Skip to content

Review: ungrouped vectorized aggregate with a batch fold (#289) [already merged as #337] - #341

Closed
ChronicallyJD wants to merge 3 commits into
jdatcmd:review-base/pre-337from
ChronicallyJD:feat/289-ungrouped-vector-agg
Closed

Review: ungrouped vectorized aggregate with a batch fold (#289) [already merged as #337]#341
ChronicallyJD wants to merge 3 commits into
jdatcmd:review-base/pre-337from
ChronicallyJD:feat/289-ungrouped-vector-agg

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

Read this first. This change is already in main (merged as #337, commit 423fb7e). Like #340, I self-merged it before you asked for a review. This PR exists so the diff is reviewable: its base is pinned at 92ae8a5, the commit immediately before that merge, so what you see is exactly the change.

It cannot be merged to land the code — the code has landed — and the review is therefore non-blocking. If you want it out of main until you have signed off, say so and I will revert it and we re-land through a normal PR.

What it does

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.

What I verified independently

On my own 4M-row TSBS-shaped fixture, not the author's bench:

ms
fold OFF 1124
fold ON 834
heap 242

Columnar Batch Fold: yes confirmed firing via EXPLAIN. Results byte-identical across off / on / heap including the float average (400313|49.981327519677784 in 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:

The remaining cost is the decode (bitunpack) plus serial execution; those are the next levers

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):

buffers time
projection off, fold off 38,288 1016.6 ms
projection on, fold off 4,462 171.9 ms
projection off, fold on 37,734 764.3 ms
both on 3,908 76.9 ms

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

  • The decode-path typed reads over packed bytes, since that is the memory-safety surface. The sanitizer run covers it; a second pair of eyes on the alignment assumptions would not hurt.
  • Whether gating the whole thing behind a default-off GUC is still the right call now that the projection fix has changed the cost picture underneath it.

ChronicallyJD and others added 3 commits August 2, 2026 15:24
…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
@ChronicallyJD
ChronicallyJD requested a review from jdatcmd August 2, 2026 23:41
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

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 fetch_att(nativeValueCursor + i*attlen, byval, attlen) that columnar_native_next_row already uses on the identical pointer (columnar_reader.c:1391-1394), for baseline (base + validityBytes) and descriptor (MAXALIGN'd rawBuf) chunks alike. It walks the same addresses at the same stride as the shipped row producer, so it adds no memory surface the row path does not already exercise, and pg18_san covers both. If we want the strict-alignment guarantee made explicit rather than inherited, that belongs at the shared fetch site, not this path.

On the default GUC: I would keep enable_ungrouped_vector_agg off for now. It matches enable_group_vectorization (also default-off), so the two vectorized-agg switches stay consistent; and the fold is serial-only, so with parallelism available the planner already picks parallel core Agg (2.76s on q6@100M) over the serial fold (8.99s) -- the default mainly affects the serial case. The version worth defaulting on is the parallel-aware one: a partial-aggregate path that stacks the fold onto the ~5x parallel arm. I would flip both defaults together when that lands.

@jdatcmd

jdatcmd commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Superseded — this review branch's work landed in main as #337 (feat: ungrouped vectorized aggregate with a batch fold, #289). Closing; not merging.

@jdatcmd jdatcmd closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants