Grouped vectorized aggregate (#289) - #321
Conversation
…numeric sum/avg), apply_one takes MemoryContext Groundwork only; no grouped producer yet. The new agg kinds are reachable only once the grouped path lands. See HANDOFF for the corrected scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
enable_group_vectorization (default off) and groupagg_max_groups. Foundation for the grouped path built out in following commits; ungrouped path unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Add the grouped sibling of the ungrouped vectorized aggregate: a scanrelid==0 custom path that fires for a plain GROUP BY over one columnar relation with an optional WHERE. Group keys are classified (bare or non-volatile scalar exprs over this rel, hashable+equalable, deterministic collation), the aggregate template reuses the extended sum/avg accumulators, and each surviving row from ColumnarReadNextRow is rechecked against the full WHERE, hashed into an open-addressing group table by the key types' own hash/eq functions, and folded in scan order so results are byte-identical to the scalar Agg. Gated by pgcolumnar.enable_group_vectorization (default off); dispatch keys on a length-5 custom_private. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
native_groupagg.sh proves the grouped path two ways: a heap mirror (exact aggregates byte-exact, float sums/averages rounded) and a toggle-differential (path off vs on over the same columnar table, which validates the order-preserving accumulators byte-for-byte). Plan assertions confirm the node is chosen for supported shapes and that over-cap cardinality, non-deterministic collation, an output built on a key, and a keys-only GROUP BY all fall back while still returning the oracle's answer. Covers NULL keys, deletes, ADD COLUMN, a WHERE that both prunes groups and needs a residual recheck, and empty input. Registered in the version matrix next to the ungrouped agg suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
Review: do not merge yet. Two wrong-answer blockers, reproduced.This is good work and the design is right: a real single-pass grouped path, opt-in, Method: a 7-dimension adversarial code review (each finding then attacked by an Blocker 1:
|
| measured | |
|---|---|
| plan | node is chosen (estimate 20000 > cap 100) |
| run | ERROR mentioning groupagg_max_groups |
So it is enforced at execution, not plan time, and it errors rather than falling
back. The suite's own comment is the accurate description; the GUC string and the
PR body are not. Default is 1,000,000 so the practical risk is low, but a DBA
reading pg_settings would expect a silent fallback and get a failed query.
Other findings from the review (not individually reproduced by me)
Each survived an independent refutation attempt. Flagging them as reported rather
than verified, since I only ran the ones above.
Correctness
src/columnar_vector.c:462-- strippingRelabelTypefrom a GROUP BY key discards
the cast's result type and collation. This also defeats the
deterministic-collation check, so an explicitCOLLATEwith a nondeterministic
collation can group wrongly. Two dimensions found this independently.src/columnar_vector.c:1209-- float sum/avg silently overflow toInfinitywhere
core raises "value out of range: overflow".src/columnar_vector.c:2004/:2011-- WHERE clauses referencing system columns or
whole-row Vars are neither rejected nor projected.src/columnar_vector.c:908-- a legacy inheritance parent is accepted and only the
parent's own storage is scanned, dropping every child row.src/columnar_vector.c:1256-- min/max keep the first value on a tie; core's
*_larger/*_smallerkeep the last. Visible fornumeric1.0 vs 1.00. I tried to
reproduce this and could not, because the::textI used to expose dscale makes the
query fall back. Worth checking directly.
Memory (these bound how large a scan can get before it hurts)
src/columnar_vector.c:1232-- the numeric/int8 sum and avg accumulators allocate one
or two numerics per scanned row intospecContext, which is not reset until end
of scan. That is O(rows), not O(groups), on exactly the 100M-row shape this feature
targets.src/columnar_vector.c:2249-- group-key expressions useExecEvalExprinstead of
ExecEvalExprSwitchContext, leaking one allocation per row intoes_query_cxt.src/columnar_vector.c:2077-- table growth is a singlepalloc, so it hits the
internal alloc-size limit before the1<<30ceiling.
Planner / EXPLAIN
src/columnar_vector.c:1023-- the path is priced fromcheapest_total_path, which can
be an index scan, but always executes a full columnar scan.src/columnar_vector.c:1947-- plainEXPLAINalways reports
Columnar Pushed-Down Filters: 0and shows neither the filter nor the group keys.
Test suite
- The 15 oracle and toggle comparisons never assert their own premise: none verifies
the grouped node was chosen for that query shape. I checked, and all the$EXACT
ones do fire today -- but nothing keeps them firing, which is how section 4 rotted. test/native_groupagg.sh:218-- the non-deterministic-collation answer check cannot
fail: the fixture has no keys differing only by case.test/native_groupagg.sh:232-- "GROUP BY with no aggregate falls back" is
short-circuited earlier than the guard it is credited with covering.
What is good, and worth saying
- The plan assertions use a marker line unique to this node, and the comment explains
that this is proof of the node rather than an absence test a fallback would also
pass. That is the right instinct and it is why the vacuity is confined to section 4. - The
pipefailnote at line 196 catches a trap that would have turned the over-cap
check into a false negative. - The GUC really is default off, so none of this is live today.
- The single-pass design, per-type hash/equality, and the toggle-differential oracle
are all sound.
To merge
Blockers 1, 2 and 3 need fixing, and the cap's description needs to match its
behaviour. The RelabelType and per-row allocation findings I would want addressed
before this is ever turned on by default, but they need not block the merge of an
opt-in path if you would rather land the foundation and follow up.
Happy to be wrong on any of the not-reproduced items -- push back with a repro and I
will retract.
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
…rage fixes Blockers (reproduced by review): - sum(real) returned 0: it handed back a float8 Datum in a float4 slot. Now sum(real) accumulates in real (matching float4pl) and returns real; sum/avg additions go through overflow-checked helpers so a finite+finite overflow errors like core instead of silently carrying Infinity. - gating (pseudoconstant) WHERE clauses were dropped: the residual per-row recheck cannot honor a one-time filter, so the path now falls back when baserestrictinfo holds any pseudoconstant clause. - the float/avg test section was vacuous: rounding an aggregate in the SELECT list made the node fall back, so both arms ran the scalar Agg. Rewritten as toggle-differentials of the bare aggregates, and toggle_diff/oracle now assert the node actually fires (the premise no comparison used to state). Before-default-on and other review findings: - group-key RelabelType is no longer stripped, so an explicit COLLATE keeps its (non-)deterministic collation and the determinism check is honest. - numeric/int8 sum/avg free the previous running sum and per-row intermediate: live memory is O(groups), not O(rows). - group keys evaluate in the per-tuple context (no per-row leak into the query context); the open-addressing table uses a huge, zeroed allocation so it can reach its ceiling; the path is costed from a full columnar scan, not a possibly-cheaper index path; EXPLAIN reports the real pushed-down filter count; min/max keep the later value on a tie (numeric 1.0 vs 1.00), matching core; a WHERE on a system/whole-row column and a legacy inheritance parent now fall back instead of scanning wrong data. - groupagg_max_groups GUC description now says it is an execution-time cap that errors, matching the code. Test grows to 58 checks incl. named regressions for both wrong-answer blockers and a numeric display-scale tie; every oracle/toggle asserts the node fires. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…rage fixes Blockers (reproduced by review): - sum(real) returned 0: it handed back a float8 Datum in a float4 slot. Now sum(real) accumulates in real (matching float4pl) and returns real; sum/avg additions go through overflow-checked helpers so a finite+finite overflow errors like core instead of silently carrying Infinity. - gating (pseudoconstant) WHERE clauses were dropped: the residual per-row recheck cannot honor a one-time filter, so the path now falls back when baserestrictinfo holds any pseudoconstant clause. - the float/avg test section was vacuous: rounding an aggregate in the SELECT list made the node fall back, so both arms ran the scalar Agg. Rewritten as toggle-differentials of the bare aggregates, and toggle_diff/oracle now assert the node actually fires (the premise no comparison used to state). Before-default-on and other review findings: - group-key RelabelType is no longer stripped, so an explicit COLLATE keeps its (non-)deterministic collation and the determinism check is honest. - numeric/int8 sum/avg free the previous running sum and per-row intermediate: live memory is O(groups), not O(rows). - group keys evaluate in the per-tuple context (no per-row leak into the query context); the open-addressing table uses a huge, zeroed allocation so it can reach its ceiling; the path is costed from a full columnar scan, not a possibly-cheaper index path; EXPLAIN reports the real pushed-down filter count; min/max keep the later value on a tie (numeric 1.0 vs 1.00), matching core; a WHERE on a system/whole-row column and a legacy inheritance parent now fall back instead of scanning wrong data. - groupagg_max_groups GUC description now says it is an execution-time cap that errors, matching the code. Test grows to 58 checks incl. named regressions for both wrong-answer blockers and a numeric display-scale tie; every oracle/toggle asserts the node fires. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
387fe73 to
87fb8da
Compare
All findings addressed — thank you, this was a genuinely good reviewThe vacuous section 4 is the miss I most want to own: the suite looked thorough at 31 checks while giving zero real coverage to every average and float-sum accumulator, and it hid a wrong-answer bug. That's exactly the failure the rest of the suite was built to avoid. Fixed at the root, not just patched. Pushed as Blockers (all reproduced, all fixed)
Cap descriptionRewritten to say it is an execution-time cap on the actual group count that errors (with a hint), matching the code rather than the old "plan-time … falling back". Before-default-on and the reported findings — also fixed
Adversarial re-verification caught four more edge cases (also fixed)Before claiming done I ran the fixes back through an adversarial pass. It surfaced four things worth having, now fixed and covered:
Re-gate
The |
|
Merged. I re-verified rather than took the summary, using the same reproductions Blocker 1, Blocker 2, gating WHERE. Blocker 3, the vacuous section. This is the one I most wanted to check, since a
The three The four you caught yourself. All match heap with the node firing: Worth noting how nearly I missed verifying these: my first probe wrapped the Finding the CI green on all 11 checks. #289 stays open for the remaining lever. |
The open list said the grouped aggregate (#321) "is not merged" with a review-state detail, but #321 merged 2026-08-01T22:31:39Z -- exactly the restated status this rewrite exists to stop carrying. State the durable fact (landed, behind the default-off GUC) and let #321 hold its own state; keep the pointer to its body for the numbers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
The open list said the grouped aggregate (#321) "is not merged" with a review-state detail, but #321 merged 2026-08-01T22:31:39Z -- exactly the restated status this rewrite exists to stop carrying. State the durable fact (landed, behind the default-off GUC) and let #321 hold its own state; keep the pointer to its body for the numbers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
#321 added a grouped vectorized aggregate path (SELECT keys, agg(col) ... GROUP BY keys over one columnar relation), gated by the default-off pgcolumnar.enable_group_vectorization, plus pgcolumnar.groupagg_max_groups. Neither GUC nor the capability was documented, and limitations.md listed GROUP BY as always scalar, which is now only true by default. - configuration.md: the two GUCs (enable_group_vectorization off by default; groupagg_max_groups, execution-enforced, over-cap errors). - limitations.md: qualify the GROUP BY entry and describe the opt-in path, its key/output requirements, the extra sum/avg over bigint/numeric/float it accepts, and the group-count cap behavior. - features.md: note the opt-in grouped path beside the ungrouped one. Docs pass test/ste_check.py; no code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
…#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 #289). Give it a dedicated single-pass scan-fold node, the ungrouped sibling of the grouped path #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
Grouped vectorized aggregate (#289)
The existing vectorized aggregate fires only for a plain, ungrouped, unfiltered aggregate, which it answers from zone-map metadata. The moment a query adds
GROUP BY(the TSBSdouble-groupbyshape, q4/q5) it falls back to a scalarHashAggregateover the row-at-a-time scan. This adds the grouped sibling: ascanrelid==0custom path that groups and aggregates inside one pass over the columnar reader.What it does
Fires for
SELECT <keys>, agg(col) … [WHERE …] GROUP BY <keys>over a single columnar relation when:GROUP BYkey is non-volatile, computable from this relation, has a hash function and an equality operator, and — if collatable — uses a deterministic collation;pgcolumnar.groupagg_max_groups.Anything else adds no path and the ordinary
Aggplan runs, so results are never at risk.Each surviving row from
ColumnarReadNextRow(WHERE pushed down for group/vector skipping) is rechecked against the full WHERE, its keys evaluated, and scattered into an open-addressing hash table. Grouping uses each key type's own hash and equality functions — notmemcmp— so-0.0/NaN, numeric scale, and deterministic-collation text group exactly as core does. Per-group accumulators reuse the existingcolumnar_apply_oneand fold in scan order, so results are byte-identical to the scalarAggthe planner would otherwise choose.Extends the aggregate accumulators to
sum/avgoverint8/float/numeric(the ungrouped path still rejects these, unchanged).Opt-in
Gated by
pgcolumnar.enable_group_vectorization(default off). While off, planning and execution are unchanged. When on, it is priced to be chosen overAgg-over-scan (an accelerator you turn on), costed per output group rather than per input row.Correctness —
test/native_groupagg.sh(registered in the matrix)count, integer/numeric sums,min/max) compare byte-exact; float sums/averages compare rounded (float summation order is the executor, not a defect).GROUP BYall fall back while still returning the oracle's answer.ADD COLUMN, a WHERE that both prunes groups and needs a residual recheck, and empty input.Gate
native_groupagggreen on both).pg18_san):native_groupaggpasses — no leak / use-after-free / alignment fault in the new hash-table and memory-context code.Performance
Interleaved A/B on the TSBS
cpuset (100M rows, PG18 non-assert, serial, forced columnar scan), grouped path off vs on, warm median over 4 rounds. Both arms return the identical 48,000 groups:double-groupby-1(1 avg)double-groupby-all(10 avgs)q5 gains more: the wider the aggregate list, the more per-row
nodeAggplumbing the single-pass fold removes. This matches the honest estimate.The larger ~4× lever for this shape is the follow-on: dictionary-coded grouping on the high-cardinality text key. This change is the executor foundation that makes that possible.
Cleanroom: order-preserving hand-written accumulators and an open-addressing hash over public PostgreSQL APIs; no core, TimescaleDB, Citus, or DuckDB source consulted.
🤖 Generated with Claude Code