feat: late materialization, phase 1a — build the qual's columns first (#452) - #511
Conversation
#452) Decode cost scaled with rows SCANNED rather than rows emitted. The scan built a complete tuple -- every projected column, every varlena copied into the row context -- and core's ExecScan applied the qual afterwards. Heap does the opposite: slot_deform_heap_tuple stops at the qual's attribute for a rejected row and pays the full deform only for survivors. On ClickBench q24 that difference is 8.49x (#445). The reader now produces a row in two passes when the caller supplies a qual column mask and a filter: the columns the qual reads are decoded, the filter is asked, and the remaining projected columns are built only for a row that survives. A rejected row has their cursors advanced past it instead -- pgcolumnar_skip_value, which is PgColumnarDecodeValue's cursor arithmetic without the MemoryContextAlloc and the memcpy. That is the entire cost removed. No batching and no random access, contrary to the first plan: every column carries its own cursor, so visiting them in two groups within one row is free. Only rowInGroup is shared, and it advances once, after both passes. Refused, at Begin, when: the feature is off; there is no qual; the qual reads a system column, whose attnos do not address value cursors; the qual is volatile, because ExecScan re-applies the same qual to every row this returns and a volatile expression would then run twice per surviving row; or the qual reads every projected column, leaving nothing to defer. A deleted row never reaches the filter. Nothing above the scan can see it, and evaluating an arbitrary user expression on an invisible row could raise an error the query has no business raising. EXPLAIN reports "Columnar Rows Filtered Before Materialization", which is distinct from the counters beside it: those count rows never READ, this counts rows read but not materialized. test/native_late_materialization.sh pins it, with the removal proof built in -- the same query with the feature off must report 0 and return identical rows. Its premises assert that nothing was pruned at either the group or the vector level, so a green run cannot be pruning wearing this feature's name; the predicate is a leading-wildcard LIKE, which builds no scan key at all. Refs #452 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
Every other query in the suite is immutable, so deleting
contain_volatile_functions() from Begin left the whole suite green -- the guard
was unprotected in exactly the way the divisor and the collation guard were
found to be elsewhere today.
The fixture is `AND random() < 2`: always true, so the rows are still the ones
the LIKE selects and the heap mirror remains the oracle, and volatile is
precisely what stops the planner folding it away.
Proven by removal, .so fingerprinted across both arms so the two runs cannot be
the same binary:
024c0f74a5b7 guard present 13 checks, 0 fails
3fc733b34589 guard removed FAIL a volatile qual is refused the
two-pass path: got [20470] want [0]
Worth recording that the accompanying correctness check does NOT protect this:
"the volatile query still matches the heap mirror" passes with the guard removed
too, because random() < 2 is true however many times it runs. Only the counter
check sees the defect. The mirror is a safety net here, not the proof.
The third check is the premise that makes the zero mean something: the same
shape without the volatile term must still take the path. Without it, a fixture
that never qualified for late materialization at all would satisfy the
expectation.
Refs #452
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
) Caught by the five-major gate, on all five majors: pushdown_report and analyze_stats both failed, and both for the same reason. ExecScan increments nfiltered1 for every tuple ITS qual rejects, and that is what EXPLAIN prints as "Rows Removed by Filter". Filtering inside the scan means ExecScan never sees a rejected row, so the line silently read 0 on every columnar scan carrying a qual. The plan still looked right; a counter two other suites depend on had stopped counting. FAIL pushdown off examines every row: got [unset] want [199999] FAIL the executor filters far more rows with pushdown off: got [no] want [yes] FAIL having an index available saves the point lookup real work: got [no (0 with the index, 0 without)] want [yes] The filter now counts the rejection where the executor would have counted it, and the late-materialization suite pins it next to the feature that broke it, so this cannot regress silently a second time. Worth recording how nearly this was missed twice. The first verification of the fix ran with PGC_SKIP_BUILD=1 after a manual `make` with no `make install`, so all three suites ran against the PREVIOUSLY INSTALLED .so -- which happened to be the guard-removed binary left by an earlier removal proof. It reported the volatility guard broken as well, which is what made it obvious. Fingerprinted: 3fc733b34589 stale, guard-removed 3 suites failing a411738500db actually installed 14 / 34 / 33 checks, 0 fails That is #508's defect class wearing a different costume: there the build failed silently, here the install was never asked for. Refs #452 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
ChronicallyJD
left a comment
There was a problem hiding this comment.
Read the diff rather than the description. The correctness case is sound and I
checked the parts where a two-pass decode goes quietly wrong:
- Cursor arithmetic is identical.
pgcolumnar_skip_valueand
PgColumnarDecodeValueadvance byatt->attlenfor by-value and fixed-length,
and byPgColumnarVarSizeAnyUnaligned(p)for varlena, in the same order. A
divergence there would corrupt every later row in that column and nothing above
would notice, so it is the right thing to have put next to its twin. - Every column's cursor advances exactly once per row on all three paths —
deleted (skip all), qual path (pass one reads qual columns, pass two reads or
skips the rest), and the single-pass path. I traced each. MemoryContextReset(rs->rowContext)runs at the top of every iteration, so
a rejected row's pass-one allocations do not accumulate. With 11M rejects that
is the difference between this working and an OOM, and it is easy to get wrong
by resetting only on the emit path.- Deleted rows never reach the filter, and the reason given is the right one.
The Rows Removed by Filter regression and its fix are the most valuable part of
the PR, and pinning it next to the feature that broke it is correct.
One thing I would not merge without an answer: the qual runs twice on survivors
ExecScan re-applies the qual to every row the scan returns — your words, and the
reason the volatility guard exists. So evaluations go from N to N + S, where
S is the number of survivors:
extra cost = S × qual_cost
saving = (N − S) × deferred_decode_per_row
As selectivity rises the saving goes to zero and the extra goes to N × qual_cost.
Your benchmark is the best case for this, by a wide margin. q24's qual is
URL LIKE '%google%', which is expensive — SB_MatchText was 27.2% of self
time in the decode profile I took for the benchmark report — and it matches
1,728 of 11,110,833 rows, 0.016%. The double evaluation is invisible there
because S is essentially zero. Turn the same qual around — URL LIKE '%a%', or
any <> '' on a mostly-non-empty column, which is q28 and q29 — and S is most
of the table.
The refusals are enable, no qual, volatile, system column, and deferrable == 0.
None of them is about selectivity or qual cost, and
pgcolumnar_enable_late_materialization defaults to true, so whatever this
shape does, it does by default.
I have not measured it. That is a prediction from your own numbers and the
structure of the loop, not a result, and I would rather say so than dress it up.
Both boxes are busy with matrices; I will build the case — expensive qual, high
selectivity, wide projection — and post the number either way.
If it is real, the cheapest guard is probably the planner's own selectivity
estimate at Begin: the path only pays when most rows are rejected, which is
exactly when rows is far below tuples.
Worth noting the alternative you have already foreclosed deliberately: the scan
could clear scan.plan.qual and own the filtering outright, which removes the
second evaluation entirely — at the cost of the Filter line and
Rows Removed by Filter, which you have just spent a fix restoring. I think you
chose right, and the double evaluation is the price of that choice rather than an
oversight; it just needs to be a priced choice rather than an unbounded one.
Smaller
The two index conventions in pgcolumnar_setup_late_materialization —
FirstLowInvalidHeapAttributeNumber-offset from pull_varattnos, zero-based in
projectedColumns — are commented exactly where someone would otherwise unify
them and be wrong. Good.
Holding approval on the selectivity question only. Everything else reads as ready.
) Investigated before writing 1b, and the finding is why it was not started in the same session as 1a. Established facts, from the source rather than the plan: - pgcolumnar_native_load_group decodes every wanted column at :1598 and only builds the skip vector at :1615, and pgcolumnar_native_decode_chunk takes no skip mask at all. So "Columnar Vectors Skipped" has always meant "not turned into Datums", never "not decoded" -- stronger than #452's text, which says only that decompression is not skipped. - Skipping a vector's decode leaves a hole in the raw buffer. The row producer steps past it safely. The batch fold does not: PgColumnarReadFoldColumn hands out the raw pointer with no skip information, and columnar_vector.c:3161 fetches skipVec into a local it never indexes. - The fold is correct today because it evaluates the scan keys itself, per value (columnar_vector.c:3236). A hole would be re-checked as uninitialized memory, producing a wrong aggregate silently, and only where zone maps rule something out. - pgcolumnar_batch_shape_eligible requires every qual to be convertible to a scan key, so the fold runs precisely when predicates exist -- which is exactly when vectors are skipped. The common case, not an edge. So 1b-i (let decode honour the skip) also requires the vectorized aggregate path to honour it, and is not the small self-contained slice it looked like. 1b-ii (exact selection from evaluating the qual) additionally needs the vector-at-a- time batching that 1a turned out not to need, and is the only half that reaches q24, where 1a leaves ~3200 ms on the table. The suite written for 1b-i is recorded here rather than committed: red against main for the right reasons, but a registered red suite and an unregistered stray .sh are both worse than a specification. Refs #452 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
|
One commit added since the gate: It records what I found investigating Phase 1b, which is worth having before anyone starts it, because 1b is not the small next step this PR's description implies.
So the half that looked self-contained (let decode honour the skip it already builds) also requires a change to the vectorized aggregate path. The other half — exact selection from evaluating the qual — needs the vector-at-a-time batching that 1a turned out not to need, and is the only one that reaches q24. A suite for it exists and is red against Nothing here changes what this PR does. It changes what the next one costs. |
ChronicallyJD
left a comment
There was a problem hiding this comment.
Measured it, and my concern does not survive contact. Withdrawing the hold.
I predicted the double qual evaluation would sink an expensive, non-selective
qual. It does not. 2,000,000 rows, 21 columns, SELECT *, qual column 99 chars
so the LIKE is genuinely expensive, interleaved, medians of five:
| predicate | matches | off | on | |
|---|---|---|---|---|
q LIKE '%zzz%' |
20 (0.001%) | 4715.8 ms | 4330.7 ms | 1.09x faster |
q LIKE '%a%' |
1,746,656 (87%) | 4394.6 ms | 4562.3 ms | 1.04x slower |
4%. The arithmetic in my review was right — the saving does go to zero as
selectivity rises, and the extra evaluation does remain — but I never checked the
ratio of the two terms, and it is not close. One substring search over 99 bytes is
cheap against decoding twenty text columns, so even at 87% the saving on the
remaining 13% nearly pays for it.
So there is a regression shape and it is worth knowing about, but 4% on a
deliberately adversarial fixture is not a reason to guard on selectivity, and
certainly not a reason to hold the PR. A guard would add a planner dependency and
a refusal path to buy back four percent in the worst case I could construct.
Approving. The correctness review stands as posted: cursor arithmetic verified
identical to PgColumnarDecodeValue in all three cases, every column's cursor
advancing exactly once on all three paths, rowContext reset at the top of the
loop so rejects do not accumulate, and deleted rows never reaching the filter.
Two limits on the number above, since they cut in your favour and I would rather
state them than let the 4% read as a ceiling. My deferred columns are twenty md5
strings of 32 bytes; ClickBench's are 104 columns of mixed width, so the saving
term is larger there than here. And my qual column is 99 bytes against URL
reaching 4,068 — a more expensive qual raises the extra term, but it raises the
selective case's saving too, which is where q24 lives.
One process note, because it is the second time today I have done this: I posted
that concern with the arithmetic and without the ratio, having explicitly labelled
it unmeasured. The label was honest and it was not sufficient — a prediction
stated in a review still spends someone's attention, and the cost of checking it
first was twenty minutes on a box that turned out to be free.
On failure pgc_summary tails 40 lines of the server log. That is the right thing
to show when one statement failed and the wrong thing after a crash: a crashing
backend takes the postmaster through "terminating any other active server
processes" and recovery for every subsequent check, so the cause is at the TOP of
the log and the last 40 lines are its aftermath. pgc_teardown then removes the
workdir, so there is nowhere left to look.
Measured, running a deliberate heap overrun through this harness under the
pg18_san build:
server log: 8,777 lines
AddressSanitizer reports: 67
first report at line: 12
what the 40-line tail showed: lines 8738-8777, all crash recovery
The suite reported 123 failures and not one word about why. The diagnosis existed
for about a quarter of a second, 8,765 lines above the only window anyone saw.
So the failure path now greps the whole log for the events that mean "this was
not a failed assertion" -- AddressSanitizer, UndefinedBehaviorSanitizer, runtime
error:, terminated by signal, PANIC: -- and prints the first five with line
numbers, above the existing tail. The tail is unchanged; this is added context,
not a replacement, because for the ordinary single-statement failure the tail is
still the useful view.
## Tests
harness_selftest.sh stands the scenario up without needing a sanitizer build: a
fatal-looking line via RAISE LOG, 60 filler lines to push it past the tail window,
then a real failure.
Two premise checks first, because this check has two ways to pass for the wrong
reason -- if the sub-suite did not fail, the summary never runs; if the filler did
not bury the marker, the existing tail would have shown it and the new code would
be untested:
PASS premise: the sub-suite failed, so its summary ran
PASS premise: the 40-line tail is filler, not the marker
Red before the change, and again with lib.sh reverted under the new test:
FAIL a failing suite names the first fatal event in its log: got [no] want [yes]
The assertion is scoped to the new section and asks whether the marker is there
rather than how many times: PostgreSQL emits a STATEMENT: line beside the message,
so it legitimately appears twice, and an exact count would be asserting a detail
of PostgreSQL's logging.
Found while running the sanitizer build over today's decode-path changes (#511,
#514), where a clean result could not be distinguished from a broken instrument.
No defect was found in either.
Decode cost scaled with rows scanned rather than rows emitted. The scan built a complete tuple — every projected column, every varlena copied into the row context — and core's
ExecScanapplied the qual afterwards. Heap does the opposite:slot_deform_heap_tuplestops at the qual's attribute for a rejected row and pays the full deform only for survivors. On ClickBench q24 that difference is 8.49x (#445).What it does
The reader produces a row in two passes when the caller supplies a qual-column mask and a filter: the columns the qual reads are decoded, the filter is asked, and the remaining projected columns are built only for a row that survives. A rejected row has their cursors advanced past it instead —
pgcolumnar_skip_value, which isPgColumnarDecodeValue's cursor arithmetic without theMemoryContextAllocand the memcpy. That is the entire cost removed.No batching and no random access, contrary to the first plan. Every column carries its own cursor, so visiting them in two groups within one row is free; only
rowInGroupis shared, and it advances once, after both passes. The plan document records the wrong version and why it was wrong.Measured, on the real thing
11,110,833-row ClickBench table,
pg18nnon-assert, bench idle, interleaved, medians of 3, postmaster restarted so the new.sowas actually loaded:SELECT *, 105 columnscount(*), 1 columnResults byte-identical both ways, on the
LIMIT 10and the full 1,728-row set. q21 does not move because its qual column is its only projected column, so thedeferrable == 0guard refuses the path — the guard working, not the feature failing.This is below the 71% this work was forecast to deliver, and the reason matters more than the number.
pgcolumnar_native_decode_chunkdecodes a whole chunk into a raw buffer at group-load time, so 1a removes only the copy out of that buffer. 1a captures materialization; decode is untouched. Getting decode means not decoding vectors that hold no surviving row — Phase 1b — which now has a measured budget of roughly the remaining 3200 ms rather than an assumed one. The forecast was wrong because I read the plan I had written rather than the code it described; the same document already said chunks are decoded whole.Refused, at Begin
The feature is off; there is no qual; the qual reads a system column, whose attnos do not address value cursors; the qual is volatile, because
ExecScanre-applies the same qual to every row this returns and a volatile expression would then run twice per surviving row; or the qual reads every projected column, leaving nothing to defer.A deleted row never reaches the filter. Nothing above the scan can see it, and evaluating an arbitrary user expression on an invisible row could raise an error the query has no business raising.
The regression the five-major gate caught
Filtering inside the scan means
ExecScannever sees a rejected row, so it never incrementsnfiltered1— andRows Removed by Filtersilently read 0 on every columnar scan carrying a qual. The plan still looked right.pushdown_reportandanalyze_statsboth read that line and both failed, on all five majors:Fixed by counting the rejection where the executor would have counted it, and pinned in
native_late_materialization.shnext to the feature that broke it. An instrumentation counter that stops counting while the plan above it stays correct is thecheck "" ""shape one layer down.Proof by removal — including which removal was a no-op
Per the rule this repository keeps re-learning: a removal proof must remove something the compiler, the planner or the runtime can observe.
enable_late_materialization = offcontain_volatile_functions()got [20470] want [0],.so024c0f74→3fc733b3The volatility check was a no-op until its fixture was changed. Every other qual in the suite is immutable, so deleting the guard left the suite green; the fixture had to gain
AND random() < 2to reach it. And its heap-mirror companion does not protect it —random() < 2is true however many times it runs, so the mirror passes with the guard removed. Only the counter check sees the defect.Gate
Five majors, all green, at this head on the bench host:
native_late_materialization=PASSon all five and absent from every skip list — a green major and "the new suite ran" are different claims.pushdown_reportandanalyze_statsalso PASS on all five.Known limitation
The EXPLAIN counter is read from the leader's read state, exactly like
Chunk Groups ReadandVectors Skippedbeside it, so under a parallel scan it reports the leader's share. Consistent with its neighbours rather than new behaviour; the suite pinsmax_parallel_workers_per_gather = 0, where it is exact.Refs #452