feat: fold-path payload deferral with an adaptive gate (#405 step 2) - #617
Conversation
Owner-scheduled 2026-08-13 after the PR #601 review refuted the retraction: on the batch-fold path, payload columns were fetch_att'd for every non-skipped row BEFORE the scan-key check, a cost measured flat in selectivity (~11% of a wide float8 query at 1% selectivity, ~4.6x derived ceiling on compressible payload). The gather now runs in two phases on a deferred group: phase 1 touches only the scan-key columns; failing, deleted, and vector-skipped rows consume their payload slots without reading them (the same consume-do-not-read contract those paths already followed); phase 2 materializes payload only for rows that passed their keys. The work-done counter increments at that stable post-key site with its own label, Columnar Fold Payload Loads, so no EXPLAIN line ever means two quantities on two plans (the plan's Step 3 by construction). On a deferred group the counter equals surviving rows times fetched payload values - the identity whose absence let the original Step 2 read 0 and mean nothing. Step 4 is implemented as a RUNTIME-ADAPTIVE gate, an amendment to the plan's planner-side sketch, recorded with its rationale in the design doc: per group, defer unless the survival observed over previous groups exceeds half; the first group is optimistic. No statistics dependence, self-correcting on planner-hostile data, one comparison per group (the #289 no-per-group-precompute guard holds), observable as Columnar Fold Deferred Groups: X of Y. test/native_fold_deferral.sh: fold pinned via the post-#602 ANALYZE line; three-way correctness (deferred fold == row path == heap mirror) at 1% and 99% selectivity; the loads identity asserted exactly (measured 4800 = 1200 survivors x 4 payload columns, against 480000 eager); all groups deferred at 1%, exactly the optimistic first at 99%; a cross-instrument premise ties the EXPLAIN group total to the stats() catalog count. Plain-aggregate tlists on purpose: an expression OVER aggregates disqualifies the vectorized path (the #602 tlist trap), and the fixture pins that it did not silently row-path. Removal proofs, both run: forcing eager reds the identity at exactly the pre-change cost (480000 vs 4800, 100x) and reds 0-of-6 deferred while correctness stays green; removing the adaptive disable reds the 99% arm at 6-of-6. The second proof initially ran against unmutated code (a sed delimiter collision reported by sed and nearly missed) and was redone - recorded because a removal proof that did not run is a green lie. Also from this work: issue #616 was filed against stats() and RETRACTED - the suite variable was named GROUPS, a bash builtin that silently ignores assignments; the gotcha is recorded in the suite comment. The census shapes bound the win: sum/avg over int2/int4/float4/float8 defer; int8/numeric/min-max do not fold at all; q24 is untouched (byval gate). Verified: 13/13 on PG17 (lane), PG18, PG19; nine fold-family suites unregressed on PG17 (native_agg, ungrouped_vector_agg, addcolumn, deletes, batch_fold_explain, fold_skipguard, vecskip, parallel_vector_agg, groupagg); ASAN+UBSAN gate passes the four fold suites; -Wshadow -Werror clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChronicallyJD
left a comment
There was a problem hiding this comment.
Approve. The reorder keeps the present-index invariant in every row disposition, and the counter identity is real.
This builds the gap I measured, and the thing that had to be right is the packed-stream present index: cpresent[col] must advance once per valid row whether or not the value is read, or every later row's fetch_att misaligns and the aggregate is silently wrong. I traced all four dispositions on a deferred group:
- Phase 1 gathers only key columns — deferred payload columns are not even cursor-advanced here.
- vec-skipped, deleted, and key-fail each add a loop that advances
cpresentfor valid payload columns without fetching — the slot is consumed, not read. - survivor → Phase 2 fetches and advances.
So each row's payload slots are consumed exactly once, and the net cpresent movement matches the eager path (which advanced all needed columns in one loop). A column that is both a scan key and an aggregate input is fetched in Phase 1 as a key and used directly, never double-counted (only !ciskey payload increments the counter). The three-way correctness arm (deferred == row path == heap mirror) is the right pin for "the reorder did not corrupt an aggregate", and it is there at both selectivities.
- Counter identity:
foldPayloadLoadsat the post-key site equals survivors × payload values on a deferred group — the exact identity whose absence let the original Step 2's counter read 0 and mean nothing. Forcing eager reds it at 480,000 (candidates × payload) and0 of 6deferred; the difference is the saving. Good. - Adaptive gate:
candRows/survRowsaccumulate across groups anddeferOnis computed at each group's top from the prior groups' survival, first group optimistic. At 99% survival it defers exactly group 1 (1 of 6) and goes eager after — no planner plumbing, one comparison per group, the #289 guard intact. - Scope: byval-only,
enable_ungrouped_vector_aggdefault off, q24 untouched — matches the census from the #601 correction.
I appreciated the honesty on the way: the removal proof that first ran against unmutated code (sed delimiter collision) redone rather than trusted, and #616 retracted for the GROUPS bash-builtin gotcha. Both are the standard.
Scope: verified by close read of every disposition path (a wrong cpresent advance is a silent wrong-aggregate, so that is where I spent the read); I did not re-run native_fold_deferral.sh (bench + sanitizer box are mid-gate on my recluster PR). The identity + three-way correctness + your 13/13 and fold-family sanitizer cover the execution. Approving.
Records the final disposition of the three ranked items in #405, re-measured against current main so the issue can be closed: - Item 1 (position-list intersection): MEASURED again on main 977bb44. Two queries returning the same 2,000 surviving rows show 25x the payload-decode cost (28.9 ms vs 726.7 ms) when survivors are spread one-per-vector, with 1,998,000 rows removed by filter -- decode tracks surviving VECTORS not positions. We prune vectors, not positions; Abadi's full bit-string intersection stays retracted (a trade, not a free win, PR #601); the worthwhile subset shipped as the fold-path payload deferral (#617). - Item 2 (branch predication): N/A -- the tree builds no packed selection vector to predicate (the selection loops were deleted in #200). Recorded. - Item 3 (vector length): reframed. COLUMNAR_NATIVE_VECTOR_LENGTH is vestigial (write-only catalog stamp); the execution stride is a literal and the runtime knob is chunk_group_row_limit. Not a single tunable constant. Recorded. Docs only (design note); no code, no behaviour change. Recommends CLOSE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TgAk1gqeME7DHpJw8xxybu
Implements the owner-scheduled rebuild of #405 Step 2, grounded in @ChronicallyJD's corrected measurement on the issue (the review that refuted my retraction). Design amendments recorded in
design/ISSUE_405_LATE_MATERIALIZATION.md(in this PR).What lands
Columnar Fold Payload Loads— and on a deferred group equals survivors × payload values, the identity whose absence let the original Step 2's counter read 0 and mean nothing.Columnar Fold Deferred Groups: X of Y. Push back to the planner design if you disagree — the suite binds behavior, not mechanism.The numbers
Measured in-suite on the 120k-row fixture: deferred loads 4,800 (1,200 survivors × 4) vs 480,000 forced-eager — the pre-change cost, 100×. At 99% survival the gate defers exactly the optimistic first group.
Proofs
Also from this work
Issue #616 (filed against
stats()during suite development) is retracted and closed: the suite variable was namedGROUPS— a bash builtin that silently ignores assignments — so it read root's gid forever while every probe read correctly. Two hours, one false accusation, one durable gotcha note.Verification
13/13 on PG17 (lane), PG18, PG19. Nine fold-family suites unregressed. ASAN+UBSAN gate green over the four fold suites (decode-path change → the #584 rule).
-Wshadow -Werrorclean.Scope bounds, per the census: only sum/avg over int2/int4/float4/float8 fold and defer; int8/numeric/min-max never fold; q24 untouched (byval gate); the whole path sits behind
enable_ungrouped_vector_agg(default off).🤖 Generated with Claude Code