Repository navigation
feat: GROUP BY is vectorized by default, gated on the group estimate (#1292) - #1330
Conversation
…irst (#1292) Six arms in native_groupagg.sh: the default is on; by default a five-minute bucket query takes the grouped path and a key estimated at 200,000 groups is declined, answering what heap answers; raising groupagg_max_groups admits it; and an estimate the gate believes but the data contradicts still stops at the runtime cap with its hint. On main (PG17) five are red, all because the default is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mate (#1292) The owner's decision on #1292: pgcolumnar.enable_group_vectorization is on by default, and the planner takes the grouped path only when the estimated group count is at most a tenth of pgcolumnar.groupagg_max_groups. Over the cap the node errors, since its plan is fixed by then, so an ungated default would turn a high-cardinality GROUP BY from a working query into an error. The tenth is a margin for an estimate up to 10x low; #369's bound and #1304's time buckets keep bucket estimates within a small factor. The runtime cap stays as the backstop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f the gate (#1292) The PG16 matrix with the default on failed three suites on this change (the other three failures are this machine's: test_decoding unbuilt and pg_partman missing on 16): - native_groupagg forced the node under a cap of 100 to reach the runtime error. The gate now declines that plan, which is its point, so the arm goes; section 10's stale-statistics arm reaches the cap through a wrong estimate. 'plan: node still chosen (no estimate gate)' is renamed to what it holds. - groupagg_table_sizing measures hash-table sizing on 200,000 groups and now raises the cap so the gate admits its fixture. - vector_agg_tlist_shape tests the ungrouped fold, and the grouped path now takes its GROUP BY refusal shape by default with the same marker; its plan helper pins the grouped path off. test_native_groupagg.py gains the seven gate arms by the same names, on its own generator. PG17: native_groupagg 87/87 (+1 skip), groupagg_table_sizing 15/15, vector_agg_tlist_shape 68/68, test_native_groupagg.py 18 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1292) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
) configuration.md's section on why the path was off becomes how it stays safe on by default; features.md, how-to.md and limitations.md said opt-in and off; features.md also said plain columns and count/sum/avg only. CHANGELOG under Changed. docs_style 58/58. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ab22bc5 to
d0bd2d5
Compare
|
The full PG19 matrix (CI doesn't build 19), at ab22bc5, the tree before the rebase onto 80bce53. 283 suites ran. All passed except three, each from this machine's setup and none from this change:
The same three failed in the PG16 matrix. With the default on, everything else is green on 19, including the three suites this PR changed. The rebase onto 80bce53 (#1328) was clean. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Review at d0bd2d5. The code change is small and reads correctly, and the gate does what it says. My concern is the premise behind it: that fresh statistics keep the estimate within 10x. On a common shape they don't, and then a query that works on main errors by default.
Repro, PG17, at this head
CREATE TABLE sk (k bigint, v int) USING pgcolumnar;
-- half the rows share one key, the other half are unique: a heavy hitter and a long tail
INSERT INTO sk SELECT CASE WHEN g % 2 = 0 THEN 0 ELSE g END, g FROM generate_series(1, 4000000) g;
ANALYZE sk;
SELECT n_distinct FROM pg_stats WHERE tablename = 'sk' AND attname = 'k'; -- 29356
SELECT count(DISTINCT k) FROM sk; -- 2000001
EXPLAIN SELECT k, count(*), sum(v) FROM sk GROUP BY k;
-- Custom Scan (PgColumnarScan) (rows=29356) Columnar Vectorized Group Keys: 1
SELECT k, count(*), sum(v) FROM sk GROUP BY k;
-- ERROR: grouped vectorized aggregate exceeded pgcolumnar.groupagg_max_groups (1000000)
SET pgcolumnar.enable_group_vectorization = off; -- the same query answers 2000001 groupsWhy the gate can't catch it. The estimate is 68x low with statistics taken a moment earlier. That's ANALYZE's estimator, not stale data: a 30,000-row sample of this column holds about 15,000 copies of the heavy hitter and about 15,000 singletons, and Haas–Stokes turns that into roughly 29,000. A tenth of the cap can't absorb that, and nor can any fixed fraction, because the error grows with the table. Here the table is only 4M rows. The error's hint suggests running ANALYZE, which is exactly what produced the estimate.
This is the outcome #1300 quotes from the owner's decision: "gate by the estimate now, never error later". The PR description lists the backstop as "the runtime cap stays as the backstop for an estimate more than ten times too low". That case doesn't need stale statistics, just a skewed key.
What I'd suggest, roughly in order of how much they change
- Gate on a true upper bound, not an estimate.
- Time buckets: #369's and #1304's bounds are bounds, so keep the path on by default for those.
- Plain keys: for a single plain-column key, the relation's row count is an upper bound on its groups. "rows ≤ cap" admits only tables that cannot exceed it. On the repro, 4M > 1M, so it declines, and so does everything else that could error. It's conservative for large tables with few groups, but those keep the opt-in setting until #1300.
- Default-on only after #1300. Keep this gate, but flip the default once over-the-cap spills or hands off instead of erroring. Then an estimate that's wildly low costs memory, not an error.
- At least: change the hint. Point it at
SET pgcolumnar.enable_group_vectorization = offrather than ANALYZE, and add the repro shape tolimitations.md. I'd only want this if (1) or (2) is rejected.
The arm I'd add, in both twins: the repro above, requiring that the default plan either answers with all of its groups or declines the grouped path. It errors today.
Otherwise
- The changes I read: the gate's placement after #369/#1304, the boot-value change, the three suites adjusted for the new default, and the renamed arm. They look right.
- What I didn't run: the suites themselves on any major. I ran only this probe on PG17, after building this head.
- On the 10%: you asked for my view on the threshold. With (1), there's no threshold to choose. With (2), 10% is fine, because a miss costs memory instead of an error.
@OffgridwithJD measured a fresh ANALYZE estimating 29,356 groups for 2,000,001 on a key half one value, half unique, and the query main answers errored by default under the estimate gate. Section 10 of native_groupagg and its pytest twin now require an UPPER BOUND gate: a bucket query over more rows than the cap is still taken, a plain key over more rows than the cap is declined, and the skewed shape (95% of 800,000 rows on one key, 40,001 groups, estimated below 3,000) is declined under a cap of 30,000 and answers every group. The runtime cap is reached through a cached plan run under a lower cap; the stale-statistics arm goes, because a bound cannot be fooled that way. Red on the estimate gate (PG17): the skewed key took the path and errored ('exceeded pgcolumnar.groupagg_max_groups (30000)'), and the cached plan was declined at the default cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…timate (#1330 review) The planner takes the grouped path only when an upper bound on the group count is within pgcolumnar.groupagg_max_groups: the relation's live row count (from the row-group catalog and delete vectors, before any WHERE), or, where every key is a time bucket, the product of the bucket bounds (#369, #1304) with one group per key for NULL. An estimate could not be the gate: a skewed key fools ANALYZE's own estimator by 68x with fresh statistics. With a bound the default never errors; the cap is reached only if it falls, or the table grows past it, after planning. Each key's time bound is read once per plan and shared by the estimate and the gate, so planning still scans zone_map once. PG17: native_groupagg 89/89 (+1 skip), test_native_groupagg.py green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1330 review) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD, you're right: an estimate can't be the gate, and your repro proves it. This is fixed at e62d472, as your option 1. The default stays on. The gate is now an upper bound. The planner takes the grouped path only when an upper bound on the group count is within
The 10% is gone, because a bound needs no margin. With it, the default never errors; the cap is reached only if it falls, or the table grows past it, after planning. Each key's bucket bound is now read once per plan and shared by the estimate and the gate. My first version read the zone maps twice, and the existing "scans zone_map once" arm caught it. The arms are reworked in both twins:
The stale-statistics arm is gone: a bound can't be fooled that way. Red first on the estimate gate, PG17: your shape took the path and errored with Green at 79a9b39 (the code; e62d472 only adds docs):
Mutations,
I'm reporting two gaps rather than hiding them:
The docs now describe the bound, and |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at e62d472. The gate is a bound now, and I tried to break it from three directions.
Where the bound holds:
- Base columnar relations only.
PgColumnarTryGroupAggPathreturns early for anything else, so a join's or an appendrel's estimatedtuplesnever reaches the gate. - Concurrent writers.
relation_estimate_sizereads the row groups and delete vectors under the active snapshot, so another session's uncommitted delete can't lower the count. - Rows this transaction wrote and hasn't flushed are counted. I measured it on PG17: 900 committed rows, then
BEGIN; INSERT500 more in the same transaction, with a cap of 1000. The scan's estimate was 1400, the path was declined, and all 1400 groups were answered.
Your untested +1 for NULL is load-bearing, and here is an arm for it. The bucket bound is floor((hi − lo) / width) + 2, and a span can use all of it. With lo = 00:00:59 and hi = 00:02:00 at one-minute width, the bound is 3 and there really are 3 buckets. Add NULL rows and there are 4 groups:
CREATE TABLE nb (ts timestamptz, v int) USING pgcolumnar;
INSERT INTO nb SELECT '2026-01-01 00:00:59+00', g FROM generate_series(1, 50) g;
INSERT INTO nb SELECT '2026-01-01 00:01:30+00', g FROM generate_series(1, 50) g;
INSERT INTO nb SELECT '2026-01-01 00:02:00+00', g FROM generate_series(1, 50) g;
INSERT INTO nb SELECT NULL, g FROM generate_series(1, 50) g;
SET TimeZone = 'UTC'; SET pgcolumnar.groupagg_max_groups = 3;
SELECT date_trunc('minute', ts) b, count(*) FROM nb GROUP BY b;- At e62d472: the path is declined (
HashAggregate), and all four groups come back, 50 each. - With
*withNull *= one + 1.0changed to*withNull *= one: the path is taken (rows=3), and the query errors withexceeded pgcolumnar.groupagg_max_groups (3).
So the +1 should stay, with this as its arm in both twins. The row count can't save it, because it is 200 here, well over the cap. I'd add the arm in a follow-up rather than hold this PR for it.
The pytest twin's row-count gap: your explanation, that the skewed table loses on cost, is consistent with what I saw. The shell twin dates that mutation, so I wouldn't hold the PR for it either.
I didn't run the suites. I ran the three probes above, on PG17 at this head and on a build with the +1 removed.
|
Merging: approved by @OffgridwithJD at e62d472, and CI is green on that head. The NULL-group arm from the review follows in its own PR. |
The bound-without-row-count mutation stayed green in the pytest twin: its skewed table, once the mutated gate let it through, lost to the ordinary plan on cost, so nothing errored. Both twins' gate helpers now turn hash aggregation and sorting off, so the grouped path wins whenever the gate offers it; a declined query still runs, on the disabled plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD's shape: three minute buckets fill the bucket bound exactly, and NULL rows add a fourth group. Under a cap of 3 a bound without the NULL group takes the path and errors; with it the path is declined and the four groups are answered. In both twins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bound-without-row-count mutation stayed green in the pytest twin: its skewed table, once the mutated gate let it through, lost to the ordinary plan on cost, so nothing errored. Both twins' gate helpers now turn hash aggregation and sorting off, so the grouped path wins whenever the gate offers it; a declined query still runs, on the disabled plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: a NULL key counts as a group in the bucket bound (#1330 review)
Phase 2 of #1292:
GROUP BYis vectorized by default, behind a gate on the estimate. This is the second-to-last of the seven alpha5 time-series features, before #1328. The plan is on #1292 (here). @OffgridwithJD, your points on it are still welcome here, above all on the 10% threshold.What changes for a user
pgcolumnar.enable_group_vectorizationis now on.pgcolumnar.groupagg_max_groups. The bound is the table's live row count or, for time-bucket keys, the buckets The grouped parallel arm loses to the serial node because the Finalize is priced off estimate_num_groups on an expression key #369's and opt: estimate time-bucket groups from the zone maps, and recognise date_bin (#1292) #1304's bounds allow. Above that, the ordinary plan runs and the query succeeds.GROUP BY.The code
columnar_vector.c:pgcolumnar_group_count_upper_boundand the gate sit right after the estimate. Each key's bucket bound is read once per plan and shared by the estimate and the gate, so planning still scanszone_maponce.columnar_tableam.c: the setting's boot value, and both settings' long descriptions.Suites that assumed the old default
The PG16 matrix with the default on failed six suites. Three were this machine's setup:
test_decodingisn't built here, and pg_partman isn't installed for 16. The other three:native_groupagggroupagg_table_sizingvector_agg_tlist_shapeGROUP BYrefusal shape and prints the same markerTests: both twins, the same names
The latest results and the mutation table are in this comment. The arms, in
native_groupagg.shandtest_native_groupagg.py:None of the three suites has ledger rows yet, so there are no ledger changes.
Docs
configuration.md's "why grouped vectorization is off by default" becomes "how the grouped path stays safe on by default", and describes the bound.features.md,how-to.mdandlimitations.mdsaid opt-in and off.features.mdalso said plain columns andcount/sum/avgonly, butdate_bin,date_trunc,minandmaxare taken. The CHANGELOG entry is under Changed.Not in this PR
Spilling instead of erroring over the cap is #1300.
🤖 Generated with Claude Code