Review: ungrouped vectorized aggregate with a batch fold (#289) [already merged as #337] - #341
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
|
Thanks — fair and useful review, and I want the correction on the record. The decode-floor claim was wrong; accepted. My "the remaining cost is decode (bitunpack)" line mis-attributed it. The residual being flat in the aggregated-column count is the tell that it was projection being discarded (all columns decoded), not bitunpack of the needed ones. Re-measuring q6 at 100M on current main (this fold plus #339) bears out your decomposition: projection was the ~6x lever, this fold is the ~1.4x serial arm. No disagreement on the composed picture. No revert needed from me. Your independent byte-identical check plus the assert/san gate match what I have; it is correct as landed. On alignment (your review focus): the batch fold reads each value with the same On the default GUC: I would keep |
What it does
Addresses #289. A full-scan ungrouped aggregate that a zone map cannot answer — one with a
WHEREfilter, orsum/avgover int8/float/numeric — ran on the row-wise coreAgg. This folds the decoded column buffer directly instead. Behindpgcolumnar.enable_ungrouped_vector_agg, default off.What I verified independently
On my own 4M-row TSBS-shaped fixture, not the author's bench:
Columnar Batch Fold: yesconfirmed firing via EXPLAIN. Results byte-identical across off / on / heap including the float average (400313|49.981327519677784in all three). I measured 1.27–1.35x where the PR body claimed 1.43x, which I read as fixture shape rather than disagreement.The correction you should know about
The original body said:
That was wrong, and I have already corrected it publicly (in a comment on #337 and again on #289). With the fold on, the residual was 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) — so it was neither decode-of-projected-columns nor filtering.
It was column projection being discarded entirely: the reader read and decoded every column of every row group regardless of the query. That became #338, fixed in #339/#340.
The practical consequence for reviewing this PR: the decode lever is real but smaller than this PR's body implies, because the decode cost it cites was being paid on all twelve columns rather than the one or two the query needed.
Composed effect
Once the projection fix landed, on the same fixture (12 cols, 4M rows, 294 MB):
All four return
399448|95.00982398311429, identical to a heap oracle. This PR's contribution is the 1.3x arm and roughly half the composed 13.2x.Tests as merged
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 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 not vacuous. PG18 and PG19 assert builds; pg18_san (ASAN/UBSAN + alignment) clean, which is the load-bearing safety check for a decode-path change.What I would focus a review on