feat: ungrouped vectorized aggregate with a batch fold (#289) - #337
Conversation
…jdatcmd#289) An ungrouped aggregate with a WHERE filter, or a sum/avg over int8/float/numeric, is answerable from no zone map, so it fell to the row-wise core Agg: about 244 ns/row against heap's 48 ns/row on the TSBS q6 shape (the measurement on jdatcmd#289). Give it a dedicated single-pass scan-fold node, the ungrouped sibling of the grouped path jdatcmd#321 built. pgcolumnar.enable_ungrouped_vector_agg (default off) routes such a query to columnar_native_scan_agg, generalized: it builds scan keys from the WHERE for group and vector pruning, rechecks the whole WHERE per row (the keys only prune), and folds every surviving row through columnar_apply_one. That is the same reference fold the grouped and metadata paths use, applied in scan order, so the result is byte-for-byte what core Agg returns, floats included. The zone-map metadata path (count, min, max, and sum/avg over int2/int4 with no filter) is untouched. With the GUC off the behavior is exactly as before. test/ungrouped_vector_agg.sh: 27 checks, on==off across filtered and unfiltered float/int8/numeric sum/avg, min/max, nulls, empty result, an all-null column, and after deletes; it asserts via EXPLAIN that the new node actually runs, so the A/B is never vacuous. Passes on PG18 and PG19; the existing agg suites (native_agg, native_groupagg, deletes, add-column, group rewrite) still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
The per-row scan-fold node landed the plan shape but did not move q6: for an ungrouped aggregate both it and core Agg pay the same dominant cost, the per-row Datum materialization in ColumnarReadNextRow (measured ~16.3s vs 16.4s on 20M, no win). Replace the per-row loop, for the shapes it fits, with a fold over the decoded column buffer. New reader accessors (ColumnarReadFoldNextGroup / ColumnarReadFoldGroupInfo / ColumnarReadFoldColumn) expose a loaded group's packed value streams. columnar_native_batch_fold walks them column-at-a-time: it evaluates a pushable WHERE inline (a btree comparison per element, using PostgreSQL's float total order so NaN matches the operator ExecQual would call) and folds each surviving value through the same columnar_apply_one, in scan order, so the accumulators are byte-identical to the row path, floats included, with none of the per-row Datum, memory-context, or executor cost. Eligible shapes: count(*), count(col), and sum/avg over int2/int4/float4/float8, with the whole WHERE expressible as btree keys on those types. Anything else (int8/numeric sum/avg, min/max, a residual or cross-type filter, or a group missing a column) falls back to the always-correct row path. EXPLAIN reports "Columnar Batch Fold: yes/no". test/ungrouped_vector_agg.sh asserts the batch path is taken for an eligible shape and the row path for min/max, and still checks on==off (bit-identical, floats included) across every shape. 29/29 on PG18; PG19 and the full gate to follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
harness_selftest requires every test/*.sh suite to be listed in run_all_versions.sh. The new suite was not, so the suites (PG17/PG18) CI job failed on harness_selftest even though every suite (including the new one) passed. Register it alongside the other aggregate suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
|
Approving. I reproduced this independently on my own 4M-row TSBS-shaped fixture (not your bench), and it holds up: the fold is real, and it is correct. Verified
Credit where it is due on the writeup: reporting step 1 as a no-op ("16.3 vs 16.4s ... that measurement is the evidence that motivated step 2") is the right way to do this. So is "does not close the whole 4x to TimescaleDB alone and does not claim to." Neither was necessary to get the PR merged, and both make the result easier to trust. One correction, and it is not a defect in this PRThe body says:
That does not account for most of the residual, and I would not want it to send the next round of work at the wrong lever. With the fold ON my residual is flat in the number of aggregated columns (803 ms at 1 agg column, 837 ms at 5) and essentially unchanged with no filter at all (798 ms). Something that is neither decode-of-projected-columns nor filtering is dominating. It is whole-table I/O. On a 12-column, 4M-row, 351 MB table (44,962 buffers total):
One column costs the same bytes as twelve. Cross-checked against width: the identical 2-column query reads 7,620 buffers on a 2-column table (59 MB, 100%) and 45,097 on a 12-column one (351 MB, 100%) — in both cases exactly the whole relation. This is pre-existing and not yours — fold OFF reads the same 100%, so the fold neither caused nor worsened it. The mechanism is rs->nativeBuffer = palloc(rg->byteLength > 0 ? rg->byteLength : 1);
if (rg->byteLength > 0)
ColumnarReadLogicalData(rs->rel, rg->fileOffset, rs->nativeBuffer, rg->byteLength);
So the accurate statement is that the next lever is projection, not bitunpack: word-parallel bitunpack would speed up decoding columns the query never asked for. I have filed this separately with the measurements rather than expanding your scope here. Merging as-is. |
Column projection was being computed and discarded; the reader read and decoded every column of every row group regardless of the query. TSBS shape, 12 cols / 4M rows: filtered aggregate over one metric goes 38,288 buffers / 1016 ms -> 4,462 / 172 ms (5.9x), and 13.2x composed with the #337 fold, results identical to a heap oracle in every GUC combination. Restores corruption detection via a direct tiling invariant rather than weakening corruption.sh. Full 15-19 matrix green locally and 11/11 CI.
The ungrouped batch fold (#337) runs serially: one process folds every row group, so it can only reach ~1.4x over core Agg and leaves the ~5x parallel scan (gap 23) on the table. Make the fold parallel-aware so both stack. A new opt-in GUC, pgcolumnar.enable_parallel_vector_agg (default off), adds a second upper path: a parallel-aware partial ColumnarAgg under a core Gather and Finalize Aggregate. Each worker claims distinct row groups through the same shared atomic the base parallel scan uses (ColumnarReadSetParallelCounter, gap 23) and folds them column-at-a-time, emitting one per-worker transition state; the core Finalize combines them -- int8pl for count, float8_combine + float8_avg for avg(float8) -- so the result matches an ordinary parallel aggregate exactly, overflow parity included (float8_combine re-derives and re-checks the Youngs-Cramer Sxx the partial passes through). The partial target is core's own UPPERREL_PARTIAL_GROUP_AGG reltarget, so the partial and final Aggrefs stay structurally related and setrefs matches them. First slice: count(*), count(col), and sum/avg over float4/float8 -- the kinds whose transition state is a plain, non-internal value the fold already holds (columnar_parallel_agg_ok); q6's count(*)+avg(float8) is covered. Everything else keeps the serial node or the ordinary core Agg. When the parallel arm is added it supersedes the serial node (its Gather runs leader-only with no workers), so the serial node -- priced at the cheap Gather cost by #133 -- is added only as the non-parallel fallback, else it would out-cost the genuinely parallel plan. Correctness guards (a parallel path returns wrong answers, not crashes, when wrong): the partial reader errors if it opens without a shared counter (would read every group in every worker and the Finalize would sum the duplicates); writes/deletes flush once in the leader's InitializeDSM before workers launch (a worker cannot see the leader's unflushed in-xact buffers); and the one unsafe fallback -- an absent column found mid-scan after the counter advanced -- errors rather than undercount. A shape that is not batch-foldable from the start (a NULL test, a non-btree filter) still runs correctly on the row path with the counter shared. Float parallel folds are order-nondeterministic, so the oracle is core's own parallel aggregate, not the serial fold. test/parallel_vector_agg.sh asserts the plan (Finalize -> Gather -> parallel partial ColumnarAgg, batch fold yes) is actually chosen, count is exact vs a serial oracle, avg/sum(float) match core parallel Agg within reassociation tolerance, and null/empty/more-workers-than- groups behave. 13/13 on PG18 assert; the full gate and the q6@100M bench to follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Take the PG_CONFIG arg like the other suites (was pinned to pg18a during development), use the harness check/pgc_summary, and register the suite in run_all_versions.sh so harness_selftest passes (every suite must be listed -- the #337 CI lesson). 14/14 on PG18 assert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
What
Addresses #289: a full-scan ungrouped aggregate that a zone map cannot answer (one with a WHERE filter, or sum/avg over int8/float/numeric) ran on the row-wise core Agg. This folds the decoded column buffer directly instead. Behind
pgcolumnar.enable_ungrouped_vector_agg(default off).Measured
Bench, 20M-row TSBS-cpu, q6 =
SELECT count(*), avg(usage_system) FROM t WHERE usage_user > 90.0, serial, warm median of 5:1.43x on the target query. The batch fold removes the per-row Datum, memory context, and executor tax that the #289 analysis measured as the gap. The remaining cost is the decode (bitunpack) plus serial execution; those are the next levers (word-parallel bitunpack, a parallel partial-aggregate path), each separately measurable. This does not close the whole 4x to TimescaleDB alone and does not claim to.
How (two commits)
ColumnarReadFoldNextGroup/ColumnarReadFoldGroupInfo/ColumnarReadFoldColumn) expose a loaded group's packed value streams. The node walks them column-at-a-time, evaluates a pushable WHERE inline (a btree compare per element, using PostgreSQL's float total order so NaN matches the operator ExecQual would call), and folds each surviving value through the samecolumnar_apply_one, in scan order, so the accumulators are byte-identical to the row path, floats included.Eligible shapes:
count(*),count(col), andsum/avgover int2/int4/float4/float8, with the whole WHERE expressible as btree keys on those types. Anything else (int8/numeric sum/avg, min/max, a residual or cross-type filter, or a group missing a column) falls back to the always-correct row path. The zone-map metadata path (count/min/max and sum/avg over int2/int4 with no filter) is untouched. With the GUC off, behavior is exactly as before. EXPLAIN reportsColumnar Batch Fold: yes/no.Tests and gate
test/ungrouped_vector_agg.sh(29 checks): on==off, bit-identical, across filtered and unfiltered float/int8/numeric sum/avg, min/max, nulls, an empty result, an all-null column, and after deletes. It also asserts via EXPLAIN that the batch path is taken for an eligible shape and the row path for min/max, so the A/B is never vacuous.🤖 Generated with Claude Code