Skip to content

feat: ungrouped vectorized aggregate with a batch fold (#289) - #337

Merged
ChronicallyJD merged 3 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/289-ungrouped-vector-agg
Aug 2, 2026
Merged

feat: ungrouped vectorized aggregate with a batch fold (#289)#337
ChronicallyJD merged 3 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/289-ungrouped-vector-agg

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

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:

ms
core Agg (GUC off) 16,244
batch fold (GUC on) 11,389

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)

  1. The node (the ungrouped sibling of the grouped path Grouped vectorized aggregate (#289) #321 built): route a filtered or extended ungrouped aggregate to a dedicated single-pass scan node instead of core Agg. On its own this was a no-op (16.3 vs 16.4s) because for an ungrouped aggregate the per-row cost equals core Agg's; that measurement is the evidence that motivated step 2, and is kept as the foundation.
  2. The batch fold: new reader accessors (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 same columnar_apply_one, in scan order, so the accumulators are byte-identical to the row path, floats included.

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. 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 reports Columnar 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.

  • Assert builds PG18 and PG19: this suite 29/29, and native_agg, native_groupagg, native_agg_deletes, native_agg_addcolumn, rewrite_group_scan unchanged.
  • pg18_san (ASAN/UBSAN + alignment): clean. This is a decode-path change (typed reads over packed bytes), so the sanitizer run is the load-bearing safety check.

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Collaborator Author

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

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 get 1.27–1.35x rather than your 1.43x, which I read as fixture shape, not disagreement.

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 PR

The body says:

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

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

query columns needed buffers touched % of table
count(*) 0 3 0%
sum(a) 1 45,094 100%
count(*), avg(b) WHERE a>90, fold ON 2 45,094 100%
count(*), avg(b) WHERE a>90, fold OFF 2 45,648 101%
sum of all 12 12 45,092 100%

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 columnar_native_load_group (src/columnar_reader.c:933), which does what its comment says and reads the group's bytes whole:

rs->nativeBuffer = palloc(rg->byteLength > 0 ? rg->byteLength : 1);
if (rg->byteLength > 0)
    ColumnarReadLogicalData(rs->rel, rg->fileOffset, rs->nativeBuffer, rg->byteLength);

projectedColumns is computed by the custom scan, passed down two call layers, copied into the read state at columnar_reader.c:334 — and then never read. The decode loop below it doesn't consult it either, so every column chunk is also decoded regardless of need.

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.

@ChronicallyJD
ChronicallyJD merged commit 423fb7e into jdatcmd:main Aug 2, 2026
11 checks passed
ChronicallyJD added a commit that referenced this pull request Aug 2, 2026
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.
jdatcmd pushed a commit that referenced this pull request Aug 3, 2026
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
jdatcmd pushed a commit that referenced this pull request Aug 3, 2026
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
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.

1 participant