Skip to content

feat: GROUP BY is vectorized by default, gated on the group estimate (#1292) - #1330

Merged
jdatcmd merged 8 commits into
mainfrom
feat/1292-gate-default-on
Oct 13, 2026
Merged

jdatcmd merged 8 commits into
mainfrom
feat/1292-gate-default-on

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Phase 2 of #1292: GROUP BY is 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

The code

  • columnar_vector.c: pgcolumnar_group_count_upper_bound and 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 scans zone_map once.
  • 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_decoding isn't built here, and pg_partman isn't installed for 16. The other three:

suite why change
native_groupagg forced the node under a cap of 100 to reach the runtime error; the gate now declines that plan the arm is removed. Section 10's stale-statistics arm reaches the cap through a wrong estimate instead. "plan: node still chosen (no estimate gate)" is renamed to what it holds
groupagg_table_sizing measures hash-table sizing on 200,000 groups raises the cap, so the gate admits its fixture
vector_agg_tlist_shape tests the ungrouped fold; the grouped path now takes its GROUP BY refusal shape and prints the same marker its plan helper pins the grouped path off

Tests: both twins, the same names

The latest results and the mutation table are in this comment. The arms, in native_groupagg.sh and test_native_groupagg.py:

  • the setting is on by default;
  • a bucket query is taken by default, and still taken over more rows than the cap;
  • a plain key over more rows than the cap is declined, and answers as heap does;
  • raising the cap to 10,000,000 admits it;
  • the skewed key (ANALYZE below 3,000 for 40,001 groups) is declined, and answers every group;
  • a cached plan run under a lower cap stops at the runtime cap with its hint.

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.md and limitations.md said opt-in and off. features.md also said plain columns and count/sum/avg only, but date_bin, date_trunc, min and max are taken. The CHANGELOG entry is under Changed.

Not in this PR

Spilling instead of erroring over the cap is #1300.

🤖 Generated with Claude Code

jdatcmd and others added 5 commits October 13, 2026 14:19
…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>
@jdatcmd
jdatcmd force-pushed the feat/1292-gate-default-on branch from ab22bc5 to d0bd2d5 Compare October 13, 2026 14:20
@jdatcmd

jdatcmd commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

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:

  • logical_decoding_cdc_recipe and logical_decoding_source: test_decoding.so isn't built for 19 here;
  • partman_columnar: pg_partman isn't installed for 19.

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. cluster_tests stays at 527 by collection: 946 tests, 419 guard + 527 cluster.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 groups

Why 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

  1. 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.
  2. 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.
  3. At least: change the hint. Point it at SET pgcolumnar.enable_group_vectorization = off rather than ANALYZE, and add the repro shape to limitations.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.

jdatcmd and others added 3 commits October 13, 2026 14:46
@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>
@jdatcmd

jdatcmd commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

@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 groupagg_max_groups. The bound is the smaller of two numbers:

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:

  • by default, a bucket query is taken;
  • a bucket query over more rows than the cap is still taken;
  • a plain key over more rows than the cap is declined, and answers as heap does;
  • raising the cap to 10,000,000 admits it;
  • your shape: 95% of 800,000 rows on one key gives 40,001 groups, which ANALYZE estimates below 3,000. Under a cap of 30,000 it is declined and answers every group;
  • the runtime cap is reached through a cached plan (PREPARE under the default cap, EXECUTE under 1,000), and errors with its hint.

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 exceeded pgcolumnar.groupagg_max_groups (30000).

Green at 79a9b39 (the code; e62d472 only adds docs):

  • native_groupagg: 89/89 on 15, 16, 17 and 19, and 91/91 on 18, with 1 skip where ICU is absent.
  • groupagg_table_sizing: 15/15 on each of 15–19.
  • vector_agg_tlist_shape: 68/68 on each of 15–19.
  • test_native_groupagg.py: 18 passed on each of 15–19.
  • Full PG16 matrix: only the three suites this machine can't run fail (test_decoding, pg_partman).
  • Full pytest on PG18: 944 passed. The two failures are test_build_refusal's fingerprint premise, which fails the same way on main in my sudo-run worktrees, and the guard test that wraps it.

Mutations, native_groupagg.sh on 15–19:

mutation shell, every major pytest PG17
no gate plain key declined, its heap answer, the skewed key red at "plain key declined"
the gate on the estimate (this PR before your review) the skewed key, the cached plan red at the cached plan
the bound without the row count (the time buckets, else the estimate) the skewed key green
the default off the five arms that need it on red at "on by default"
no NULL group in the bucket bound none green

I'm reporting two gaps rather than hiding them:

  • The row-count mutation is caught by the shell twin only. With the estimate in its place, the pytest twin's skewed table loses on cost rather than taking the path, so nothing errors there.
  • The NULL group has no arm. The bucket bound already counts a partial bucket at each end, which is the likely reason. I kept the +1 because it is what makes the bound true for a key with NULLs, but it is defensive and untested. Say if you'd rather it went.

The docs now describe the bound, and docs_style passes 58/58. The how-to no longer tells the reader to run ANALYZE.

@OffgridwithJD OffgridwithJD left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. PgColumnarTryGroupAggPath returns early for anything else, so a join's or an appendrel's estimated tuples never reaches the gate.
  • Concurrent writers. relation_estimate_size reads 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; INSERT 500 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.0 changed to *withNull *= one: the path is taken (rows=3), and the query errors with exceeded 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.

@jdatcmd

jdatcmd commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@jdatcmd
jdatcmd merged commit b55cb23 into main Oct 13, 2026
15 checks passed
jdatcmd added a commit that referenced this pull request Oct 13, 2026
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>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
@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>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
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>
jdatcmd added a commit that referenced this pull request Oct 13, 2026
test: a NULL key counts as a group in the bucket bound (#1330 review)
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