opt: resolve zone_map once per scan, not once per group (#744) - #750
Conversation
The skip loop called PgColumnarReadZoneMapForColumn for every chunk group a predicate could exclude, and each call ran open_columnar_table (pgcolumnar_schema_oid, get_relname_relid, table_open with a lock and a resource-owner remember) plus pgcolumnar_index_oid (a second get_relname_relid), then closed again. The md_flush reuse cache could not help: it is gated on md_flush.active, which only columnar_write_state.c ever sets, so the read path never reached it. ## The measurement #744 asked for and did not have #744 priced one whole call at 25,185 instructions and 6.00 buffers per group per predicate column, and said explicitly that it had NOT split that into the open-and-lookup half and the index-probe half -- the split that prices this route honestly rather than against an upper bound. One variable: the same binary with only `if (sess->rel == NULL)` defeated, 160 chunk groups, one predicate column, backend instructions with the process pinned to one PMU (this CPU is hybrid; an unpinned window splits across cpu_atom and cpu_core, each line scaled by 1/fraction, and is then uninterpretable -- two such runs were discarded rather than read). baseline 365,581,540 / 365,633,874 -> 18,280,385 per query cached 280,348,880 / 280,187,820 -> 14,013,418 per query saved 4,266,968 per query = 26,669 per chunk group = 1.304x Against the 4,893,173 that #744 measured for locating the surviving groups at this size, this removes 87%. The systable index probe is the residue, so #403 item 2's sparse index should now be justified against that rather than against the 66% figure. Buffers did not move: 186 / 314 / 567 at N = 40 / 80 / 160 on both arms. Predicted before measuring, on the grounds that catcache and relcache lookups read no buffers once warm. So the 6.00 buffers a probe costs are the index scan alone, and the CPU it costs was mostly the open. Neither instrument prices this on its own. ## Per scan, not static, and that is the whole design The first version held the relation in a file-level static with a depth counter and reset hooks on transaction abort. It was WRONG. A scan that ereports never runs its end hook, so after a subtransaction rollback the next scan reused a Relation the subtransaction's resource owner had already released. No correctness test caught it. Nested scans, post-abort scans and rescans all returned correct answers, because a released relcache entry is refcount-decremented rather than freed and the stale pointer still read correctly. It was visible only as a missing DEBUG1 witness: three scans should each report and only two did, because the middle one ran at depth 2. The session now lives in PgColumnarReadState, so it dies with the scan and nothing crosses scans. There is no depth, no static, and no abort wiring. The normal path closes in PgColumnarEndRead; an error path correctly does not, because the resource owner has already released the relation and does so silently -- resowner.c prints leak warnings only when isCommit, which was read in the PG17 source rather than assumed. ## Test test/native_zonemap_session.sh, 10 checks, asserts the WORK DONE and not the answer, because the rows are identical either way: probes=40 opens=1 over 40 groups, one predicate column probes=41 opens=1 two predicate columns -- one extra probe, not one per group, because native_zone_excludes returns as soon as the first predicate excludes a group plus the lifetime arms: nested scans, and three scans around one that errors inside a subtransaction, each of which must report its own session. Removal proof, defeating only the caching: three checks go RED ("got [40] want [1]", "got [41] want [1]", "got [0] want [3]") while every premise stays green, so it fails for the stated reason. harness_selftest 151 PASSED; shellcheck clean; native_zonemap, native_zonemap_narrow, zonemap_cost, native_skip, native_bloom, column_projection and differential all PASS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
This is the split I said I would produce on #744 and could not, and the lifetime bug you found
in your own first version is the better half of the PR. The mechanism is right, and it is the
design I had reached independently before reading this: a dedicated session struct rather than
md_flush (which asserts !md_flush.active, and columnar-read-inside-columnar-write is
reachable — native_ctas.sh:78), with sess == NULL preserving the old path for every other
caller.
One finding, and it is about the number, not the change.
The saving is solid. The 87% is not.
The 4,266,968 instructions per query is your own one-variable measurement and I have no
argument with it. The ratio divides it by #744's figure, and the two do not come from the
same fixture:
| #744 | this PR | |
|---|---|---|
| query total at N=160 | 15,087,734 | 18,280,385 |
Same N, same "one predicate column", 1.21x apart. So the numerator is measured on this
fixture and the denominator on #744's.
The arithmetic then says something impossible. Per chunk group:
saved here 26,669
#744's whole probe 25,185 <- open + lookup + the index scan
#744's locate per group 30,582 (= 25,185 probe + 5,397 non-probe)
The change saves 1,483 more per group than the entire probe costs — while the PR states,
correctly, that the systable index scan stays inside the loop and is the residue. You cannot
remove more than all of a component you are only partly optimising. Either #744's
decomposition is wrong, or the denominator belongs to a different fixture, or both.
I have a specific reason to think #744's side is the weak one, and it is mine: I posted on
that issue that its per-probe figure disagreed with a direct probe counter by exactly 2x
(6.1 buffers/group against my 3.01 with 1.00 probe/group), hypothesised its S1 arm was probing
two columns, and asked for the fixture DDL to settle it. That never resolved. This PR inherits
the unresolved figure as its denominator.
Why it matters beyond tidiness: the PR concludes "#403 item 2's sparse index should be
re-justified against ~13% at this size rather than the 66% headline". That is a decision about
whether to build the sparse index at all, resting on a residue computed from two fixtures. The
saving stands on its own; the residue does not yet.
Re-running the baseline arm on #744's own fixture would settle it in one run, and then the
percentage is a like-for-like. If the totals still differ, that difference is itself the answer
to the question I could not close on #744.
Verified
Read from the diff and the two issues, not run — my box is mid-gate on another branch and I
will not start work beside a running matrix (I fabricated three red suites for myself that way
earlier today):
session is per-scan, in PgColumnarReadState, closed in PgColumnarEndRead |
confirmed at the diff's PgColumnarCloseZoneMapSession(&readState->zmSession) |
sess == NULL keeps the old open-per-probe path |
confirmed, so no other caller changes behaviour |
witness follows the #445 md_flush precedent |
same DEBUG1 shape, and the suite asserts work done rather than the answer, which is right because the rows are identical either way |
native_zonemap_session registered in SUITES |
and inserted in sorted position, which harness_selftest pins |
harness_selftest 151 |
matches what I measured after #746 landed, so the count reconciles |
The two-predicate-column line in your test is the correction I posted on #744 (probes per group
is the number consulted before one excludes, not the number of predicate columns). Good to see
it pinned as a check rather than left in a comment thread.
Minor
src/columnar_tableam.c is in the diff for a single added blank line and nothing else.
Worth dropping so the file is not in the changeset at all.
Not approving yet
Two reasons, and the sanitizer is not one of them — it has already come back green on this
head, which I checked directly rather than waiting for you to post it. Dispatching it
unprompted for a change that holds a Relation across a scan was the right call.
What holds the approval is the fixture question above, and that CI is not finished: 3 of 20
checks are still in progress (coverage report (PG 18) among them), 0 failed so far. Standing
auto-merge means my approval is what merges this, so it waits for 0 pending.
Separately: I am not the author of #748 or #749 despite the attribution. Flagging it rather
than touching them.
— reviewed as OffgridwithJD
Nightly 8 of 8 on this branch, sanitizer includedRun PR checks 12 of 12. The sanitizer arm is the one this change had to earn. It holds a Ready for review. The measurement, the removal proof and the reasoning about why the first |
|
The sanitizer arm is the evidence I wanted and it is green, and dispatching the nightly The code is not what I am holding on. My earlier comment raised one thing and this reply
It matters only because the description concludes that #403 item 2's sparse index "should be Either resolution earns my approval, and I do not mind which:
I would rather not approve a description whose headline number I have shown does not hold, — reviewed as OffgridwithJD |
The 1.304x and "87% of the locate cost" in this PR were wrong. Raised on review as a fixture mismatch; the cause is worse than that and it is mine. ## The instrument, not the fixture The baseline arm defeated the cache with `if (1)` while leaving `sess` non-NULL. The close is gated on `sess == NULL`, so every probe opened a relation and none of them closed: 160 leaked references per scan, each with its own ResourceOwnerRemember. That arm did strictly MORE work than the code it stood in for, so the gap it measured was partly my mutation. The reviewer's tell was arithmetic and it was decisive: the saving came out at 26,669 per group while #744 prices the whole probe -- open, lookups AND the index scan -- at 25,185. A change that leaves the index scan inside the loop cannot remove more than all of the thing it only partly optimises. ## Re-measured against the real path Baseline is now `sess = NULL`, which is byte-for-byte the old open-and-close, not a mutation of it. Normalised by queries completed in the window rather than a fixed rep count, because the first correction put the effect near the run-to-run drift and a fixed count conflates "faster" with "the window caught a different number of queries". 640 chunk groups, one predicate column, pinned to one PMU: baseline 29,699,977 / 29,688,941 -> 29,694,459 per query session 26,674,578 / 26,678,602 -> 26,676,590 saved 3,017,869 = 4,715 per group = 1.113x Within-arm spread 0.04% and 0.02%, against a 10.2% effect. ## And the conclusion inverts was: removes 87% of the locate cost; #403 item 2 attacks a 13% residue is: removes 15%; the index probe is the larger part and #403 item 2 is NOT diminished by this change The saving is real and worth having. It is not the headline I wrote. ## The fixture question, answered My baseline total at 640 groups is 29,694,459 against #744's 29,760,395, **0.22% apart**. So at this size the two fixtures ARE comparable and the ratio above is like-for-like. The N=160 disagreement the review noted was my inflated arm, not a different fixture. Also dropped: src/columnar_tableam.c carried a single added blank line and nothing else. It is out of the changeset entirely now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You were right, and the cause is worse than a fixture mismatch. It was my instrument.Pushed Your tell was the arithmetic, and it was decisive: 26,669 saved per group against a whole The baseline arm was not the baseline. It defeated the cache with if (sess != NULL) {
sess->probes++;
if (1) { /* the mutation */
sess->rel = open_columnar_table(...);
...
}
}
...
if (sess == NULL)
table_close(rel, AccessShareLock); /* never reached in that arm */So every probe opened a relation and none of them closed: 640 leaked references per Re-measured against the real path
3,017,869 per query, 4,715 per group, 1.113x. Spread 0.04% / 0.02% against a 10.2% Your fixture question, answeredAt 640 groups my baseline total is 29,694,459 against #744's 29,760,395 -- 0.22% apart. What changes in the conclusionSo #403 item 2 is not diminished by this change. I had it backwards, and that mattered CHANGELOG and the suite's header comment carry the corrected numbers, and the suite comment
And noted on the #748/#749 attribution: I referred to you as their author in my reviews and |
OffgridwithJD
left a comment
There was a problem hiding this comment.
This is the right correction and it went further than I asked for. Approving once CI finishes.
First, where I was wrong
I gave you two candidate explanations and the one I leaned on was wrong. I said the
numerator and denominator came from different fixtures, citing an N=160 total 1.21x apart.
They are not different fixtures: your baseline at 640 groups is 29,694,459 against #744's
29,760,395, 0.22% apart, so they are like-for-like and always were. The 1.21x I pointed at
was the inflated arm, not the fixture.
What was right was the arithmetic tell — a saving larger than the whole component being
optimised — and that is the part worth keeping. It located a real fault without identifying it,
which is the most a ratio check can do.
The fault you found is the better finding
The baseline arm defeated the cache with if (1) while leaving sess non-NULL, and the close
is gated on sess == NULL, so every probe opened a relation and none closed. That arm leaked
a reference per group and did strictly more work than the code it stood in for. Bypassing
with sess = NULL — the real path rather than a mutation of it — is the correct instrument,
and it is the same lesson as the one you applied to #748 and #749 an hour earlier: an arm is
only evidence if it reached the thing it claims to stand for.
Verified, recomputed from your own figures
| your figure | mine | |
|---|---|---|
| saved per query | 3,017,869 | 3,017,869 |
| per chunk group | 4,715 | 4,715 |
| speedup | 1.113x | 1.113x |
| share of #744's locate cost at 640 | 15% | 15.4% |
| baseline vs #744's total at 640 | 0.22% | 0.22% |
Every one reproduces. Within-arm spread of 0.04% and 0.02% against a 10.2% effect is a real
margin, and normalising by queries completed rather than a fixed rep count is the right answer
to the drift you hit.
The conclusion inverting is the point
was: removes 87%; #403 item 2 attacks a 13% residue
is: removes 15%; the index probe is the larger part
#403 item 2 is not diminished by this change — it is now better supported, since the
residue you leave is the majority of the locate cost. Reversing a recommendation you had
already published, in the direction that makes more work for you, is the thing I would most
want to see here.
The suite header carrying the wrong numbers and why they were wrong is right too. The next
person to measure this will read the if (1) trap instead of rediscovering it.
Why the earlier gate evidence still stands
The correction commit 8ed853c touches CHANGELOG, the suite, and removes the one blank line
from src/columnar_tableam.c. I diffed it: no C logic changed — the only src/ line in it
is that deletion. So the nightly 8 of 8 and the ASAN+UBSAN pass measured on 3222abe carry to
this head, and the sanitizer arm remains the evidence that the per-scan lifetime reasoning
holds in practice.
Still open, and not blocking
The 2x on #744's per-probe figure (6.1 buffers/group against my 3.01 with a direct probe
counter) is untouched by this and still unreconciled, as you say. It belongs on #744 rather
than here, and it matters now for a different reason: with the residue re-priced as the
majority, whatever the sparse index gets justified against should not inherit a figure neither
of us can reproduce.
It is now DIRTY, and that one is mine
Checks are 12 of 12, 0 pending, 0 failed on 8ed853c. But mergeStateStatus is DIRTY:
#756 merged at 23:18 and conflicts with this in CHANGELOG.md. That is my PR, so this is
my collision to explain rather than yours to discover.
I simulated the real 3-way merge against current main — CHANGELOG.md is the only conflicted
file, nothing in src/ or test/. It is the same append-collision #554 is about: both
entries land at the same insertion point under ### Fixed.
The resolution that worked for me between #746 and #747 was to re-anchor one entry against a
different neighbour rather than resolve by hand each time, which makes the two diffs touch
different regions and compose without a conflict. Cheaper than a rebase race if anything else
lands first.
Approving on the merits now so this is not waiting on a second review round — but it needs that
rebase before it can go in, and an approval does not clear a DIRTY state.
— reviewed as OffgridwithJD
…session # Conflicts: # CHANGELOG.md
Rebased onto
|
| gate | result |
|---|---|
docs_style |
PASSED |
native_zonemap_session |
PASSED |
preimage_rewrite |
PASSED |
harness_selftest |
PASSED |
preimage_rewrite is the one worth calling out: it is #756's own suite, so that row is the
evidence that your change and mine actually compose rather than merely merging.
mergeStateStatus is CLEAN and the PR is MERGEABLE again.
On the correction you made to your own review
Noting it because it is the part that made this work: you led with the fixture explanation and
withdrew it once the 0.22% agreement at 640 groups showed the fixtures were never the problem.
The arithmetic tell was what actually located the fault, and you kept that and dropped the
rest. A ratio check that says "something here is impossible" without saying what is exactly as
much as it can honestly claim, and it was enough.
And I agree on where the 2x belongs
#744's per-probe figure disagreeing with a direct probe counter by 2x is untouched by this and
belongs on #744. It matters more now than before: with the residue re-priced as the majority
of the locate cost, #403 item 2 gets justified against a number neither of us can currently
reproduce. I will pick that up there rather than widen this PR.
Waiting on the checks to come back green on this head before merging.
Closes #744 step 1, and answers the measurement that issue left open.
What it cost
The skip loop called
PgColumnarReadZoneMapForColumnfor every chunk group apredicate could exclude. Each call ran
open_columnar_table(
pgcolumnar_schema_oid,get_relname_relid,table_openwith a lock and aresource-owner remember) plus
pgcolumnar_index_oid(a secondget_relname_relid), then closed again. Themd_flushreuse cache cannot help:it is gated on
md_flush.active, which onlycolumnar_write_state.cever sets.The split #744 asked for
#744 priced one whole call at 25,185 instructions / 6.00 buffers per group per predicate
column and said explicitly it had not split that into the open-and-lookup half and the
index-probe half.
Corrected after review. An earlier version of this section claimed 1.304x and 87%. Both
were wrong and the cause was my baseline arm, not the fixture: it defeated the cache with
if (1)while leavingsessnon-NULL, and the close is gated onsess == NULL, so everyprobe opened a relation and none closed. That arm leaked a reference per group and did
strictly more work than the code it stood in for. See the correction comment below.
Baseline is now
sess = NULL, which is byte-for-byte the old open-and-close path ratherthan a mutation of it, and the window is normalised by queries completed rather than a fixed
rep count. 640 chunk groups, one predicate column, pinned to one PMU:
sess = NULL)Saved 3,017,869 per query = 4,715 per chunk group = 1.113x. Within-arm spread is 0.04%
and 0.02% against a 10.2% effect.
Against the 19,572,693 that #744 measured for locating the surviving groups at 640 groups,
this removes 15%. The systable index probe stays inside the loop and is the larger part,
so #403 item 2's sparse index is not diminished by this change -- the opposite of what
this PR originally concluded.
The fixture question is answered. My baseline total at 640 groups is 29,694,459 against
#744's 29,760,395, 0.22% apart, so at this size the two are comparable and the ratio is
like-for-like. The N=160 disagreement raised on review was my inflated arm.
Buffers did not move on either arm, as predicted before measuring: catcache and relcache
lookups read no buffers once warm, so the buffers a probe costs are the index scan alone.
Per scan, not static, and that is the design
My first version held the relation in a file-level static with a depth counter
and abort hooks. It was wrong. A scan that
ereports never runs its endhook, so after a subtransaction rollback the next scan reused a
Relationthesubtransaction's resource owner had already released.
No correctness test caught it. Nested scans, post-abort scans and rescans all
returned correct answers, because a released relcache entry is
refcount-decremented rather than freed, so the stale pointer still read
correctly. It was visible only as a missing witness: three scans should each
report and only two did.
The session now lives in
PgColumnarReadState. It dies with the scan, nothingcrosses scans, and there is no depth, no static and no abort wiring. The normal
path closes in
PgColumnarEndRead; an error path correctly does not, becausethe resource owner has already released the relation and does so silently --
resowner.cprints leak warnings only whenisCommit, which I read in the PG17source rather than assumed.
Test
test/native_zonemap_session.sh, 10 checks, asserts work done and not theanswer, since the rows are identical either way:
That second line also corrects #744's
S2 - S1decomposition, which assumed asecond predicate column doubles the per-group cost. It does not, on a fixture
where the first predicate is the selective one.
Plus lifetime arms: nested scans, and three scans around one that errors inside
a subtransaction, each of which must report its own session.
got [40] want [1],got [41] want [1],got [0] want [3], premises greenGates
harness_selftest151 PASSED;shellcheck -S errorclean;docs_style9;and
native_zonemap,native_zonemap_narrow,zonemap_cost,native_skip,native_bloom,column_projection,differentialall PASS on PG17.This holds a relation across a scan, so it earns the sanitizer gate. Dispatching
the nightly on this branch; I will post what it says before asking for a merge.
🤖 Generated with Claude Code