diff --git a/design/ISSUE_405_LATE_MATERIALIZATION.md b/design/ISSUE_405_LATE_MATERIALIZATION.md index 68faf7d3..f56de883 100644 --- a/design/ISSUE_405_LATE_MATERIALIZATION.md +++ b/design/ISSUE_405_LATE_MATERIALIZATION.md @@ -310,3 +310,30 @@ position-level late-mat, but it is byval-only and the residual is a decoded-buff load, so the win is bounded by a wall-clock measurement not yet taken." The row path — where the expensive varlena decode lives — already defers. The measurement in Step 1 decides whether even the fold slice is worth it; the honest prior is no. + +--- + +## Step 2 rebuild (scheduled 2026-08-13): the implementation shape, one amendment + +The owner scheduled the rebuild (issue comment, 2026-08-13). Steps 2 and 3 +are implemented as this document planned: the gather loop defers non-key +`fetch_att` until the scan-key check passes, payload cursors advance without +reading on failing rows (the consume-slot-do-not-read contract vecSkipped and +deleted rows already follow), and the work-done counter increments at the +stable post-key site with its own label, `Columnar Fold Payload Loads`, so no +EXPLAIN line means two different quantities on two plans (Step 3 by +construction). + +**Amendment to Step 4**: the cost gate is RUNTIME-ADAPTIVE, not the planner +plumbing this plan sketched. Per group, deferral is enabled iff the survival +fraction observed over all previous groups is at most one half; the first +group is optimistic. Rationale for the deviation, recorded per house rule: +the planner route requires extending the agg node's positional +custom_private and its list_length discriminator plus an input_rel width +computation, all to consume a planner selectivity estimate that the executor +can simply measure; the adaptive gate needs no statistics, self-corrects on +data the planner mispredicts, costs one comparison per group boundary (the +#289 no-per-group-precompute guard holds), and is directly observable as +`Columnar Fold Deferred Groups: X of Y`. The reviewer may push back to the +planner design; the suite's arms bind the BEHAVIOUR (deferral on selective +data, eager fallback on high survival), not the mechanism. diff --git a/src/columnar_vector.c b/src/columnar_vector.c index 7a1f5d9f..c6a9f6ed 100644 --- a/src/columnar_vector.c +++ b/src/columnar_vector.c @@ -582,6 +582,18 @@ typedef struct PgColumnarAggScanState bool batchEligible; bool batchFolded; + /* + * Fold-path payload deferral (#405 step 2). foldPayloadLoads counts + * fetch_att materializations of non-key columns, incremented at the + * stable post-key site when deferring and at the eager gather otherwise, + * so the counter measures the work either way and the deferral's saving + * is their difference. foldGroupsDeferred/foldGroupsTotal expose the + * runtime-adaptive gate (defer unless observed survival exceeds half). + */ + uint64 foldPayloadLoads; + int foldGroupsDeferred; + int foldGroupsTotal; + /* * Parallel batch fold (#289 phase 5/6): a partial node emits its per-worker * transition state instead of the finalized value, and its reader claims @@ -3124,8 +3136,13 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, int16 *cattlen = (int16 *) palloc0(sizeof(int16) * natts); uint64 *cpresent = (uint64 *) palloc0(sizeof(uint64) * natts); bool *cneeded = (bool *) palloc0(sizeof(bool) * natts); + bool *ciskey = (bool *) palloc0(sizeof(bool) * natts); Datum *cval = (Datum *) palloc0(sizeof(Datum) * natts); bool *cisnull = (bool *) palloc0(sizeof(bool) * natts); + int npayload = 0; + int64 candRows = 0; /* rows that reached the key check */ + int64 survRows = 0; /* rows that passed it */ + bool deferOn = false; if (!pgcolumnar_batch_shape_eligible(state, tupdesc, &keys, &nkeys)) return false; @@ -3135,6 +3152,27 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, if (col >= 0 && col < natts) cneeded[col] = true; + /* + * Payload deferral (#405 step 2): key columns are the ones the scan keys + * read; every other needed column is payload, whose fetch_att can wait + * until the key check passes. ciskey is the split; npayload > 0 is the + * only case with anything to defer. + */ + { + int kk; + + for (kk = 0; kk < nkeys; kk++) + { + int ka = keys[kk].sk_attno - 1; + + if (ka >= 0 && ka < natts) + ciskey[ka] = true; + } + for (col = 0; col < natts; col++) + if (cneeded[col] && !ciskey[col]) + npayload++; + } + /* * Push the scan keys so the reader prunes whole row groups its zone maps rule * out (#349). The fold walks every surviving group in full and still rechecks @@ -3183,6 +3221,19 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, PgColumnarReadFoldGroupInfo(rs, &nrows, &dmask, &dlen, &skipVec, &decodeSkipped, &vecStart, &vcount); + /* + * The adaptive gate (#405, amended step 4): defer this group's payload + * unless the survival observed so far exceeds half, in which case the + * two-phase gather would refetch nearly everything and eager is + * cheaper. The first group is optimistic. One comparison per group: + * the #289 no-per-group-precompute guard holds. + */ + deferOn = (npayload > 0 && + (candRows == 0 || survRows * 2 <= candRows)); + state->foldGroupsTotal++; + if (deferOn) + state->foldGroupsDeferred++; + /* * This loop now honours skipVec, and it has to (#512, #452 phase 1b-i). * Decode no longer produces the vectors the zone maps ruled out, so the @@ -3273,10 +3324,15 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, vecSkipped = (curVec < vcount && skipVec[curVec]); } - /* gather needed values at each column's present index; advance it */ + /* + * Phase 1: gather the scan-key columns (and, when this group is + * eager, everything). Deferred payload columns are not touched + * here at all -- not even their cursors -- so the failing-row + * path below can consume their slots without a fetch (#405). + */ for (col = 0; col < natts; col++) { - if (!cneeded[col]) + if (!cneeded[col] || (deferOn && !ciskey[col])) continue; if ((cvalidity[col][r >> 3] >> (r & 7)) & 1) { @@ -3289,6 +3345,8 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, cval[col] = fetch_att(cpacked[col] + cpresent[col] * cattlen[col], true, cattlen[col]); cisnull[col] = false; + if (!ciskey[col]) + state->foldPayloadLoads++; } cpresent[col]++; } @@ -3297,12 +3355,28 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, } if (vecSkipped) - continue; /* value slots already consumed above */ + { + /* consume deferred payload slots; never read them (#405) */ + if (deferOn) + for (col = 0; col < natts; col++) + if (cneeded[col] && !ciskey[col] && + ((cvalidity[col][r >> 3] >> (r & 7)) & 1)) + cpresent[col]++; + continue; + } del = (dmask != NULL && (r >> 3) < dlen && (dmask[r >> 3] & (1 << (r & 7))) != 0); if (del) - continue; /* value slots already consumed above */ + { + if (deferOn) + for (col = 0; col < natts; col++) + if (cneeded[col] && !ciskey[col] && + ((cvalidity[col][r >> 3] >> (r & 7)) & 1)) + cpresent[col]++; + continue; + } + candRows++; for (k = 0; k < nkeys; k++) { @@ -3319,7 +3393,38 @@ pgcolumnar_native_batch_fold(PgColumnarAggScanState *state, Relation rel, } } if (!pass) + { + if (deferOn) + for (col = 0; col < natts; col++) + if (cneeded[col] && !ciskey[col] && + ((cvalidity[col][r >> 3] >> (r & 7)) & 1)) + cpresent[col]++; continue; + } + survRows++; + + /* + * Phase 2 (#405): the row survived, so NOW materialize its + * deferred payload. This is the stable post-key counter site the + * plan required: on a deferred group the counter equals surviving + * rows times fetched payload values, never candidates. + */ + if (deferOn) + for (col = 0; col < natts; col++) + { + if (!cneeded[col] || ciskey[col]) + continue; + if ((cvalidity[col][r >> 3] >> (r & 7)) & 1) + { + cval[col] = fetch_att(cpacked[col] + cpresent[col] * cattlen[col], + true, cattlen[col]); + cisnull[col] = false; + state->foldPayloadLoads++; + cpresent[col]++; + } + else + cisnull[col] = true; + } for (a = 0; a < state->naggs; a++) { @@ -3585,6 +3690,9 @@ PgColumnarReScanAggScan(CustomScanState *node) state->done = false; state->haveStats = false; state->batchFolded = false; + state->foldPayloadLoads = 0; + state->foldGroupsDeferred = 0; + state->foldGroupsTotal = 0; MemoryContextReset(state->resultContext); for (a = 0; a < state->naggs; a++) { @@ -3646,6 +3754,15 @@ PgColumnarExplainAggScan(CustomScanState *node, List *ancestors, ExplainState *e */ PgColumnarGroupStats gs; + if (state->batchFolded) + { + ExplainPropertyInteger("Columnar Fold Payload Loads", NULL, + (int64) state->foldPayloadLoads, es); + ExplainPropertyText("Columnar Fold Deferred Groups", + psprintf("%d of %d", state->foldGroupsDeferred, + state->foldGroupsTotal), es); + } + gs.usableSkipPredicates = state->usablePreds; gs.groupsTotal = state->groupsTotal; gs.groupsRead = state->groupsRead; diff --git a/test/native_fold_deferral.sh b/test/native_fold_deferral.sh new file mode 100644 index 00000000..1df9062f --- /dev/null +++ b/test/native_fold_deferral.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# pgColumnar #405 step 2: fold-path payload deferral, with the work MEASURED. +# +# On the batch-fold path, non-key columns used to be fetch_att'd for every +# non-skipped row BEFORE the scan-key check; the corrected #405 record measured +# that cost flat in selectivity. The fold now defers payload materialization +# until a row passes its keys, gated adaptively per group: defer unless the +# survival observed so far exceeds half (the first group is optimistic), so +# high-survival shapes keep the eager single pass that is cheaper for them. +# +# The counter is the plan's stable post-key site: on a deferred group, +# Columnar Fold Payload Loads equals surviving rows x payload values fetched - +# never candidates - which is the work-done identity the original Step 2 +# failed to have and the retraction was caught by. The gate is observable as +# Columnar Fold Deferred Groups: X of Y. +# +# Oracles: the row path (vector agg off) and a heap mirror; the fold node is +# pinned via the post-#602 ANALYZE line, which reports the fold that RAN. +# +# Usage: test/native_fold_deferral.sh [PG_CONFIG] +# Written fresh for pgColumnar. + +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +pgc_setup "${1:-/usr/local/pg17/bin/pg_config}" + +GUC="SET pgcolumnar.enable_ungrouped_vector_agg = on;" +NP=4 # payload columns +ROWS=120000 + +sq() { # scalar under this suite's GUC; tail -1 drops the SET tag + env PATH="$PGC_BINDIR:$PATH" psql -h 127.0.0.1 -p "$PGC_PORT" -U postgres \ + -d "$PGC_DB" -At -c "$GUC $1" 2>/dev/null | tail -1 +} +explain_line() { # explain_line