fix: sample every row group, and only the predicate columns, when estimating pruning (#817) - #821
Conversation
…imating pruning (commandprompt#817) pgcolumnar_zonemap_survival prices a restricted columnar scan by sampling row groups and asking the reader's own skip predicates how many survive. Two things were wrong with how it sampled. The stride was zero-based. Row group numbers are stripe ids reserved from the metapage, and the metapage starts reservedStripeId at 1, so group 0 exists on no table: the first probe was always spent on a number that could not exist, and group ngroups was never probed at all. When the group count fits in the sample target this loop is a census, and it was a census that omitted the newest group every time. The same predicate was therefore priced differently according to where in the table its groups sat. On ten groups of 2,000 rows with identical row estimates, the six newest groups cost 166.89 against the six oldest at 200.27, and the two newest cost 33.38 against the two oldest at 66.76 -- exactly half. The under-priced half is the recency predicate this engine is aimed at. The sample also read every column to use one. PgColumnarReadZoneMapList keys on (storage_id, group_number) against the four-column zone_map_pkey, so it fetched every column's and every vector's row and then dereferenced one column's. pgcolumnar_native_group_can_match has asked the per-column question with a three-key probe since commandprompt#314; the estimator now asks it the same way, through the same session and with a lookedUp memo. Planning-time zone_map fetches go from 540 to 10 on a 30-column table and from 36 to 10 on a 2-column one. The two halves could not ship apart. Fixing only the stride makes the whole-group probe reachable at the default stripe_row_limit, where the wasted probe on group 0 had been hiding it, and native_zonemap_narrow correctly reddens at wide 90 against narrow 34. Also removes a dead store in PgColumnarReadZoneMapForColumn, which resolved zone_map_pkey unconditionally two lines after reading the value commandprompt#744 had cached for exactly that purpose. Shown by poisoning the cached value: inert with the dead store present, and 20 sequential scans of zone_map once it is gone. The estimator's session reports as "zone map estimate:" so that a scan's "zone map read:" line keeps the meaning native_zonemap_session counts on. A scan's line is byte-identical to what commandprompt#744 shipped. Not changed: the survival site still reads the planning session's stripe_row_limit, which is what commandprompt#817 was filed for, and customscan.c's if (groups < 1.0) guard remains dead but harmless.
|
Independent confirmation on someone else's fixture, and it is sharper than my own: The jdatcmd session built this for #766/#753 without reference to my fixture: 200,000
The written-limit row is the one to read:
The default row does not move, and that is the correct outcome for this PR. That is the half of #817 this PR deliberately leaves open: a two-group model of a One correction to my own record: I predicted to the other session that the default acting as: OffgridwithJD |
PR #821 fixed the zone-map estimator's sample. It did not change which row-group limit pgcolumnar_zonemap_survival reads, which is the half this suite works around. A reader seeing #817 referenced as fixed could delete a line the suite still needs. The comment now names the GUC half specifically and carries the measurement on both sides of #821: the default-limit cell is unchanged at 4212.00 against 4212.00, and the written-limit cell moved from 1404.00 to 1263.60, which is 4212 x 3/10 and agrees with the "Chunk Groups Read: 3 of 10" that EXPLAIN ANALYZE reports. The check asserts the ordering, which holds either side. Measurement by OffgridwithJD against their #821 branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Y7gXubmW8DDDZPZNPXJHm
jdatcmd
left a comment
There was a problem hiding this comment.
Approved. CI is clean, both suites legs green, and I checked the load-bearing
premise at source rather than taking it.
The premise this whole fix rests on, verified independently. Not from the
metapage initialiser but from real data, on a 4,000,000-row fixture of mine with
91 row groups across three tables:
SELECT min(group_number), max(group_number), count(*) FROM pgcolumnar.row_group;
1 | 27 | 91
min is 1. Group 0 exists on no table, so the old stride really did spend its
first probe on a number that cannot exist and really did never reach group
ngroups.
The mirror pair is the part that makes this convincing. One clause each, the
same operator family, the same six matching groups, differing only in where in
the table those groups sit, and identical row estimates asserted so run is
constant and survival is the only moving input. 166.89 against 200.27 is then
attributable to position and nothing else. A single predicate would not have
shown that, and a pair with different selectivities would not have isolated it.
The removal proof does what a removal proof has to do. Two mutations that are
orthogonal, each reddening only its own defect's checks, so the suite tells the
two defects apart rather than failing as a block. Plus the 2x2 for the dead
store, where the two "present" cells being identical IS the claim.
And the .so fingerprint per arm is the right control. I hit precisely that
failure today from the other direction: shared_preload_libraries loads the
library at postmaster start, so a probe on a long-lived cluster measured stock
code and reported a clean 0.03% null result, and a changed-output control passed
for the same reason. Your fingerprint line is the cheap version of the check that
would have caught it.
Two things I checked because they looked like gaps and are not.
The stride still thins above 32 groups, and you say so rather than leaving it. I
confirmed the bound: g_max = 1 + floor((nsample-1) * ngroups / nsample), which
is 1 + ngroups - ceil(ngroups/nsample), so g <= ngroups for every
nsample <= ngroups and the stride cannot run off the end. Sampling error rather
than an off-by-one is the correct characterisation.
The survival site still reading the planning session's GUC is out of scope here
and stated as such, and your suite uses the per-table option so it does not
depend on that question either way. That is the half my doc_parallel_premise
workaround covers, and the comment there now points at it specifically.
Nothing blocking. Merging is yours whenever you want it.
The column comment on pgcolumnar.row_group said "0-based row group ordinal". It is not. A group number is the stripe id reserved from the metapage when the group began buffering, and PgColumnarInitMetapage starts reservedStripeId at 1, so there is no group 0 on any storage. Measured rather than reasoned: on a 27-group table, row_group and zone_map both report min = 1, max = 27 over 27 distinct numbers. This is worth correcting because a reader believed it. The planner's zone-map sample walked [0, ngroups), spending its first probe on a number that cannot exist and never probing the highest group at all, which priced the same predicate differently according to where in the table its groups sat. That was fixed in #821; this is the statement that taught it, still in the tree. The comment is a source comment, not a catalog one. There is no COMMENT ON for the table, so it never reaches pg_description, and native_upgrade_converge -- which compares col_description -- cannot see it. Nothing changes at runtime. The same line appears in test/fixtures/pgcolumnar--1.0-alpha.sql and --1.0-alpha2.sql and is deliberately left alone: those are faithful snapshots of shipped versions, and editing them would defeat what the upgrade tests check. The neighbouring "column_index -- 0-based attribute position" is correct and unchanged.
What is wrong
pgcolumnar_zonemap_survivalprices a restricted columnar scan by sampling rowgroups and asking the reader's own skip predicates how many survive. Two things
were wrong with how it sampled, and a third defect sits in the probe it now uses.
1. The sample was zero-based, so it never looked at the newest group
PgColumnarEstimatePruneSurvivalwalkedfor
iin[0, nsample), so it sampled[0, ngroups).A row group number is the stripe id reserved from the metapage
(
columnar_write_state.c: "the row group number is the stripe id"), andPgColumnarInitMetapagestartsreservedStripeIdat 1. Group 0 exists on notable. So the first probe was always spent on a number that could not exist,
and group
ngroupswas never probed at all.The wasted probe was harmless: an absent group narrows the sample rather than
biasing it, which is what the
continuesays. The missing one was not. When thegroup count fits in
PGCOLUMNAR_PRUNE_SAMPLE_GROUPSthis loop is a census,and it was a census that omitted the newest group every time.
Measured on ten groups of 2,000 rows, one clause each, and — verified in the
suite — identical row estimates, so
runis identical and the only input thatmoves is survival:
c1 > 8000c1 <= 12000c1 > 17000c1 <= 4000Exactly half, in the narrow pair: five survivors in nine examined against six,
and one against two. The under-priced half is the recency predicate this engine
is aimed at — on batch-loaded time-series,
WHERE ts > now() - interval '1 hour'selects precisely the groups the sample never looked at.
The mirror pair is the control that isolates position and nothing else: one
clause each, the same operator family, the same default selectivity, the same
six matching groups, differing only in where in the table those six sit.
2. The sample read every column to use one
It called
PgColumnarReadZoneMapList, which keys on(storage_id, group_number)against the four-columnzone_map_pkey, so it fetched everycolumn's and every vector's row from the heap, discarded the vector rows after
the fetch, and then dereferenced
byCol[preds[p].attidx]alone. On a 30-columntable that is 60 tuples per group to read one column's min and max.
pgcolumnar_native_group_can_matchhas asked the per-column question with athree-key probe since #314. The estimator now asks it the same way, through the
same
PgColumnarReadZoneMapForColumnand the same #744 session, with the samelookedUpmemo so two predicates on one column probe once.Planning-time
zone_mapfetches,EXPLAINonly:w, 30 columnsn, 2 columnsTen groups, one predicate column, one heap fetch each — the probe
breaks atthe whole-chunk row and
zone_map_pkeyordersvector_indexascending, so-1is the first tuple it meets. The suite asserts that ordering as a premise rather
than assuming it.
These two could not ship apart
I tried. The one-based fix alone makes the whole-group probe reachable at the
default
stripe_row_limit, where the single wasted probe on group 0 had beenhiding it, and
native_zonemap_narrowcorrectly reddens at wide 90 againstnarrow 34 on a bound of 2x. That suite is right and the change was incomplete.
3.
PgColumnarReadZoneMapForColumndiscarded the index oid it had cached#744 resolves
zone_map_pkeyonce per read session and stores it insess->idxOid. An unconditional second lookup stood immediately beforesystable_beginscanand overwrote both that value and the no-session branch'sown lookup. The
openscounter never noticed, because it counts relation opens,which the session really did save — so the cache read as wholly effective while
half of what it cached was thrown away. A dead store draws no compiler warning.
This one is in scope because this PR adds a second caller of that function.
The estimator's session needed a label, and
native_zonemap_sessionsaid soThe estimator holds a #744 session of its own, so it resolves
zone_maponce forthe whole sample rather than once per probe. That made its
DEBUG1reportindistinguishable from a scan's, and
native_zonemap_sessioncounts scan reportsaround an aborted scan: it read 7 where it wants 3, being four planner closes
plus three executor ones.
That suite is right, and I did not touch its expectation. The session now carries
what it was opened for, so the planner's line reads
zone map estimate:and ascan's stays
zone map read:— byte-identical to what #744 shipped. The suitecounts 3 again, and the planner's probe count becomes observable, which is the
quantity this whole PR is about. The new suite asserts it:
probes=10 opens=1.Removal proof
Every arm below was a fresh build; the installed
.sofingerprint differs onevery one, so none of them tested a stale binary.
Mutation A — restore the zero-based stride, keep the per-column probe.
Both CENSUS checks read 9 of 10 groups; the mirror pair splits 166.89 against
200.27 and the narrow pair 33.38 against 66.76. WIDTH still passes (9 = 9), and
native_zonemap_narrowstill passes. 4 failures, all of them defect 1's.Mutation B — restore the whole-group probe, keep the one-based stride.
WIDTH reads 600 against 40 and both CENSUS checks fail; both cost pairs stay
equal, because the census is complete.
native_zonemap_narrowfails at 90against 34. 3 failures here plus 1 there, all of them defect 2's.
Neither mutation touches the other's checks, so the suite tells the two apart.
The dead store, as a 2x2. Poison the cached value to
InvalidOidandnothing else. With the dead store present the poison must be inert; with it
removed the poison must reach
systable_beginscan, which then declines theindex.
The two "dead store present" cells are identical, which is the claim: the cached
value was discarded. Only removing the dead store makes the cache load-bearing.
What this does not change
at the index-fetch penalty and deliberately left it here; this PR does not
revisit it. The new suite uses the per-table
stripe_row_limitoptionprecisely so it does not depend on that question either way.
ngroups > nsamplethesample is a stride over
[1, ngroups], not a census, and a narrow predicateat either end can fall between its teeth. That is sampling error, not an
off-by-one, and it is the same on both sides of this change.
gis bounded byngroupsfor everynsample <= ngroups, so the stride cannot run off the end.customscan.c'sif (groups < 1.0) return 1.0;is dead —rel->tuples <= 0has already returned, so
ceilof a positive is always at least 1. It isharmless and untouched here; it is noted on the issue.
Tests
test/zonemap_estimate_sample.sh, registered inSUITESand listed indocs/testing.md. 26 checks, including five premises — the fixture's row count,its per-table option, that its groups really are numbered 1..10 with no group 0,
that the whole-chunk row sorts first, and that a cost was actually extracted
rather than two blanks compared. The floor is checked too: a predicate matching
nothing and one matching only the last group both come out at one group's share,
30.04.
Gate
harness_selftestSUITESwas edited)docs_styleAn earlier run of the matrix, before the session label, had
native_zonemap_sessionas its only failure out of ~228 suites; that is thefailure the label addresses.
acting as: OffgridwithJD