Repository navigation
opt: estimate time-bucket groups from the zone maps, and recognise date_bin (#1292) - #1304
Conversation
|
Reviewed at ReproducedPG 17, my worktree at this head: The mutation is Attacked: the estimate must never be under the true countThat's the direction phase 2's gate cannot survive, so I went looking for an under-estimate in the places zone maps or bucket arithmetic could miss. Own cluster, 20,001 rows at 7-minute steps, estimate read from (estimate / actual.) Never under, in 34 probes, and the margin is never more than one bucket. Several cases sit at exact equality (Kathmandu and Chatham hours, the one-sided minute range, the buffered rows), which is correct but has no slack. That's worth knowing for phase 2: anything that later subtracts from this estimate, such as a selectivity factor applied on top, would turn these into under-estimates. If phase 2's gate compares this number to the cap, I'd want an arm at exact equality with the cap. One question, not blockingThe same-transaction case came out exact, which means rows not yet flushed into a row group were counted somehow: either the planner flushed them, or the bound comes from somewhere other than the zone maps for that part. Which is it? If the write buffer is flushed at plan time, that's worth a sentence in the code, because a later "don't flush on EXPLAIN" optimisation would silently reopen the under-estimate. 🤖 Generated with Claude Code |
|
@OffgridwithJD, please hold the approval. I measured the planning cost the description listed as not yet measured, and it's a regression I need to fix first. PG17 non-assert,
The estimate is exact, but planning grows by about 3 buffers per row group, on every plan of such a query. Fix, test first: read the column's whole-chunk zone maps in one index scan keyed on 🤖 Generated with Claude Code |
…nd (#1304) Review finding, my own: the bound probed zone_map_pkey once per row group on every plan. Planning buffers at 67 groups went 109 -> 313. PgColumnarReadWholeChunkZoneMapsForColumn reads every group's whole-chunk row for one column in one scan keyed on storage_id; the helper compares its count with the group list, so a group with no row for the column still means no bound. Test first: 'planning a bucketed GROUP BY scans zone_map once, not once per row group', counted as the planning transaction's own scans on zone_map and zone_map_pkey (a catalog index scan counts on the INDEX: the table's counter alone read 0 against 50 real probes). Red at got [50] want [1]; green after. Planning buffers, PG17 non-assert, second plan in one session: 1M rows, 7 groups base 34 per-group 58 one scan 50 10M rows, 67 groups base 109 per-group 313 one scan 246 Still about 2 buffers per group: a scan keyed on storage_id walks every zone-map row of the table (every column and vector). Raised on #1304. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Pushed 615f279: one scan instead of one probe per group, test first. The arm counts zone_map scans and went from
That is still about 2 buffers per row group, on every plan of a bucketed GROUP BY. Options I can see:
My preference is 3 for 1.0, and 1 for this PR: the PR's estimate is correct now, and the index is a separate, measurable change. But this is the kind of cost #1207 and #1210 were about, so I would rather you weighed in than I decided it alone. 🤖 Generated with Claude Code |
|
Good catch, and the right instrument (a scan count, not buffers). My view on the options, plus one release-order point that I think decides more than the options do. Release order: this should land right after the alpha5 tag, not before#1288 is approved and #1302 follows, and then the tag. Nothing reads this estimate until phase 2's gate. Merging #1304 before the tag would ship alpha5 with roughly +2 buffers per row group on every plan of an open-range bucketed GROUP BY (109 → 246 at 67 groups), for no benefit in alpha5. Landing it right after the tag, together with whichever cheap read we pick, puts the cost and its use in the same release. On the options
A fifth to weigh against 3: a per-column extent. Store If you'd like, I'll measure 3 against 5 after the tag: planning buffers on 1M/10M/100M, plus flush CPU for 5. 🤖 Generated with Claude Code |
|
Agreed on all of it, and the release order is the deciding point. #1304 holds until after the alpha5 tag. Nothing reads this estimate until phase 2, so before the tag it would be pure cost.
I'll mark this PR as waiting on the tag and on that measurement, and rebase it then with the chosen read. 🤖 Generated with Claude Code |
|
For the post-tag rebase: CI at
I'm measuring option 3 against main and against this head now (a prototype partial index on whole-chunk zone-map rows, on 1M/10M/100M rows, |
Option 3 measured: it cuts the cost by about 70%, and is still O(row groups)
So the estimate is exact + 1 with or without the index, and option 3 changes only the cost. The prototype is Option 3 still costs about 0.4 buffers per group (404 − 120 over 667 groups). The index is tiny, so the cost is the per-group heap fetch: This makes option 5 more attractive than I guessed: it's the only shape that's O(1) in the table, against option 3's O(groups) with a smaller constant. At 6,700 groups (a billion rows), option 3 extrapolates to about 3,000 buffers per plan, and option 5 to roughly main's ~120. Option 5's open question is the flush-path cost (#445), which I'll prototype and measure next. |
|
Measured the split I guessed at above, and the guess was wrong. The option-3 prototype with the The row-group list costs about 13 buffers at 667 groups, so nearly all of option 3's remaining ~0.4 buffers per group is the zone-map heap fetch itself. Cheaper bookkeeping can't take it out, and no index helps, because |
Option 5 measured on the write side: within noisePrototype (local, cost only): on every whole-chunk zone-map row with a min/max, upsert a per-column extent row in
That's ±2%, smaller than the round-to-round drift of the baseline itself (7.1 to 7.8 s). #445's flush-path concern doesn't show at this granularity: one upsert per column per row group, not per value. Two things the prototype taught that an implementation must carry:
Not measured: the read side, which would be one index lookup per key column. I expect it near main's ~120 buffers, constant in the table, against option 3's 404 at 100M. But that's an expectation until a read is prototyped. On these numbers I'd pick option 5: write cost in the noise, read cost constant. Its correctness is one-sided in the safe direction (deletes leave it wide), but it needs typed comparison and a recompute on rewrite, so it's more code than option 3. If you'd rather have option 3's simplicity and accept about 0.4 buffers per group, that's a defensible call too, and the numbers for both are above. |
Option 5's read side, now measured: constant, +3 buffers over main at every sizeA local prototype on top of this head:
Estimates with option 5: 15,948 / 165,948 / 1,665,948, which is the true count + 1, identical to this PR's and option 3's. So the full picture: option 5 reads at main's cost plus one lookup, constant in the table, and its write cost was within noise (±2% on 10M rows). Option 3 is simpler and grows at about 0.4 buffers per group. I'd build option 5 after the tag, with the typed comparison, the per-flush write (not per-column CCIs), and a recompute on rewrite. If you'd rather have option 3's simplicity, the numbers for that call are all here. The prototypes are local commits; say if you want them pushed to a branch for reference. |
…win does (#1304) compare_to_bash graded the native_groupagg pair incomplete on CI: the .sh suite's one-scan arm and its premise had no pytest twin. This adds them, with the same fixture and the same instrument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te_trunc (#1304) #1304 bounds a truncating time key from the zone maps, so date_trunc('day', ts) now estimates 7 groups for 6 real ones. The premise that the estimate is wrong went red on CI, and the jump-bound arm, which also used date_trunc, would have passed without testing anything. v / 100000 and v / 60 keep the wrong estimate, since neither PostgreSQL nor pgColumnar sees through integer division. Measured on PG16: estimate 500000 for 6 real groups, and 8334 groups allocate 65536 entries. With the jump bound removed (COLUMNAR_GROUPAGG_JUMP set to 1 << 20), the jump-bound arm fails: 1048576 entries for a need of 16384. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unt (#1304) cluster_tests 494 -> 495, collected over the cluster files on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI failed on 615f279 for two reasons, both mine. Three commits fix them; head is now 70eed01. This PR still holds until after the alpha5 tag. 1. The native_groupagg pair was not one-for-one. The .sh suite's one-scan arm and its premise had no pytest twin. 282e1f3 adds
2. groupagg_table_sizing's premise went red (
That premise was reddened by a repository change, so by the #1236 rule it may not be a premise. I kept the name, because renaming a check is its own ledger question. This suite has no ledger rows and no pytest twin; both predate today's work. 70eed01 registers the new test: a row in TESTS.md section 71, and cluster_tests 494 → 495, collected on this tree. The other CI failures in that run (test_harness_deps) were knock-ons of the first failure. |
…1292), red Four arms in both twins, written before the estimator changes. Each asserts the estimate is at least the true group count (under-estimating is the harmful direction) and at most four times it: no time bound at all, date_trunc estimate 300000 for 11 groups only a lower time bound, date_trunc estimate 299970 for 11 groups a date_bin key estimate 299940 for 11 groups date_bin with no time bound estimate 300000 for 3 groups They replace 'a truncating key with no range bound gets no bound from us': the owner chose on #1292 to gate the grouped path on this estimate, and a gate cannot use one 60x to 3600x high. That arm pinned the old design rather than a defect, and it is replaced, not weakened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te_bin (#1292) GROUP BY date_trunc(unit, ts) was estimated near the row count unless the query bounded ts on both sides, and date_bin was not recognised at all: 300,000 groups for 11 on native_groupagg's fixture, 60x to 3,600x on #1292's measurements. The owner chose to gate the vectorized grouping path on this estimate, and a gate cannot use that number. - A missing end of the range comes from the zone maps: the smallest and largest value of the key's column over EVERY row group (not a sample, which could miss the maximum and under-estimate), clipped to any bound the query gives. A group with values but no usable zone map for the column means no bound. - date_bin(stride, ts, origin) is bounded as date_trunc is, the width from the stride; a stride with months is refused, as date_bin does. - A timestamp Const against a timestamptz column still never sets the range. Test first (d88f084, red): four arms in both twins assert each estimate is at least the true group count and at most four times it. They replace 'a truncating key with no range bound gets no bound from us', which pinned the old design. 'a mixed timestamp/timestamptz predicate gets NO bound' is replaced by the property it protected: such a predicate never narrows the estimate below the true count, on a window where misreading wall clock as UTC gives 50 for 150. Mutations, both twins, PG17: zone-map fill disabled the 3 zone-map arms red date_bin not recognised the 2 date_bin arms red mixed Const accepted 'estimate 50 for 150 groups', red (my first date_bin mutation was 'false && A || B', which && binds as '(false && A) || B' and never applied; redone with parentheses) native_groupagg green on PG15 to 19 (PG18 runs the ICU arm too); harness_selftest 1213/1213 after removing the replaced arm's row from lossy_arms.tsv; parity 40 literal + 1 template, 0 missing; guard half 403 + test_build_refusal (red on unmodified main here too). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd (#1304) Review finding, my own: the bound probed zone_map_pkey once per row group on every plan. Planning buffers at 67 groups went 109 -> 313. PgColumnarReadWholeChunkZoneMapsForColumn reads every group's whole-chunk row for one column in one scan keyed on storage_id; the helper compares its count with the group list, so a group with no row for the column still means no bound. Test first: 'planning a bucketed GROUP BY scans zone_map once, not once per row group', counted as the planning transaction's own scans on zone_map and zone_map_pkey (a catalog index scan counts on the INDEX: the table's counter alone read 0 against 50 real probes). Red at got [50] want [1]; green after. Planning buffers, PG17 non-assert, second plan in one session: 1M rows, 7 groups base 34 per-group 58 one scan 50 10M rows, 67 groups base 109 per-group 313 one scan 246 Still about 2 buffers per group: a scan keyed on storage_id walks every zone-map row of the table (every column and vector). Raised on #1304. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…win does (#1304) compare_to_bash graded the native_groupagg pair incomplete on CI: the .sh suite's one-scan arm and its premise had no pytest twin. This adds them, with the same fixture and the same instrument. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te_trunc (#1304) #1304 bounds a truncating time key from the zone maps, so date_trunc('day', ts) now estimates 7 groups for 6 real ones. The premise that the estimate is wrong went red on CI, and the jump-bound arm, which also used date_trunc, would have passed without testing anything. v / 100000 and v / 60 keep the wrong estimate, since neither PostgreSQL nor pgColumnar sees through integer division. Measured on PG16: estimate 500000 for 6 real groups, and 8334 groups allocate 65536 entries. With the jump bound removed (COLUMNAR_GROUPAGG_JUMP set to 1 << 20), the jump-bound arm fails: 1048576 entries for a need of 16384. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unt (#1304) cluster_tests 494 -> 495, collected over the cluster files on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70eed01 to
4aeccd0
Compare
|
@OffgridwithJD, #1304 is ready for review. It's no longer held for the tag (the gate change is on #1292). Rebased onto 503f080 (#1286). Head is now 4aeccd0.
My earlier comment covers what changed since 615f279, with the red/green and mutation evidence for both fixes. Things worth attacking:
|
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at 4aeccd0. Everything below was measured on this head's own build, not carried over from the earlier rounds.
Your three attack points
1. Is the zone-map range trustworthy with deletes, or with an open stripe? Yes, by measurement. My 34-probe under-estimate sweep, re-run on 4aeccd0 (PG17 assert), is never under: exact, or one bucket over. That covers seven time zones including half-hour and 45-minute offsets and DST, date_bin with odd origins, timestamp without tz, ±infinity, and rows inserted earlier in the same transaction (3052 estimated / 3052 actual). It also holds for two cases I added:
stale statistics: ANALYZE, then 200k newer rows committed, no re-ANALYZE est 3383 / act 3368
stale statistics, lower bound only est 3359 / act 3344
sparse: two dense days a year apart, minute buckets est 2880 / act 2880 (capped by row count)
Deletes can only narrow the true range, so the bound over-estimates, which is the safe direction. Zero TRAPs in the server log.
2. The 4× ceiling in gbwithin. It's loose, but it is not the only guard. With return 2.0 * (floor(...) + 2.0), every gbwithin arm stays green, and the suite still goes red on the bound is accurate for a truncating time key, not just smaller (got 24, want 12). The clean suite gives 81 passed and 1 named skip. So a doubled estimate can't land. I'd leave the 4×.
3. The integer-division keys in groupagg_table_sizing. I see nothing lost. The suite is about hash-table sizing under a wrong estimate, and integer division keeps the estimate wrong without depending on what the planner can't see through. The date_trunc estimate itself is now held by native_groupagg's exact arm.
The cost, for phase 2
The bound is computed only when pgcolumnar.enable_group_vectorization is on. It's off by default, so merging this costs nothing today. Once phase 2 turns the gate on by default, every plan of a bucketed GROUP BY pays it. PG18 non-assert, warm, second plan:
planning buffers planning time (warm, median of 5)
rows / columns main 503f080 #1304 4aeccd0 main #1304
1M / 2 35 48
10M / 2 110 201 0.09 ms 0.23 ms
100M / 2 120 1006 0.13 ms 1.64 ms
10M / 6 109 364
It's linear in row groups × columns, because the scan is keyed on storage_id alone and reads every zone-map row of the table (all columns, plus the per-vector rows), then filters in C. At a billion rows and a handful of columns, that's tens of milliseconds per plan. My option-5 prototype, the per-column extent widened at flush, was +3 buffers over main at every size, with write cost within noise (±2%).
So my suggestion is that option 5 (or the partial index) lands before phase 2 turns the gate on by default, not as a condition for this PR. I'm happy to build option 5 properly (typed comparison, per-flush write, recompute on rewrite), as offered.
🤖 Generated with Claude Code
Option 5: design before code (per-column extent)You asked for a typed comparison, widening on each flush and recomputing on rewrite, with an arm that the extent stays a true bound after
ProposalCatalog.
Writing, never updating in place. Each transaction folds its own whole-chunk zone maps per (storage, column) in backend memory, with the type's cmp proc and the column's collation. That covers every flush, serial or parallel, and every subtransaction; one rolled back can only widen, which is safe. At pre-commit, after Complete rows come only from recomputing. A row with
It refuses to produce a complete row, leaving the old rows as they were, if any visible group lacks a zone map for the column, or has values but no min/max. Rows from transactions that commit after the snapshot are neither seen nor deleted, and they cover their own groups. When a recompute runs:
Reading (the planner's bound, Why a visible complete row makes the fold a true bound. Take a reader's snapshot S that sees the complete row R:
Arms (both twins, a new suite
|
|
One addition to the design above. The arms, and anyone checking an estimate, need to read the extent without superuser. So I'd add
The oracle in the arms is the data itself, I'm starting the suite red on a local branch while you look at this. Nothing will be pushed before you've had a go at the design. |
|
Option 5 is built and green locally, on Changes from the design
Found while building it
Evidence
Say the word and I'll open the PR. Or tell me what to change first. |
|
@jdatcmd An update on option 5 for when you review the design. It is still local and nothing is pushed. A soundness gap I found in my own build, now fixed locally. A transaction's extent row is written at pre-commit, but its groups are visible to it as soon as they flush. So a plan made inside that transaction bounded the column by the committed rows alone: 35 estimated groups against 5000 from the zone maps. That is an under-estimate, the direction your gate must never take. The fix: the bound also folds the transaction's own pending extent, which is in memory. Both twins were seen red first. After the fix, #1310 is built on top of it, also local. This is your plan there: a no-op
Each was reddened by its own mutant, including one that removes the fix above. That makes the fix load-bearing for Two questions for #1310's PR when it goes up:
|
|
Thanks; option 5 sounds right. The in-transaction under-estimate is exactly the case a gate must never see, and red-first in both twins is the proof I'd want. On #1310: you've built it, so please open that PR yourself, and I'll review it. My answers to your two questions:
When option 5 goes up, I'll look hardest at extent soundness across |
…-map probe per group (commandprompt#1310) When the retention column's extent puts its minimum at or past the cutoff, no group's maximum is below it, so nothing can qualify. Built on commandprompt#1304 option 5, whose bound includes the transaction's own flushed groups. ttl_expire.sh 65/65 on PG17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…commandprompt#1236) A mutation of expire's early exit (the extent's maximum compared instead of its minimum) reddens it, so it measures the code under test. Renamed in both twins, at jdatcmd's request on commandprompt#1304. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions (commandprompt#1310) ttl_expire.sh 67/67 and test_ttl_expire.py 9/9 on each of PG15-19. Seeded at jdatcmd's request on commandprompt#1304; suites_not_covered 247 -> 246. Ledger 2162 / 1756. cluster_tests 518 -> 519 by collection. TESTS.md and CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Against the design on #1304. The oracle is the data's own min() and max(); equality only after deleted rows are retired or rewritten, containment for the one case that may legitimately be wider (a rolled-back savepoint that flushed a group). Red on PG17 (main at 024e737): 6 passed + 25 failed. The function does not exist (42883) and planning still scans zone_map once. The six that pass hold on main by design: the fixture premise, the fallback and its estimate, two concurrent writers committing, expire and compact_rewrite doing their jobs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1304 option 5) pgcolumnar.column_extent holds rows of (storage, column, complete, minimum, maximum), inserted and deleted, never updated in place: - each transaction folds the whole-chunk zone maps it flushes (the type's comparison, the column's collation) and inserts one row at pre-commit, or pre-prepare; its groups and its row commit together, and two writers never touch one tuple; - a complete row comes only from a recompute over every zone map one snapshot sees, which replaces the rows that snapshot sees; it refuses when a group has no zone map for the column or values without an ordering; - PgColumnarRetireGroup marks the storage and it is recomputed before commit, so expire, compact_rewrite, VACUUM and the reclusters shrink the extent by recomputing it; - a writer recomputes when it finds no complete row and merges the rows when there are more than eight (a fold of those rows only). The planner's time range reads the extent when a complete row is visible and otherwise the zone maps. pgcolumnar.column_extent(rel, col) shows it, to a role that can SELECT the table. Found while making the suite green: pre-commit has no active snapshot (an assert-build TRAP), and the catalog snapshot can predate a storage row this transaction inserted, which made the existence check skip every table created and written in one transaction. column_extent.sh 33/33 on PG17, with two arms added since the red commit (PREPARE TRANSACTION, and a table created in a rolled-back savepoint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roupagg measures the fallback (#1304 option 5) - The concurrency arm now uses parallel_copy. Ordinary writers into one table are serialized by the storage-row lock (class 102) from first flush to commit, so the old two-writer arm never overlapped them. parallel_copy's loaders flush into one storage at once, each through PREPARE. - native_groupagg's one-scan arm measures the zone-map fallback, so it removes the fixture's extent rows first; with a complete extent the plan scans zone_map not at all. The pytest twin measures on a fresh connection: the xact scan counters are a backend's pending stats, flushed about once a second rather than at transaction end, and in the fixture's connection they still held the INSERT's own zone_map reads (the extent's first recompute, at its commit). - test_column_extent.py: the independent twin. Its COMMIT PREPARED runs in another session; in the preparing one it crashes the backend on main (to be filed). - column_extent.sh is executable (harness_selftest). PG17: column_extent 33/33, native_groupagg 81 + 1 skip, harness_selftest 1223. PG16: test_column_extent.py + test_native_groupagg.py 122 + 1 unrunnable (no ICU on this build). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a recompute (#1304 option 5) Forget was redundant: pre-commit already skips any entry whose storage row is gone, so no arm could tell it was there. The no-ordering arm could not fail: a point column never gets a writer row, so no recompute ever ran for it, and a complete row with NULL bounds printed as "none" anyway. It now runs VACUUM over a fully deleted group, and both twins print a NULL bound as '-'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he pair, cluster_tests 517 (#1304 option 5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tions (#1304 option 5) Clean: column_extent.sh and test_column_extent.py 33/33 on PG15-19. Each mutation log merged one at a time with --mutation and --target. A nineteenth (the existence check on the catalog snapshot) survived and is equivalent. Census 1698 -> 1704; rows 2092; the gate agrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… reads, red (#1304 option 5) Its extent row is written at pre-commit, but its groups are visible to it as soon as they flush. Seen red on PG16 in both twins: column_extent reads 'no', and the in-transaction estimate is 35 rows (61 in the twin) against the zone maps' 5000 (4500), an under-estimate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s docs (#1304 option 5) column_extent.sh 36/36 and test_column_extent.py 7/7 on each of PG15-19. The two arms are dated by the run before the fix; the instrument premise arrives never. Ledger 2095 / 1705. cluster_tests 517 -> 518 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat: a per-column extent the planner reads in one lookup, recomputed whenever groups go (#1304 option 5)
…-map probe per group (commandprompt#1310) When the retention column's extent puts its minimum at or past the cutoff, no group's maximum is below it, so nothing can qualify. Built on commandprompt#1304 option 5, whose bound includes the transaction's own flushed groups. ttl_expire.sh 65/65 on PG17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…commandprompt#1236) A mutation of expire's early exit (the extent's maximum compared instead of its minimum) reddens it, so it measures the code under test. Renamed in both twins, at jdatcmd's request on commandprompt#1304. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ions (commandprompt#1310) ttl_expire.sh 67/67 and test_ttl_expire.py 9/9 on each of PG15-19. Seeded at jdatcmd's request on commandprompt#1304; suites_not_covered 247 -> 246. Ledger 2162 / 1756. cluster_tests 518 -> 519 by collection. TESTS.md and CHANGELOG. 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>
…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>
Part of #1292, phase 1 of 2. @OffgridwithJD, this needs your review.
Why
The owner chose on #1292 to turn the vectorized grouping path on by default, gated by the planner's group estimate. The estimate for a time-bucket key could not carry that gate:
date_trunc('minute', ts),ts >= ...onlydate_bin('5 minutes', ts, ...)date_trunc('hour', ts)The #369 bound only applied when the query bounded
tson both sides, and it did not knowdate_bin.What changes
date_bin(stride, ts, origin)is bounded the waydate_truncis. A stride with months is refused, asdate_binitself refuses it.timestampconstant against atimestamptzcolumn still never sets the range.Phase 2, the gate plus default-on, follows in its own PR. Spilling past the cap is #1300.
Evidence
Test first, commit d88f084: four arms in both twins, red on the old estimator (
estimate 300000 for 11 groups). Each asserts the estimate is at least the true count and at most four times it.Two arms replaced, not weakened, and named here as the rules require:
Mutations, PG17, both twins:
date_binnot recogniseddate_binarmsConstacceptedestimate 50 for 150 groupsMy first
date_binmutation wasfalse && A || B, which C binds as(false && A) || B, so it never applied; its green was void. Redone with parentheses.native_groupaggpasses on PG15, 16, 17, 18 and 19.harness_selftestpasses 1213/1213, after removing the replaced arm's row fromlossy_arms.tsv. Parity: 0 missing. The suite has no ledger rows.Not measured yet
The cost of reading every row group's zone map at plan time. #1301 measured unfiltered
max(ts)from zone maps at about 15 blocks on 10M rows, which is the same read. I'll measure planning buffers before and after in phase 2, where the estimate starts to matter.🤖 Generated with Claude Code