Contents
- Pre-aggregates of a unit (metadata-only and low-cardinality aggregation)
- 1. What other engines precompute, and what is taken
- 2. What is precomputed (written when a unit is built, merged, promoted, rewritten by VACUUM’s merge, and by every worker of a parallel build)
- 3. Interface (phase 1)
- 4. When a unit’s pre-aggregate may answer
- 5. PostgreSQL-exact semantics (what the consumer builds)
- 6. Consumption in SiftAgg (built)
- 7. On-demand refresh of the rollups (src/col/rollup.c)
- 8. Not done, risks
- 10. Superunits: one summary for a group of units (column format minor 12, reloption superunit, default 16)
- 9. Acceptance: the ten questions of the store benchmark (10 M rows = 511 units, 1 M = 52)
- 11. Exceptions: a filter leaf IS NULL answered from the pre-aggregates (column format minor 19)
- 12. Dirty units net of their dead rows (sift.agg_dead_delta, no format change)
- 13. Dirty units with pre-aggregates net of their dead rows (VACUUM writes them; SIFT_SEG_NET, no format minor)
Pre-aggregates of a unit (metadata-only and low-cardinality aggregation)
Each stored unit of a column index carries sums, histograms (the cube) and per-list rollups, so count, sum, avg, min, max and low-cardinality GROUP BY are answered from metadata without reading rows. Results are exact; a unit that cannot answer is scanned. Size, build and timing runs are in perf/COL-PREAGG-RESULTS.md.
Versions: pre-aggregates 3.0 (column format minor 2; 3.0 writes minor 3); element rollups minor 4 (sections 2, 6, 7); on-demand rollup refresh by a worker 3.1 (section 7); rollup_columns minor 9 (sections 2, 6).
Files: src/col/preagg.[ch], src/col/preagg_opt.c, src/col/preagg_read/preagg_read.c (formats, option, compute, verify; portable), src/col/seg_write/seg_write.c, src/col/seg/seg.c, src/col/pagg.[ch] (through the column source), src/col/rollup.c (on-demand refresh), src/agg/colsrc.h (unit_exact, unit_agg, unit_hist, unit_rollup, preagg), src/agg/spec.c (sift_aggspec_preagg), src/agg/unit.c (preagg_*). Tests: the table in section 6.
1. What other engines precompute, and what is taken
| System | Taken here |
|---|---|
| Parquet / ORC footer statistics (Parquet statistics, ORC ColumnStatistics) | the zone maps (SiftCZone) hold null_count, min, max; exact 128-bit sum/sum of squares added (the zone’s int64 sum overflows) |
| ClickHouse aggregating projections (projections) | the cube is a projection GROUP BY dim with count, sum per measure; the element rollup is a projection over all a list’s dims with finalized count, sum, min, max as plain columns; no merge-time state, the unit is immutable |
| Apache Pinot star-tree (star-tree index; Xin et al., Star-Cubing, VLDB 2003; Gray et al., Data Cube, ICDE 1996) | cube(dim: m1, m2) is a dimensionsSplitOrder of depth 1 with SUM/COUNT pairs (a query may use it only if all its aggregates and its one group column are stored); the element rollup keeps every dimension of the list in one level, no star nodes; rollup_columns does the same for the table’s rows: distinct tuples of two to six scalar columns per unit, any subset answered by adding up tuples, capped per unit |
| Druid ingestion rollup (rollup) | the element rollup is the ingestion rollup per unit, beside the rows; a query that does not fit scans them |
| DuckDB, Snowflake zonemaps and micro-partition metadata (micro-partitions) | sift_col_unit_exact + zone cover test |
| PostgreSQL (xaggr, index-only scans) | visibility rule of section 4; section 5 builds PostgreSQL’s own transition states |
A unit is immutable, so correctness under DML is the visibility rule alone. Results are PostgreSQL-exact (no sketches).
One rollup of all a list’s dimensions per unit (a star-tree answers a dimension subset only if the split order puts the query’s dimensions first; a projection per subset copies the data; ingestion rollup discards the rows). A unit and list keep the distinct tuples of up to 6 low-cardinality dimensions with count(*) and, per measure, the non-NULL count, sum, minimum and maximum. Any GROUP BY over a subset and any filter on the dimensions is one pass adding up the tuples that agree on the group key. The price is that all dimensions are in every tuple, so the tuple count is the number of distinct combinations in the unit (bounded by a cap and the byte budget; the highest-cardinality dimension is dropped greedily when the product explodes). Example: 28 units of 100,000 documents (13,800 elements each) hold 36 tuples for kind, flag and 1,440 for kind, sub, flag (12, 40 and 3 values, every combination present). A query by a dropped dimension, an unstored measure or a high-cardinality key scans that unit.
Not covered: count(DISTINCT x) (except of a column the cube holds a histogram of: its groups are its distinct values, so answering units add them to the set the scan fills, sift.agg_preagg_distinct; one column, no FILTER, plain 32-bit or text); aggregates not additive over tuples (percentile_*, mode, string_agg, array_agg, stddev of floats); min/max/sum of an unstored measure or an expression; group keys with more distinct values than the tuple cap in a unit (that unit scans); numeric (decimal) measures of jsonb (section 8). Sketches (HLL, t-digest) would be approximate; answers here are exact or the unit scans.
2. What is precomputed (written when a unit is built, merged, promoted, rewritten by VACUUM’s merge, and by every worker of a parallel build)
- Placement: after the zone arrays, inside the
ZONESsection of the segment (the segment header has no free section: BM25 shares it). HeaderCOLP(version, ncols, nsb, offsets); no unit record is touched, so a metadata query never reads a chunk. A segment of minor 0, 1 or 2 is refused with the REINDEX hint. - Leaf sums (every INT-class leaf, children of lists included): exact
sumandsum of squaresas int128 (u64 lo,i64 hiarrays over the units, 40 bytes per leaf and unit).sumsqleaves int128 only for int8 values of about 1018: flagSIFT_CAGG_SUMSQ_OVF, the consumer then scans. Zone data are reused forcount,nnull,min,max, floatsum(row order of the unit). - Cube (
sift_cpagg_cube): per unit and dimensionGROUP BY dim -> count(*), and per measure the non-NULL count and the sum (INT measure: exact int128; float: double in row order, seeded with the first value asfloat8pldoes, so a group of-0sums to-0). Group keys are in key domain, ascending, NULL group last: a text dictionary leaf by segment code (translate by value through the segment dictionary), an integer,date,boolornumeric(p,s)leaf by its stored value (a leaf holdingdate_trunc('month', ts)is the time dimension). Stored as varints (counts, key deltas, zigzag int128), framed by a directory so one dimension decodes alone. - Dimensions are scalar INT/STR leaves (not the presence leaf
<column>#presentof a jsonb document, a flag of the column; no floats:-0andNaNgroup by value in PostgreSQL), top level, not inline text, no overflow value. A dimension with more than 256 groups in a unit has no histogram there (cap;cube(...): 4096). Measures:numeric,real,double,int2/4/8leaves, at most 8 per dimension in the default (decimals and floats first, then integers); the dims a unit keeps are chosen by size, smallest histogram first, under the budget below, and a kept dim carries all its measures: +1.6% index bytes on a 10-column table with 4 dims (+0.0% to +1.6% from 25k to 200k rows). - Min and max per group (column format minor 7): the boolean reloption
cube_minmax(default on since 3.3;falsewrites what 3.2 wrote) makes every group of the cube of units written from then on hold, for each integer measure (int2/4/8,numeric(p,s)as its scaled integer), the smallest and largest value of its non-NULL rows (flag bit 1 of the dim body; after the measure’s sums, the minima of groups that have a value, then the maxima, as zigzag keys). The decoder refuses a group whose minimum is above its maximum and any other flag bit;sift.col_verifyrecomputes them and refuses them in a segment older than minor 7. Float measures keep none. Asift_tablewrites them too (no setting:sift.col_format_minorbelow 7 writes none), except in the calendar cells of a date or timestamp column, whose bytes fit the calendar dims' share of the unit only without them (the dim says so in its flag byte; the verify recomputes each dim as it is stored). - Calendar cells of a date or timestamp (column format minor 8,
sift_table): asift_tablehas no index option and no expression leaf, so each unit’s cube also holds, for every top-leveldate,timestampandtimestamptzcolumn, the groups of its calendar cells at the finest grain that fits: days, else months, else years (the ladder). A group’s key is the cell’s first day, as days since 2000-01-01 (adate); atimestamptzis cut in UTC days, whatever the session zone. The directory leaf isgrain * SIFT_PAGG_CAL(128) plus the column (grain 1 day, 2 month, 3 year), so the grain is recorded per unit and the reader asks for a column’s dim at whichever grain the unit has.- A grain fits when the unit has at most 1,024 cells of it (
SIFT_PAGG_CAL_GROUPS: 2.8 years of days, 85 of months, 1,024 of years), each standing for 8 rows or more on average (SIFT_PAGG_CAL_ROWS), and the dims of all calendar columns together fit 10% of the unit’s bytes (at least 8 kB, counted as built; the other dims have a budget of their own, 40%). A cell takes about 20 bytes with one measure and about 33 with four: 1,024 cells are 20 to 35 kB, the 5% of a unit of 400 to 700 kB. A unit with an infinity or a day before 4713 BC in the column, one that fits no grain, and one written before minor 8 has no dim for the column and scans. - The cube holds 16 dimensions (
SIFT_PAGG_MAX_DIMS). When plain columns and date cells ask for more, the cells come first and the default dims give way (the dates' own histograms, then the last columns; a day cell answers what they do) and a NOTICE says which. A user-writtencube(...)is kept as written (col_cube_priority). For asift_colindex an explicitcalendar_columnslist that does not fit is refused; the default or'all'keeps the columns that fit, in column order, and the build andsift.col_refreshNOTICE the ones left out, as for the other caps (a rollup no unit holds, a set summary or key bitmap not written). - Beside
jsonbpaths (3.4, same minor 8, written only where every kind of the rows is known):calendar_columns = 'all'or a list writes the date cells of an index withpaths(3.3 refused it). Not the default there: on a 13-column index with 3 date columns and 353,029 rows they grew the index by 9.3% to 12.1%. - Range tests from the cells (3.4): a unit whose date has too many days for a histogram of its own answers a comparison, an AND or an OR of comparisons on it from the cells, each decided by its span (cell inside passes, outside does not, a cell a bound cuts leaves the unit to a scan), so
WHERE d >= a AND d < b OR d >= c AND d < eis read from the cells of every unit no bound cuts (agg_cal_paths); a range with day bounds cuts the month cell of a unit that spans many months.
Keys answered from the cells. The planner answers every key that is a function of the cell: for a key of the leaf
x, the grains whose cells hold one value. Keys:date_trunc(unit, x)andextractordate_part(field, x)of adate,timestamportimestamptz, the casts::date,::timestamp, nested and combined (date_trunc('month', date_trunc('hour', x))::date,extract(year FROM date_trunc('month', x))::int), and a comparison,INorIS NULLof them as a test.- Depends only on the day (
day,week,dow,isodow,doy,isoyear,date_trunc('week' | 'day'),::date, every function of a week start, whose year or month can differ from the day’s): needs cells of days. - Depends only on the month (
month,quarter): cells of months or days. Depends only on the year (year,decade,century,millennium): any cell. - A unit whose cells are coarser than the key scans (a week key over cells of months), as does a key that changes within a day (
hour,minute,second, the timestamp itself):sift.col_rollup_missessaysrows_key_finer_than_day. The key is evaluated once per cell, at its first day, soGROUP BY extract(isoyear FROM ts)costs a group per cell. - A
timestamptzkey reads the session zone (date_trunc('month', tz)), which UTC day cells cannot answer:sift.col_rollup_missessayscombination. Over the UTC clock (tz AT TIME ZONE 'UTC',date_trunc('month', tz, 'UTC')) the key is a function of the UTC day and is answered. Other miss reasons:rows_key_not_a_column(a key of an hour or less on a timestamp);rows_key_time_zoneorrows_key_finer_than_day(adatekey finer than a day);combination(adatecolumn whose values, infinities included, are too far apart for a lookup table of its days). On adatecolumn, keys that cast totimestamptz(date_trunc('month', d)is that) use cells of days only: a month cell would give a skipped day of a zone the value of its first day. - A range on the column (
ts >= a AND ts < b, any zone for atimestamptz: the bounds are instants) is decided by each cell’s span: inside passes, outside does not, the NULL cell never does, and a unit with a cell a bound cuts is scanned. With no key, a range also answers from cells the units whose zone it does not contain; anextract(...) = korINtest of adateruns on the cells (SAP_LUT, its grains narrowed to those the test is constant over). Other tests, aFILTERof the aggregates, two keys andmin/maxof the column scan the unit.
The decoder refuses a key that is not the first day of a cell of the unit’s grain, a column that is not a date or timestamp, and keys out of order;
sift.col_verifyand the cube check recompute the cells from the chunks. Asift_tablewrites them only whensift.col_format_minoris 8 (the default) and the schema has no JSON path leaves. Since 3.3 asift_colindex keeps them for every date, timestamp and timestamptz column (reloptioncalendar_columns, default'all'or empty;'ts, d'names columns,'off'none), with the same ladder, budget and planner rules; an index with JSON paths,preagg = 'off'or acube(...)given dim by dim has none by default and refuses the option when it names columns. A superunit does not merge the calendar cells. - A grain fits when the unit has at most 1,024 cells of it (
- Rollup of the rows (column format minor 9): the reloption
rollup_columns = 'a, b'or'a, b, c: m1, m2'(defaultauto, which picks a pair atCREATE INDEXwhen one fits;'off'writes none; names are columns of the index) makes every unit written from then on hold the rollup of its rows: the element rollup below with the unit’s rows in place of a list’s elements. A cell per distinct combination of the dims' values (two to six boolean, integer, date,numeric(p,s)or text top-level columns; NULL is a value of its own) with the cell’s rows and, per measure, the non-NULL count, exact sum, minimum and maximum. Measures are the ones named after the colon, else the integer andnumeric(p,s)columns that are not dims (decimals first, 8 at most); a float column is none, because a float sum depends on the order of additions. Same blob and encoding as a list’s rollup, listed in the unit’s element blob under the leaf numberncols, which no list has; the decoder also refuses cells that do not add up to the unit’s rows, a cell with no rows, a measure with more values than its cell has rows, and a minimum above a maximum.- Bounded: a unit keeps its cells only when it has at most a tenth of its rows of them (at least 64, at most 4,096; the exact dims named, none dropped); otherwise it has no rollup and is scanned, so a high-cardinality column costs no bytes there (
Units Without The RollupofEXPLAIN ANALYZE).
- Bounded: a unit keeps its cells only when it has at most a tenth of its rows of them (at least 64, at most 4,096; the exact dims named, none dropped); otherwise it has no rollup and is scanned, so a high-cardinality column costs no bytes there (
Format-minor compatibility (a 3.2 library reads everything written under a lower setting and refuses a higher-minor segment with the REINDEX hint;
sift.col_verifyrefuses each feature in an older minor). Units written before a feature scan untilREINDEX, a merge, a promotion orsift.col_refresh(section 7) writes it:feature minor written when cube_minmax7 option on ( sift_table: always),sift.col_format_minor>= 7 (default 12); for jsonb paths, every kind of the rows known (promotion and merge lower the setting otherwise); a setting of 6 or less ignores the optioncalendar cells 8 sift_table: setting 8 (default) and no JSON path leaves;sift_col:calendar_columns(below 7 the old bytes)rollup_columns9 option on, setting >= 9 (default), jsonb-path rule as for minor 7; the on-demand refresh does not ask for it (it would turn a segment a 3.2 library reads into one it cannot), and sift.col_refreshcounts a segment without it as stale only under a setting of 8 and where the rows' kinds are knownElement rollups (format minor 4;
COLPdirectory version 2, written only by a minor 4 writer,sift.col_format_minor): for a list group (an array of objectsitems[]{k, n, x}, an array of scalarstags[], a mapm.*) each unit holds one rollup of the list whose rows are the elements: the distinct tuples of the values of up to 6 low-cardinality leaves of the list (the dims), each with its element count and, per measure (a numeric leaf of the same list, at most 8), the non-NULL count, sum, minimum and maximum. A tuple is a group of theGROUP BYof all its dims, so anyGROUP BYover a subset of dims, any filter on dims, andcount,sum,avg,min,maxof a measure are answered by adding up the tuples that agree on the subset (an element that is not an object is a tuple with NULL members, as the unnest produces it).- Blob encoding:
u8 ndims, u8 nmeas, varint ntuples, the leaf of every dim and measure, then column-wise: per dim a NULL bitmap and the keys as zigzag deltas (tuples sorted by dims, NULL after values; text by segment code, integers, dates and numerics by stored value, as in the cube), the counts, and per measure the non-NULL counts, sums (integer: exact int128 as two varints; float8: the sum in row order, seeded asfloat8pldoes), minimums and maximums. A unit’s element blob follows the column cubes:u8 nlists, then per list its leaf and byte count, then the blobs; the directory header gainsecubedir_offandecube_bytesafter the leaf-sum offsets. - A unit’s dims are in its blob: units of one index may have been written with different dims (an
ALTER INDEXbetween builds, a dim dropped for one unit), and the reader answers a unit only if the blob holds the dims and measures the query reads; otherwise that unit scans. Depth 1: a dim is a boolean, integer, date, numeric or text scalar leaf of a list that is itself a top-level column (a list inside a list is refused by the index). - Default: every list with at least one dim candidate, its dims (at most 6) and numeric leaves as measures (8 at most, decimals and floats first). Tuples are at most
min(4096, max(64, elements / 32)); dims are dropped from the one with most distinct values in the unit until the tuples fit (kind, sub, flagof 12, 40 and 3 values with 13,800 elements per unit keepkind, flag: +1.7 kB a unit, 2% of the index; naming all three costs 43 kB a unit for 1,440 tuples of about 30 bytes, +49%). Each list has its own budget of 2% of the unit’s encoded bytes, at least 2 kB, apart from the cube’s. - Explicit: the option
rollup_leaves(below) or the clauserollup(items[].kind, items[].sub: items[].score)ofpreaggnames a list’s dims (and measures); the unit holds exactly these or, when the tuples exceed the cap of 4096, no rollup for that list (it scans;EXPLAINshowsUnits Without The Rollup; no dim is dropped behind a given name). - A unit has no rollup for a list whose count overflowed (a value that is not an array, a list cut by the shredder); an overflowed dim or measure is left out. Minor 3 segments have none, are read as before in the same index, and stay as written (the on-demand refresh leaves them alone: a rollup needs minor 4 and 3.0 would refuse the segment; the merge that rewrites them adds rollups).
sift.col_verifyrecomputes every rollup from the chunks.
- Blob encoding:
- Options.
preagg:'default'(every dimension candidate; the kept dims of a unit, with all their measures and min/max, take at most 40% of the unit’s encoded bytes as built, at least 8 kB: smallest histograms kept first, sizes within an eighth of each other in the order of the columns; on disk the index holds about a quarter of that for dims of few groups, more for many);'off'(leaf sums only, no rollups);'cube(country: amount, quantity; status)'(exactly these, no budget; attribute names or shredder paths); arollup(dims: measures)clause beside acube(...)clause (each clause replaces the default of its kind).rollup_leaves(reloption ofsift_col, declarative, 3.1;rollupuntil 4.1, still accepted):WITH (paths = 'items[]{kind text, sub text, flag text, score float8}', rollup_leaves = 'items[].kind, items[].sub, items[].flag')names only the dims, with the names ofpaths; their lists are inferred, the measures are the list’s numeric leaves (8 at most), and segments written from now on hold exactly these dims (up to 4096 tuples a unit, no byte budget).rollup_leaves = 'off'disables the rollups and keeps the cube;rollup_leaves = 'default'or no option is the automatic rule.- Names are checked at
CREATE INDEXandALTER INDEX ... SET (rollup_leaves = ...); errors:invalid rollup_leaves option: "x" is not a leaf declared in paths,... is not a leaf of an array element or a map value,... is not a low-cardinality leaf: it must be a boolean, integer, date, numeric or text leaf(a float, a JSON or anastextleaf cannot be a dim),... is named twice,a leaf name is missing,the list of "x" has more than 6 rollup leaves. Too many distinct values is not a DDL error: a unit whose tuples exceed 4096 holds no rollup for that list and scans (the first query that meets it asks for a refresh once per segment, section 7). - A change applies to segments written afterwards; older units are asked for on demand (section 7) or rewritten by a merge. The boolean reloption
rollup_on_demand(default true) and the settingsift.col_rollup_on_demandturn the asking off per index and per session.
Measured:
| feature | figure | workload |
|---|---|---|
| cube, default | +1.1% to +1.2% of the index (2.4 kB a unit; off +0.07%); serial build CPU +3.9% (one hash per candidate dimension, three adds per stored measure; a high-cardinality dimension stops after 257 groups), inside the noise with workers |
300,000 to 3,000,000 rows, store benchmark index |
| cube, default, wide index | 16 columns, 8 dimensions of 50 values: GROUP BY a dimension 1.92 -> 0.32 ms (5 of 8 dimensions answered -> 8 of 8), +6.7% -> +8.9% of the index; 8 dimensions of 200 values: 5.4 -> 0.54 ms, +7.5% -> +30.4%; at most +41% (32 columns, 200 values); the store index is unchanged (the byte budget) |
1,000,000 rows |
cube_minmax |
GROUP BY kind with min/max of an int4 and an int8 5.5 -> 0.34 ms; of an int8 and a numeric(10,2) 30.5 ms normal plan, 2.6 ms scanned, 0.23 ms from the groups; +0.17% of the index (1.0% to 3.0% on a store-like table); insert, promotion, merge and VACUUM CPU inside the noise |
200,000 rows, 13 columns, 53 units |
| calendar cells | GROUP BY date_trunc('month', d) 2.4 -> 0.54 ms; UTC month of a timestamptz 2.6 -> 0.70 ms; +1.8% of the index for two columns (+1.0% for one) |
200,000 rows, 53 units |
rollup_columns |
GROUP BY kind, sub 104 ms normal plan, 2.11 ms scanned, 0.63 ms from the cells (2,688 cells, about 0.2 microseconds a cell); 2 to 8 times faster than scanning the units; index +4.6% for 96 cells a unit, +19% for 480; build CPU inside ±15% |
200,000 rows |
| element rollups | +2.0% of the index with the default rule (explicit kind, sub, flag +49%); build CPU +3.4% to +4.2%; GROUP BY kind, flag 18 to 30 times faster than its scan; 0.12 ms for 28 units and 1.3 to 2.9 ms for 276 units (5 to 10 microseconds a unit) |
100,000 and 1,000,000 documents |
The cube’s histograms remain the better answer to one key (the element rollup is 1.3 to 5 times slower than a depth-1 cube for GROUP BY kind, 0.09 against 0.12 ms at 100,000 documents, but answers two keys, which the depth-1 cube could not) and are kept for it.
3. Interface (phase 1)
- Engine (
col/pagg.h):sift_col_unit_preagg_ok(unit)(stored unit,sift_col_unit_exact),sift_col_unit_agg(unit, leaf, &SiftCAgg),sift_col_unit_hist(unit, dim, mcxt, &SiftCHistDim, &SiftColDict)(false: the unit scans). A unit of the write buffer or a delta batch has no sidecar. - Provider seam (
agg/colsrc.h, additive, NULL = none):preagg(rel, &SiftSrcPreagg)(planner: the dimensions and measures the index writes),unit_exact,unit_agg(SiftSrcUnitAgg: counts, min/max, exact sums, sumsq, float sum, flags),unit_hist(SiftSrcHist: groups,SiftSrcDictof the segment, count, per measurenn,sum/sumf; memory valid until the nextunit_hist). Implemented forsift_col(agg/col.c) andsift_table(table/src.c); both read the sidecar throughagg/pre.c. A segment of an older layout answers by the schema it was written with: a column the layout lacks, or a measure the table dropped, scans the unit. - SQL:
sift.col_unit_agg(index, leaf)andsift.col_unit_hist(index, dim, measure)(one row per stored unit / group;exactis the visibility rule),sift.col_index_infocolumnspreagg_bytes,preagg_units;sift.col_verifyrecomputes sums and cubes from the chunks (C4).sift._test_col_pagg_foldbenchmarks the metadata path. - sift_table owner (
tbl_preagg): sealed segments carry the sidecar (same writer); the table provider has the four callbacks over the table’sSiftColUnit(the samesift_col_unit_*calls). A table unit is exact whensift_col_unit_preagg_okholds:SiftColVisibility.sb_mask(tbl_sb_mask) is all-ones only for a superblock with no tombstone and no write buffer row (UPDATE/DELETE invalidate only the superblocks they touch); delta pages are in-memory units and scan.SiftSrcStats.exact_fracestimates the qualifying share of superblocks.
4. When a unit’s pre-aggregate may answer
- Visible and clean (
unit_exact): a stored unit with no dead row (SB not dirty in the segment’s liveness bitmap: VACUUM marks it, a merge rewrites and recomputes it), and every heap page it has rows on is all-visible to the snapshot (VM, as index-only scans; never for a non-MVCC snapshot). A unit with dead rows whose pages are all-visible answers for counts and integer sums net of them (section 12). Rows inserted later are other units (write buffer, newer segment), scanned by the normal path and added to the result, so the aggregate describes exactly its own rows. Inserts, updates and deletes cost only the units they touch. - Predicate covers the unit (consumer): no predicate; or the predicate on the dimension itself (
=,IN, range: select groups by key value, text by the dictionary); or a zone-covered predicate (min..maxof the leaf inside the range withnnullaccounted: the unit answers whole, a disjoint unit is pruned); orIS NULL/IS NOT NULLof a scalar leaf that is not a dimension, which the zone’snnullandnvalsdecide (IS NOT NULL: no NULL in the unit;IS NULL: only NULLs; no NaN stands in for one). A unit the predicate cuts through is scanned. A predicate on a leaf other than the dimension is never answered from a histogram. - Leaf not lossy: no
SIFT_COLZ_OVERFLOWin the leaf and unit (unit_aggfalse), no inline-text zone for text min/max.
5. PostgreSQL-exact semantics (what the consumer builds)
| aggregate | from | notes |
|---|---|---|
count(*), count(x) |
nvals, nvals - nnull; group count, nn |
int8 |
sum(int2/int4) |
int128 sum | int8 when it fits (else the unit scans, PostgreSQL would raise); sum(int8) is numeric (its state is an int128 sum) |
sum/avg(numeric(p,s)) |
scaled sum / 10s | every value has dscale s, so the NumericAggState (N, sumX, maxScale s) is built from (count, sum); final function is PostgreSQL’s |
avg(int) |
(count, sum) | build int8[2] / Int128AggState, call int4_avg, int8_avg |
stddev/variance(int, numeric) |
(count, sum, sumsq) | exact Int128AggState/NumericAggState with sumX2; sumsq overflow: scan |
sum/avg(float8), avg(float4) |
unit sums in row order | same order caveat as PostgreSQL’s parallel sum (float addition is not associative); refused: sum(float4) (float4 accumulator), float stddev (Youngs-Cramer Sxx is not stored) |
min/max |
zone key; per group of a cube with cube_minmax, the stored minimum or maximum of the integer or numeric measure |
integer, date, time, numeric, float (NaN last, as PostgreSQL); -0 vs 0 ties may print either; not text (codes and prefix keys are not order); per group not float |
An all-NULL measure group (nn = 0) yields NULL, the group still counts its rows; a NULL key is its own group. Text group keys are compared bytewise: deterministic collations only (as SiftAgg).
6. Consumption in SiftAgg (built)
- Shapes (
sift_aggspec_preagg,agg/spec_preagg.c: the planner costs with it, the executor starts with it). A row-level scan of onesift_colindex (no join, combo, set, arm or unnest) with no key, or one key that is a dictionary text,int2/int4,int8,numeric(p,s), date or bool column the index writes a histogram of (SiftSrcPreagg; anint8or numeric key is the column itself, not an expression, and its group is the stored integer). Aggregates:count(*),count(m),sum/avg(int2, int4, int8),sum/avg(numeric)of a measuremof that histogram,count(key),min/maxof an integer or numeric measuremper group when the cube holds them (cube_minmax), and, without a key,min/maxof an integer, date or numeric column.- Conjuncts: on the key’s column (without a key: on one column) any test the executor evaluates (
=,IN,IS NULL,LIKE, comparisons, expressions of the column) runs on the groups; on any other column only an integer or date comparison the unit’s zone lies inside (without NULLs), orIS [NOT] NULLof a leaf that is no dimension when the zone’s NULL count holds the test for every row (the soft-delete shape,WHERE deleted_at IS NULL), keeps the unit answerable. - Scanned as before: float sums (addition order),
count(DISTINCT),min/maxper group of an index or unit without them, a floatmin/maxper group, two keys, an expression of a key, tests on other columns, textmin/max.
- Conjuncts: on the key’s column (without a key: on one column) any test the executor evaluates (
- Executor (
agg/unit.c,preagg_unit, called byprocess_unitfirst):sift_colsrc_unit_exact, the zone test of the other columns, then the unit’s sums (no key) or histogram. A text group is translated to the key id once per dictionary (dict_kmap), an integer group through the range table (key_cover, shared withkey_prepare); conjuncts on the key column run ineval_predon a vector whose rows are the groups; each kept group adds its rows toseenand itsPaVal(rows, non-null rows, exact int128 sum) to the group table’s aggregate states (preagg_add:count,sum/avgof int,sum/avgof numeric in 128 bits,min/max). A unit that cannot answer (dirty page, write buffer, no histogram for the column in that unit, a measure the histogram lacks, an overflow value) is scanned into the same table, so a mixed table is exact and workers need no change. - Cost (
agg_cost,preagg_share): the share of exact units is the share of all-visible pages (allvisible_frac); they costAGG_UNIT_COST + groups * AGG_PREAGG_GROUP(25 operators per group, about 80 ns), the others the scan. From 98% exact no parallel path is offered: starting workers costs 3.1 ms (1 M) to 6.5 ms (10 M), more than the answer. - Element level (
sift_aggspec_preaggwithgroup >= 0,preagg_rollupinagg/unit.c): the unnest of an array leaf (jsonb_to_recordset,jsonb_array_elements,jsonb_array_elements_text) or a map (jsonb_each,jsonb_each_text: the key is the map rollup’s dim, the value its measure) over that list’s rollup.- Group keys: none, one or several of the unit’s dims in any order, or a function of one dim (
upper(kind),left(day, 7), aCASEon an integer dim: evaluated once per distinct value of the tuples). Conjuncts on dims:=,<>,IN,IS [NOT] NULL,LIKE, comparisons and anORof them. Aggregates:count(*),count(x)of a dim or measure,sum/avg/min/maxof an integer or float8 measure of the same list, each with an optionalFILTER (WHERE ...)on dims; on the rows only the zone comparisons above. - Every kept tuple adds its count and per-measure states to the group table (
preagg_fold), through the key ids, the direct address table or the scan’shash_group, so a mixed table is exact. A unit that lacks a dim of the query, a measure it reads, or the rollup itself is scanned (other units still answer) and asks for a refresh (section 7). - Not answered: a conjunct on a leaf that is not a dim (
WHERE e.q > 3), a key of the rows (d.grp), a key that is not a dim or a function of several,count(DISTINCT), aFILTERon a measure,percentile_*, an aggregate of an expression, a float aggregate other thansum/avg/min/maxof a float8 measure,sum(float4). A float8 sum adds tuples where the scan adds rows, so last digits can differ (as for a parallel sum); the unit scans when a value of the float leaf is not finite or above 1e100 forsum/avg(min and max only refuse NaN), soavg(float8)keeps its overflow check. EXPLAINshowsUnits From Pre-aggregates,Pre-aggregate DimsandMeasures(what the query reads),Units Without The Rollup(exact units scanned for lack of it) andRollup Refresh Queued(segments asked for, section 7).
- Group keys: none, one or several of the unit’s dims in any order, or a function of one dim (
- Rows level (
sift_aggspec_preagg_whytriespreagg_rollupwith group -1 when the histograms cannot answer a scan of the rows;agg/preagg/answers it): for an index withrollup_columns, a scan of the rows with two or more group keys, or a key and a conjunct on another column, or a conjunct on a column other than the histogram’s whose zone a unit must lie inside (which only the units of a clustered column do). Group keys are dims, one or several, in any order; every conjunct tests dims only (=,<>,IN,IS [NOT] NULL,LIKE, comparisons, anORof them) and runs on the cells, so a range of an unclustered column or anINlist with gaps is answered too. Aggregates:count(*),count(x)of a dim or measure,sum/avgof asmallint,integer,bigintornumeric(p,s)measure,min/maxof one, each with an optionalFILTERon dims. A query the histograms answer (one key, tests of that key’s column, no test at all) keeps them: they have fewer groups than the cells.- Not answered: a float aggregate,
count(DISTINCT), an expression of a key (a % 3) or of an aggregate’s argument beyondcolumn * k + c, a conjunct on a column that is not a dim, grouping sets, top-N, and a dim ofint8ornumeric(p,s)as key or test (the cells hold them, the executor tests 32-bit values and dictionary codes). EXPLAIN ANALYZEshowsPre-aggregate Rollup: rows,Pre-aggregate DimsandMeasures,Units From Pre-aggregates, andUnits Without The Rollupfor exact units that had none (written before the option, or over the cap);EXPLAIN (ANALYZE, VERBOSE)gives the reason of a refusal. The cost is the element level’s (next bullet).
- Not answered: a float aggregate,
- Rollup or scan, by cost (
rollup_cheaperinagg/unit.c,sift_aggspec_unit_costsinagg/spec_preagg.c, the planner’spreagg_unit_ops): a tuple costs more than an element: the varint decode of the columns the query reads and the fold into the groups take 300 to 800 user instructions, where a scan spends 6 to 150 on an element (counts of one key 17, three keys 50, three float aggregates with min and max 150; 100,000 documents, 400,530 elements, 41,760 tuples in 29 units,perf stat). Rollup against scan, per unit of 13,800 elements and 1,440 tuples (10%), millions of instructions:GROUP BY kindwithcount14.6 against 7.0;GROUP BYthree keys 19 against 20; floatmin/max32 against 54 to 78.- Per exact unit the executor takes the cheaper: the rollup’s tuples (read from its header) times a tuple’s cost for this query, against the unit’s elements (the zone of one of its leaves) times an element’s cost. Units scanned that way show as
Units Scanned By Costand ask for no refresh.sift.agg_rollup_costscales the tuple (0: always the rollup; the rollup tests set it). The planner prices exact units with the same two costs and the tuples it expects (the product of the rollup dims' distinct values, at most the unit’s elements and 4096). - Looking at a unit (its exactness, its rollup) costs buffer reads too: after three units in a row whose rollup costs at least a quarter more than their elements, the next sixteen are scanned without looking, and one is looked at again.
- Per exact unit the executor takes the cheaper: the rollup’s tuples (read from its header) times a tuple’s cost for this query, against the unit’s elements (the zone of one of its leaves) times an element’s cost. Units scanned that way show as
- Switches and EXPLAIN:
sift.agg_preagg(on) turns it off for plan and executor;EXPLAIN ANALYZEshowsUnits From Pre-aggregatesnext toUnitsand countsRows Scannedfor scanned units only (the line appears only when the shape qualifies).EXPLAIN (ANALYZE, VERBOSE)of a node that cannot use them printsPre-aggregates: <reason>(sift_pawhy_name, the first failed check in this order: switch off, none stored, arms, grouping sets of the rows (those over a list’s elements are answered), joins, combos, post-aggregation, virtual leaves, no aggregate, search, top-N; then the rollup checks of keys, conjuncts and aggregates, or the histogram checks of the rows). Tests (each shape runs three ways: pre-aggregates, a scan of every unit, the heap; regress unless noted):
area tests histograms, cubes col_preagg(every leaf and histogram against direct SQL after build, DML, VACUUM, promote, merge and a parallel REINDEX, on NULLs,NaN,-0, infinities, int8 near 1019, negative integers, and invalid options);agg_preagg(default, explicit-cube andpreagg = 'off'indexes;presays all, some or none of the units answered);pa_*fixtures ofscripts/coldiff.shmin/max, wide keys agg_preagg_minmax(with and withoutcube_minmax, built under minor 6 or given the option after the build),col_preagg_minmax, the minor 7 step of the TAPextension_lifecycle;agg_preagg_wide_key(int8 and numeric group keys and tests)rows rollup agg_preagg_rows(named and default measures, a dim over the cap, no option, the setting 7),col_preagg_rows, the minor 9 step ofextension_lifecycleelement rollups col_preagg_elem(every histogram and rollup againstjsonb_array_elements/jsonb_eachafter DML, VACUUM, promote, merge, parallel REINDEX, minor 3, 4 and mixed indexes),agg_preagg_elem,agg_preagg_rollup(default, explicit-rollup, minor 3 and mixed indexes, dims in any subset, filters, min/max, maps),agg_preagg_elem_parallel(a Gather over a partialSiftAggon a mixed index),pa_elem_*andpa_roll_*fixtures, rows AP08 to AP12refresh col_refresh,col_rollup_refresh, the isolation speccol_refresh, the TAPscol_refresh_crash(a PANIC before the replacement, a cancel and threekill -9inside a refresh),col_refresh_compat(an index of the real 3.0.1 library, refreshed at minor 3 and read back by it) andcol_rollup_ondemandsuperunits agg_preagg_super(groups of 2, 4 and 256, no option, built under minor 7, DML, VACUUM, promotion, merge, an option set later, a parallel build),col_preagg_super(segment minors, byte for byte damage of the block, verify, merge, delete, VACUUM), the minor 8 step ofextension_lifecycleexceptions agg_preagg_exceptions(counts, integer, numeric and float measures, min/max, the guard, DML, VACUUM, merge, REINDEX, minor 14, groups of zero rows), the fuzz familyexceptions,col_index_crash,col_index_replica,compat_matrix(a minor 19 clone is refused by the releases below)Not done: a leader metadata pass that decides workers at run time (the planner decides: a table whose pages are not all-visible when planned gets the parallel path, and exact units are still answered by whoever scans them); a depth-2 cube of the top level; float
min/maxper group; float sums of the rows (the elements' are answered); rollups for asift_table(the writer is shared, the planner callback is not); the planner costs an index of minor 3 segments as if their units were exact.
7. On-demand refresh of the rollups (src/col/rollup.c)
A rollup is written when a unit is built, so ALTER INDEX ... SET (rollup_leaves = ...), a new dim, a measure the units were written without, or an index built before 3.1 leaves units that a rollup could answer. The index learns from its readers: a tier merge rewrites a segment only when its tier fills and REINDEX rewrites everything.
- Request. A
SiftAggthat scans an exact unit only because its rollup is missing, was written with other dims, or lacks a measure the query reads (Units Without The Rollup) records the unit’s segment, once per segment and generation, in a bounded shared-memory queue (64 segments; a named DSM segment, noshared_preload_librariesneeded; a full queue drops the request and counts it).Rollup Refresh Queuedshows how many units of the query asked. The SELECT writes no page and takes no extra lock, so it works in a read-only transaction; a parallel worker asks for the units it scans and the leader adds its counts; a hot standby never queues (RecoveryInProgress()). The result does not change: the unit was scanned. - Refresh. A background worker (one per database, started at the first request, launched at most every 2 s, at most 8 databases at a time) rewrites a queued segment as a merge of that segment alone does (
sift_col_merge_segmentswithn = 1, with the index’s current options) under the maintenance lock, replacing the old segment with the atomic directory update of every merge. VACUUM, merges and promotion wait on the maintenance lock as they do for each other; readers and writers do not (a reader keeps the old segment through its directory snapshot). A request for a generation no longer in the directory is skipped and counted. A crash before the replacement leaves the old segment and the orphaned new one for the sweep. The granularity is the segment: aCREATE INDEXsegment is the whole table of one build worker, and rewriting it also rewrites units that already had their rollup. - Not asked for: dirty units (scanned for another reason, rewritten by the next merge); units of a minor 3 segment (a refresh would write minor 4, which a 3.0 reader refuses); any server with
sift.col_format_minor = 3; a unit whose explicit rollup exceeded the cap (refreshed segments are remembered by index, generation and options; the memory is lost at a restart, costing one more rewrite per segment); an index withrollup_on_demand = false; a session withsift.col_rollup_on_demand = off. - Switches.
sift.col_rollup_on_demand(on, per session), the index optionrollup_on_demand(true),sift.col_rollup_worker(on; off leaves requests queued forsift.col_rollup_refresh(index), which serves them in the calling session and returns the segments rewritten: refresh on a schedule, or in a test). - Refresh of every segment on request:
sift.col_refresh(index [, max_segments])(returnsrefreshed,remaining).ALTER INDEX ... SET (preagg, rollup, cube_minmax)changes what units written afterwards hold; the call rewrites the segments written before, without waiting for queries (the on-demand refresh covers rollups only) or aREINDEX.- A segment is stale when a header-only census of its units' cube and element blobs (
sift_cpagg_census, no chunk read) finds a dim, a measure set (exactly the named ones withcube(...)), a rollup list or rollup dims (exactly the named ones withrollup(...)), or min and max that the options andsift.col_format_minordo not ask; or when a section no unit holds is one every unit can: acube(...)dim whose values lie in a range, or whose dictionary has the entries, of its group cap, a default dim of at most 32 groups, the dims ofrollup(...), the default rollups in a minor 3 segment. A unit over a cap or byte budget has none, as when written, and is no reason to rewrite. - Each stale segment is rewritten as the merge of it alone (the on-demand path: same rows, dead ones dropped, same answers) and replaces the old one in one WAL-logged directory change under the maintenance lock (the call holds
RowExclusiveLockon the index, the lock of an insert). The new segment is written before the old one is released, so a cancel or crash leaves finished segments and the one in hand as they were; calling again does the rest. max_segmentsbounds the rewrites of a call (NULL: all, 0: only count what is stale). A segment of a minor abovesift.col_format_minoris left as written (a rewrite would drop what only that minor holds: at 3 the index stays what 3.0 reads, and a 3.0.1 library verifies and answers from the refreshed segments); a rewritten one is written at the setting. The old segments' space is reused afterVACUUM, so the file holds up to one more copy of the index until then. A server remembers the segments it refreshed and does not rewrite them again for a unit that cannot hold a section. Not on a hot standby (it replays the rewritten segments).
- A segment is stale when a header-only census of its units' cube and element blobs (
- What the planner counts. The memoized segment statistics (read again when a segment,
preagg,rollup_leaves,cube_minmaxorsift.col_format_minorchanges) also count the units that hold each dim’s histogram, with min and max, and each list’s rollup;SiftAggis priced for pre-aggregate answers on that share of units (preagg_share), so a half-refreshed index costs between the two, and a dim no unit holds (a column with too many values) no longer counts as answered from the cube. - Observation.
sift.col_rollup_state(requested, refreshed, skipped, dropped, queued, refreshing: server counters since start) andsift.col_rollup_requests(index, segment generation, units that asked, time, statequeuedorrefreshing); the objects are the last block ofsql/pg_sift--3.1.sqlandsql/pg_sift--3.0--3.1.sql. - Not done: a miss counter that advises which dims to name in
rollup_leaves; requests ask for what the option says.
Measured: adding min and max to every segment of a 200,000-row index takes 81 ms (23 ms at 25,000 rows; time grows with the rows rewritten) and the GROUP BY with min/max goes from 3.3 ms scanned to 0.24 ms from the groups; after ALTER INDEX ... SET (rollup_leaves = 'items[].kind, items[].flag') on 1,000,000 documents (276 units) the next query answers all units from the rollups 1.9 to 3.1 s after the ALTER (a transaction that began before the replacement keeps scanning: the new segment is newer than its snapshot).
8. Not done, risks
- Not supported: time buckets derived inside the writer (
date_truncof a timestamp leaf), becausetimestamptzbuckets depend on the sessionTimeZoneand month arithmetic is the host’s. A leaf that holds the expression is the dimension; for time-clustered tables the zone cover test already answers units inside one month from the unit sum. - The top-level cube has depth 1 (one dimension per histogram) and, unless the index has
cube_minmax, no per-groupmin/max; lists have the full rollup (section 2). No float variance, no sketches. A dimension over the cap in a unit has no histogram there, silently: the consumer scans that unit (unit_histfalse). - Element rollups are not written by a minor 3 writer and are refused by a 3.0.x reader like every minor 4 segment.
- Numeric measures of a jsonb path. A rollup measure is a
smallint,integer,bigint,numeric(p,s),realordouble precisionleaf. A jsonb path has no numeric type (pathstypes arebool, int2, int4, int8, float4, float8, text, astext), sosum(e.amount)ofjsonb_to_recordset(...) e(amount numeric)is scanned, and a jsonb amount is a measure only asint*orfloat8. Why: PostgreSQL’snumericsum has the display scale of the widest input (1.5 + 2.5 = 4.0,1.50 + 2.50 = 4.00) while jsonb keeps each value’s scale as written (the shredder renders numbers withnumeric_normalize); scaled integers of one declared scaleswould print4.00for a group written with one decimal, and the pushdown must return exactly what the normal plan returns. Two designs, neither implemented (each adds a path type to the shredder, the planner andcol_verify): (a) a declarednumeric(p,s)path type whose slot holds the value times10^sonly when the jsonb value has exactlysfractional digits (any other value is an overflow slot that scans; no format change beyond minor 4); (b) each value’s owndscale(a nibble per slot) and the maximum per rollup tuple (a chunk flag, a rollup field and a verify rule: a minor 5). - Churn: a unit with a dead row (liveness bit cleared by VACUUM) answers from its rows, or for counts and integer sums net of the dead rows when its pages are all-visible (section 12); a page that is not all-visible, a minimum, a maximum, a distinct count or a float sum are read from the rows; correctness never changes. The way back needs no
REINDEX:VACUUMrewrites a lone segment more thandead_rewrite_pctpercent dead (sift_coloption, default 10; 100 never; the BM25 index keeps 50),sift.col_promoteand the worker store the rows written since, and the tier merge rewrites the rest (col_preagg_maint). Below the threshold a segment keeps its dirty units on purpose: uniform deletes dirty every unit at 0.1% dead, and each rewrite costs the whole segment. PostgreSQL’sVACUUMskips the index and leaves pages not all-visible when few tuples are dead (INDEX_CLEANUP auto, under 2% of the pages): units come back at the firstVACUUMthat vacuums the index, as for index-only scans. Asift_tablehas no pre-aggregates yet, so none of this applies to it. - Dictionaries are per segment and parallel builds have one per participant: translation by value is per segment, not per unit.
10. Superunits: one summary for a group of units (column format minor 12, reloption superunit, default 16)
Answering from the pre-aggregates costs a read, a decode and a fold per exact unit (5 to 16 microseconds), so a whole-table aggregate is linear in the number of units (sections 6 and 9). A superunit is superunit (2 to 256) consecutive units of a segment with the merged sums and cube of the group stored once more, so the group is one read, decode and fold. The reloption superunit (default 16 since 3.3; 0: none, the old bytes) makes segments written from now on column format minor 12 (under sift.col_format_minor = 12, the default; 10 and below write what they wrote before).
Default 16: +1.0% of the index (200,000 rows, 53 units: whole-table count/sum 0.26 to 0.37 ms by the units, 0.05 to 0.08 ms by the groups); groups of 64 cost +0.8% but a range predicate cuts them more often into units, groups of 4 to 8 cost +1.3% to +1.5%. Insert, promotion, merge, UPDATE and VACUUM CPU stay inside the noise, and queries a summary cannot answer (a float sum, a filter on a column the cube lacks, count(DISTINCT)) stay within 10% of the plan without it. The one cost is a range query on a table of fewer than 16 units (2 units: 0.10 to 0.13-0.15 ms), the exactness test of a group that never forms.
- What is stored. After the element cubes and the small-set summaries, in the
COLPdirectory of version 4 (SuperHeader,src/col/seg_int.h): the page mask of every unit (32 bytes: the superblock pages that hold rows, so a unit’s exactness is decided without reading its record); for every INT leaf and group the mergedcount,nnull,min,max, exactsumandsum of squares(64 bytes); per group the merged cube: the dims every unit of the group holds with the same measures, added up by key (text by segment code), counts, non-NULL counts, exact sums and, withcube_minmax, the smallest and largest values. Left out: a float measure (its sum depends on the order of additions, which the units fix), a dim with more than 4,096 groups in the group, a leaf with an overflow value in a unit (no summary for that group) and list rollups. Written bysift_cpagg_super_leaf/sift_cpagg_super_cubefrom the stored units' bytes and recomputed bysift.col_verifywith the same functions, so the bytes are checked byte for byte; the reader refuses an unknown flag, a record a group of its size cannot hold, sizes that do not add up, and a directory out of order. - When a group answers. The scan hands over a group of a segment as one unit (
SuperunitsinEXPLAIN ANALYZE) when every unit of the group passed the zone tests and is exact on its own (section 4, tested per unit in the orderunit_allvisiblethenunit_clean: all-visible bits, directory, liveness) and the whole group lies inside the scan’s range. The consumer applies the same rules as for a unit to the merged zone (a predicate on another column must cover the mergedmin..max) and the merged cube. When it cannot answer (a predicate cuts the group, a measure or dim the merge left out, a float sum), the group is split and its units are read one by one; after four splits and no answer in a scan it stops asking. The three ways (summaries, units, plain plan) return the same rows.sift.agg_super(on) turns the use off. - Cost, size. The block is 0.5% to 1.5% of the index (+1.7% with groups of 16 at 200,000 rows of 1,024 rows a unit) and is built from the units' own bytes at the end of the segment (no second pass over the rows). Parallel scans take superblocks in ranges of
superunit * 4 * segments, so a group fits in a range; a group cut by a range’s end is read as units. - Scaling. A scan of the pre-aggregates over N rows is O(units); with groups of F units it is O(units / F) reads, decodes and folds plus, per unit, the exactness test (the all-visible bits of its pages against the stored page mask, one liveness bit), under 0.1 microsecond a unit with groups of 64. Measured with groups of 64 from 25,000 rows (25 units) to 400,000 rows (391 units), PostgreSQL 18.6, 2026-10-22:
GROUP BYa 20-value key withcountandsum0.34 -> 0.18 ms and 3.09 -> 0.22 ms; the superunit time stays flat while rows and units grow 16 times, the unit time grows with them (perf/COL-PREAGG-RESULTS.md). A predicate covering part of the table answers the groups inside it and the units at its edges: with groups of 16 a range over 87% of the rows takes 3.51 ms by the units and 0.96 ms by the groups at 400,000 rows; with groups of 64 only groups inside the range count (one a segment), so small groups suit range predicates and large ones whole-table aggregates. A query the summary cannot answer costs the exactness tests of the groups it tries, then is read as units. - Not done. A second level (groups of groups: a tree whose range query combines O(log units) nodes), merged element rollups, merged float measures in an order-independent form, and groups for a
sift_table(its provider has no sidecar callbacks).
9. Acceptance: the ten questions of the store benchmark (10 M rows = 511 units, 1 M = 52)
| q | question | from pre-aggregates |
|---|---|---|
| 01 | one order by id | no (btree) |
| 02 | orders per country | yes: histogram of country, 30 groups |
| 03 | revenue per month | yes: histogram of the date_trunc('month') leaf, sum(amount), 36 groups |
| 04 | top 10 categories by revenue | yes: histogram of category, sum(amount), 40 groups, sorted above |
| 05 | average of shipped, DE, 2025 | no: three predicates on three dimensions, no unit covered (rows are in id order, dates spread) |
| 06 | customers per month | no: count(DISTINCT) is exact in PostgreSQL; a per-unit set costs as much as the scan |
| 07 | orders over 100 in 30 days | no: row-level amount compare, no unit covered |
| 08 | search count | no (BM25 count) |
| 09 | search facets | no: the BM25 match set is a row subset; partial units cannot use a histogram |
| 10 | search, newest | no |
Questions 02, 03 and 04 are answered entirely from metadata at 10 M (all 511 units, Rows Scanned 0, results identical to the scan and the heap): 8.0 -> 2.1 ms, 35.0 -> 1.4 ms and 46.8 -> 3.6 ms, 6x, 46x and 13x faster than ClickHouse and 2.7x, 160x and 74x faster than Elasticsearch (medians). 05 and 07 stay scans; a depth-2 cube (country, status, month: up to 5 400 groups per 19.6 k rows, about 12% of the index) would answer 05 but is not worth it by default. 06 stays distinct-set work.
11. Exceptions: a filter leaf IS NULL answered from the pre-aggregates (column format minor 19)
A leaf that is NULL in nearly every row (a soft-delete stamp) keeps WHERE stamp IS NULL from the pre-aggregates. Without exceptions a unit holding one stamped row fails the zone test and is scanned (166 of 171 units of a 353,029-row table with 655 stamped rows).
- What is stored. Per unit, when exactly one top-level INT leaf with no overflowed value has at most 1 row in 100 not NULL (the fewest, the first of equals), the cube and element rollups of those rows, written with the dims and measures of the unit’s own cube so groups match by key. The
COLPdirectory is version 6: four more entries (directory and bytes of the exception cubes and exception element blobs, laid out like the cubes; an empty blob: the unit has none); the blob starts with the leaf. They are kept when their blobs take at most half of the unit’s own, or one byte for each 32 rows of it (a unit cut by the grouping column holds few groups, and its cube is as small as its exceptions). A segment with any exception is column format minor 19. No option: the unit’s own data decides; only thesift_colwriter writes them (asift_tablewrites none), andsift.col_format_minor14 or less writes none (the default setting, 15, writes them). - What a reader does.
leaf IS NULLover a unit, as one conjunct among others the pre-aggregates already decide, is the unit’s aggregate minus the exceptions' (sift_col_unit_except;hist_subtractandrollup_subtractmerge-walk the keys of both);leaf IS NOT NULLis the exceptions alone. Counts, integer and numeric sums and averages and the standard deviations subtract exactly.minandmaxare not subtractable: the measure is dropped and the unit scanned. A group whose count falls to 0 is kept at 0 (a group of zero rows is not emitted). A float sum is guarded because the difference of two float sums cancels digits:|result| * 64 < |total|or not finite scans the unit for that measure (Units With Exceptions ScannedinEXPLAIN ANALYZE); otherwise it equals the scan within the tolerance float pre-aggregate sums already have. - Exactness. Exceptions follow the unit’s own rule: a dirty or not all-visible unit is scanned. A soft delete is an UPDATE and changes nothing stored; the merge or VACUUM rewrites the unit and the writer recomputes its exceptions from the rows (merge, defrag and REINDEX read rows, never sidecars).
sift.col_verifyrecomputes the stored exception leaf from the chunks and compares both blobs byte for byte. Missing optional summaries are valid; an older writer may also have selected another qualifying leaf. - Upgrade. A segment of minor 18 or less has no exceptions and reads as before (the unit is scanned for the filter); the merge, VACUUM’s rewrite and REINDEX write minor 19. A library that reads up to 18 refuses a minor 19 segment with the REINDEX hint;
SET sift.col_format_minor = 14and REINDEX writes the old bytes. - Measured (353,029 rows, 171 units, 655 stamped rows, 166 units with exceptions, 3.95 rows a unit): index segment bytes +1.41% (6,381,568 to 6,471,680, no jsonb paths).
SELECT k, count(*), avg(m) WHERE stamp IS NULL GROUP BY k: units from pre-aggregates 5 to 171 of 171 (166 net of exceptions, 0 scanned), 6.1 to 2.5 ms, rows scanned 342,920 to 0; the same on the index with jsonb paths. A date range together with the filter decides only the units the range covers (2 of 148): the edge units are scanned with or without exceptions.
12. Dirty units net of their dead rows (sift.agg_dead_delta, no format change)
A VACUUM that removes a row version clears its liveness bit in the unit, and the unit is then not exact (section 4): it was read by its rows however few of them were dead. After a month of churn that is most of the units of a table (637 of 721 at 3,000,000 rows and 20% a month, with 7.6% of the rows dead), and the pre-aggregates sat unused although every page was all-visible.
- What a reader does. When every page the unit has rows on is all-visible (read before the liveness, as
unit_cleandoes), the unit has no exceptions, and at mostsift.agg_dead_deltapercent (default 15) of its rows are dead, the unit answers from its stored cube and rollups minus its dead rows (col/delta.c). The dead rows are the cleared bits of the unit’s liveness; the values of the leaves a reader needs are decoded at their positions only (sift_cchunk_decode_sel), and each dead row is taken off the group of the histogram (the calendar cell of its date for a calendar dim) or the tuple of the rollup its values name, found by binary search: the count, the count of non-NULL values and the exact sum of each integer or numeric measure go down. A group whose count falls to 0 stays at 0 and is not emitted, as for the exceptions (section 11). The unit’s row count for the statement is its live rows. - What it gives, what it does not. Counts, NULL counts, integer and numeric sums, averages and the filters the zone decides (
min..maxof the full unit bound the live rows; the NULL counts are net). A minimum, a maximum,count(DISTINCT), the sketches and key lists, a float sum (the sum of a unit is added in its own row order, so a float cannot be taken out of it), a rollup of the elements of a list, a unit with exceptions, a superunit, asift_tableand the write buffer are read from the rows. Whether the aggregates of a statement can use it is decided once (preagg_dead_ok): a statement that cannot never reads the dead rows. - Cost. No stored byte, no write, no format change, no option to build. A unit that cannot answer costs its liveness bits (decoded once, as the scan would) and the count of the dead ones; one that does costs the decode of the leaves it reads at the dead rows and a binary search for each.
Measured:
rows, churn a month dead rows grouped sum off, ms (Minstr) on, ms (Minstr) units from pre-aggregates 300,000, 20% 5.1% 5.26 (53.0) 4.84 (40.3) 8 to 89 of 108 3,000,000, 20% 7.6% 21.6 (336) 15.0 (160) 84 to 721 of 721 10,000,000, 5% 7.3% 69.2 (1,078) 42.8 (439) 17 to 1,052 of 1,052 10,000,000, 20% 11.8% 104.1 (1,214) 80.2 (821) 99 to 2,130 of 2,412 The clean state of the same rows answers in 0.05 to 0.13 ms. A statement the setting cannot help (a key lookup, a float sum, the newest rows) runs the same instructions within 1.3% (300,000 rows, key lookup: 25.98 to 26.31 million) or exactly (float sum: 49.78 million both ways); its time differences are the spread of the machine.
- Default. A dead row costs about 500 instructions here (10,000,000 rows: 771 million for 1.44 million dead rows and 2,415 units) and a row of the scan about 100, so a unit with more than about 17% of its
rows dead is cheaper to scan. The default 15 stays under that: at 10,000,000 rows and 20% a month the 282 units over it are scanned, and the other 2,130 answer from the cube. A lone segment is rewritten by
VACUUMabovedead_rewrite_pct(10) before it gets there. - Exactness. The answer of every statement is the same with the setting on, off (
sift.agg_dead_delta = 0) and with the pushdown off;col_dead_deltaand thedeadfamily ofdiff_fuzz(NULLs, numeric, int8, calendar groups, rollups, ranges of ctid, deletes, updates, VACUUM, promotions and merges) check it. A float sum keeps the policy it had: nothing is reordered.
13. Dirty units with pre-aggregates net of their dead rows (VACUUM writes them; SIFT_SEG_NET, no format minor)
Section 12 takes the dead rows off a unit’s stored aggregates one by one, and gives counts and integer sums only; a minimum, a maximum, a float sum, a rollup of the elements of a list and a unit with more than
sift.agg_dead_delta percent dead rows were read by their rows, and the churn of a few percent a round put a dead row in every unit within rounds (14 to 16 times the read of a rewritten segment after 8 rounds of 4%).
The segment is not rewritten for that any more: the VACUUM that cleared the bits writes, after the segment’s stream, the cube, the rollups and the sums of each dirty unit computed over its live rows
(col/seg/net.c, col/maint/net.c; the layout is in docs/FORMATS.md section 5).
- What a reader does. Under the conditions of section 12 (every page of the unit all-visible, read before the liveness) a unit whose dead rows are exactly the ones its sidecar was made for (liveness bits are only cleared, so the same number is the same rows) answers from the sidecar as a clean unit does from its own: the same code, with the blobs of the sidecar and the live row count. Any aggregate the cube gives is exact, a minimum, a maximum and a float sum (added in the order of the live rows, as a rewrite would) included. A unit with other dead rows, or without a sidecar, goes on to section 12.
- What it does not give. Everything that is a list of the values of the unit rather than a summary of it:
count(DISTINCT)of a key, the sketches (approx_count_distinct), the key lists and presence sets, sorted runs, point directories and superunits still describe the dead rows too; a query that needs them reads the unit by its rows, as before. A unit with exceptions, a leaf with overflow values, an element leaf (rollups of lists), an inline string leaf has no sidecar (the pass notes its dead rows and does not try it again) and keeps section 12. - What writes it. VACUUM, before the merges it decides (
vacuum_net): a segment with a unit whose dead rows the sidecar does not know gets a new one, one pass over the leaves of its dirty units, the cost of a verification of them; the segment keeps its generation, a new header page lists the extents of the stream and of the new sidecar, and the old header and sidecar pages are released with the directory switch (the relocation of a segment does the same). A crash after the pages and before the switch leaves unreferenced pages, which the orphan sweep returns. - What it changes for the rewrite. The units a sidecar answers for count as clean in the rule of the automatic rewrite (
sift.col_auto_merge_dirty_pct) and in the cost ofdead_rewrite_pct = 0; a segment still is rewritten when half of it is dead, when its units are fragmented, when the write buffer is large, and by the merge tiers. A rewrite or a merge writes no sidecar (its output has no dead rows) and drops the one of its inputs with their pages. - Cost. One sidecar is about the cube and rollups of the dirty units (a share of the segment’s pre-aggregates section) and the sums of each leaf per unit (80 bytes);
sift.col_sectionsshows it asnet. It is written again whole by every VACUUM that clears more bits.sift.col_verifyrebuilds each unit’s blobs from its live rows and compares the bytes. - Measured (8 rounds of 4% churn,
bench/churn/waste.shlane, promote and VACUUM each round). Maintenance WAL at 100,000 rows: 9.5 MB with sidecars, 5.7 MB leaving units dirty, 15.6 MB rewriting every round; the sidecar pass writes 3.9 MB where the rewrite writes 9.9 (-61%), of totals that include 2.3 MB of the heap’s VACUUM (-39%); at 1,000,000 rows 53.8, 49.7 and 206.9 MB. Reads (instructions of one query, sidecar vs delta alone vs the same index rewritten): grouped sum 1.51 / 4.60 / 0.71 M at 100,000 rows, 16.0 / 46.2 / 10.0 M at 1,000,000; minimum and maximum 1.57 / 2.80 / 0.80 M and 16.1 / 32.1 / 10.4 M. The sidecar does not reach the rewritten index: the dirty index keeps several segments (3 against 1) with the blocks and instructions that go with them, which only a merge returns. Shapes the sidecar does not cover (distinct count, sketches, key lists, point directories) read what the delta alone reads.sift.agg_dead_deltastays for the units a sidecar cannot cover (overflow values, exceptions, element leaves, inline strings): it is the same dead-row subtraction, and the sidecar replaces it for the units it covers.