Skip to content

feat: fold-path payload deferral with an adaptive gate (#405 step 2) - #617

Merged
jdatcmd merged 1 commit into
mainfrom
feat/405-fold-deferral
Aug 13, 2026
Merged

feat: fold-path payload deferral with an adaptive gate (#405 step 2)#617
jdatcmd merged 1 commit into
mainfrom
feat/405-fold-deferral

Conversation

@jdatcmd

@jdatcmd jdatcmd commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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

  • Two-phase gather on deferred groups: keys first; failing/deleted/vector-skipped rows consume payload slots without reading; survivors materialize payload post-key. The counter lives at that stable post-key site — 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.
  • Step 4 as a runtime-adaptive gate (amendment, rationale in the doc): defer unless observed survival across previous groups exceeds ½, first group optimistic. No planner plumbing, no stats dependence, one comparison per group (Vectorized decompression + aggregation for full-scan aggregates (~4× behind TimescaleDB) #289 guard holds), observable as Columnar Fold Deferred Groups: X of Y. Push back to the planner design if you disagree — the suite binds behavior, not mechanism.
  • Step 3 by construction: the new label is fold-specific; no line means two quantities on two plans.

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 named GROUPS — 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 -Werror clean.

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

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

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 cpresent for 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: foldPayloadLoads at 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) and 0 of 6 deferred; the difference is the saving. Good.
  • Adaptive gate: candRows/survRows accumulate across groups and deferOn is 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_agg default 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.

@jdatcmd
jdatcmd merged commit bd983d9 into main Aug 13, 2026
9 of 11 checks passed
jdatcmd pushed a commit that referenced this pull request Aug 14, 2026
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
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