Skip to content

feat: late materialization, phase 1a — build the qual's columns first (#452) - #511

Merged
jdatcmd merged 4 commits into
mainfrom
feat/452-late-materialization
Aug 8, 2026
Merged

feat: late materialization, phase 1a — build the qual's columns first (#452)#511
jdatcmd merged 4 commits into
mainfrom
feat/452-late-materialization

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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).

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 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. The plan document records the wrong version and why it was wrong.

Measured, on the real thing

11,110,833-row ClickBench table, pg18n non-assert, bench idle, interleaved, medians of 3, postmaster restarted so the new .so was actually loaded:

query off on saved
q24 SELECT *, 105 columns 6253 ms 5184 ms 1069 ms, 1.21x
q21 count(*), 1 column 1378 ms 1392 ms none, by design

Results byte-identical both ways, on the LIMIT 10 and the full 1,728-row set. q21 does not move because its qual column is its only projected column, so the deferrable == 0 guard 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_chunk decodes 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 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.

The regression the five-major gate caught

Filtering inside the scan means ExecScan never sees a rejected row, so it never increments nfiltered1 — and Rows Removed by Filter silently read 0 on every columnar scan carrying a qual. The plan still looked right. pushdown_report and analyze_stats both read that line and both failed, on all five majors:

FAIL  pushdown off examines every row: got [unset] want [199999]
FAIL  having an index available saves the point lookup real work:
      got [no (0 with the index, 0 without)] want [yes]

Fixed by counting the rejection where the executor would have counted it, and pinned in native_late_materialization.sh next to the feature that broke it. An instrumentation counter that stops counting while the plan above it stays correct is the check "" "" 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.

guard removal result
late materialization itself enable_late_materialization = off counter 0, rows unchanged — in-suite, both arms
volatility guard delete contain_volatile_functions() got [20470] want [0], .so 024c0f743fc733b3

The 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() < 2 to reach it. And its heap-mirror companion does not protect it — random() < 2 is 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:

PASS PG15 (129 ran, 5 skipped) · PASS PG16 (129) · PASS PG17 (129)
PASS PG18 (132 ran, 2 skipped) · PASS PG19 (134 ran, 0 skipped)
ALL VERSIONS PASSED · exit=0

native_late_materialization=PASS on all five and absent from every skip list — a green major and "the new suite ran" are different claims. pushdown_report and analyze_stats also PASS on all five.

Known limitation

The EXPLAIN counter is read from the leader's read state, exactly like Chunk Groups Read and Vectors Skipped beside it, so under a parallel scan it reports the leader's share. Consistent with its neighbours rather than new behaviour; the suite pins max_parallel_workers_per_gather = 0, where it is exact.

Refs #452

jdatcmd and others added 3 commits August 8, 2026 11:57
#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 ChronicallyJD 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.

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_value and
    PgColumnarDecodeValue advance by att->attlen for by-value and fixed-length,
    and by PgColumnarVarSizeAnyUnaligned(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
@jdatcmd

jdatcmd commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

One commit added since the gate: c1bd7e3, design notes only, no source or test change. The five-major green at febcb2d therefore still describes this branch's code. Stating the expectation rather than skipping the run silently — and if a reviewer wants it re-gated on principle, say so and I will.

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.

  • The group loader decodes every wanted column at columnar_reader.c: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 No late materialization: decode cost scales with rows scanned, not rows emitted #452's text, which claims 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 — 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. Common case, not an edge.

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 main for the right reasons (30 of 32 vectors skipped, no decode counter). It is not in this branch: a registered red suite and an unregistered stray .sh are both worse than a written specification, and its shape is recorded in the design note.

Nothing here changes what this PR does. It changes what the next one costs.

@ChronicallyJD ChronicallyJD 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.

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.

@jdatcmd
jdatcmd merged commit 2871a68 into main Aug 8, 2026
11 checks passed
@jdatcmd
jdatcmd deleted the feat/452-late-materialization branch August 8, 2026 21:56
jdatcmd pushed a commit that referenced this pull request Aug 8, 2026
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.
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