Skip to content

feat: gate #452 decode-gating on projected payload width (closes #595) - #598

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:feat/595-adaptive-decode-gate
Aug 13, 2026
Merged

feat: gate #452 decode-gating on projected payload width (closes #595)#598
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:feat/595-adaptive-decode-gate

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

Gate #452 decode-gating on projected payload width (closes #595)

#452 phase-2 decode gating evaluates an unprunable qual (a leading-wildcard LIKE, <> '') per 1024-row vector to skip payload decode for no-match vectors. The 2026-08-12 benchmark re-run (#596) showed it is a trade: a big win on a wide SELECT * under a selective filter (ClickBench q24: 6098→3395 ms), but a 1.2–2× regression on eight narrow aggregates (q11/q12/q14/q15/q25/q27/q31/q32). The per-vector evaluation cannot pay for itself when there are few projected columns to spare from decode. It dropped columnar's ClickBench win count 33→25.

The fix: gate on payload width

pgcolumnar.qual_skipvec_min_payload_cols (int, default 20, PGC_USERSET; 0 = always gate, the pre-#595 behaviour). Decode gating now runs only when the projection has at least that many non-qual columns to spare from decode — a plan-time count, no data probe:

if (pass == 1 && allDescriptor && rs->numPredicates == 0 &&
    pgcolumnar_native_payload_col_count(rs) >= pgcolumnar_qual_skipvec_min_payload_cols)
    pgcolumnar_native_qual_skipvec(rs, maxVecCount);

Measured on the ClickBench arm (11.1M stride, pg18n): this recovers the win count 25→33 — all eight regressed queries return to beating heap (e.g. q11 1140→698 ms) — while the wide case still gates (q24 keeps gating's benefit). The A/B is on #595.

Why width, not a selectivity probe

I prototyped the obvious idea first — a runtime probe of the first N vectors' selectivity — and measured it as a failure: its prefix sample is unrepresentative on ClickBench's clustered data, so it gated the narrow queries anyway and added cost. Payload width is the true, cheap, clustering-immune discriminator (only q24 is wide; every regressed query is narrow). That probe is not shipped. (Aside: the same prefix-over-counts effect explains the #426 doc's 93%-vs-91% drift — see #597.)

Verification

  • Correctness — differential (columnar vs heap) passes on forced ASAN+UBSAN and pg18a assert.
  • New TDD suite test/native_decode_gate_width.sh passes on both toolchains: a WIDE projection gates and a NARROW one does not, each with a paired removal proof (GUC=0 makes narrow gate; GUC>width makes wide stop), the >= boundary (24 gates, 25 does not), a matches-everything control (an all-matching qual skips no vector even when the gate runs), and a heap differential proving output is invariant either way. Observable is the Columnar Vector Decodes counter.
  • native_decode_gating keeps passing (its narrow fixture pins min_payload_cols=0).
  • ASAN: 0 sanitizer reports; assert: no TRAP/crash; -Wshadow/-Werror clean.

One minor known point: the two "the probe GUCs did not ship" premises assert an empty SHOW; they are non-vacuous because a paired positive premise (min_payload_cols == 20) rules out a dead connection first, but a stricter form would match the "unrecognized configuration parameter" text.

🤖 Generated with Claude Code

https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW

…commandprompt#595)

Decode gating (phase 2) evaluates an unprunable qual per 1024-row vector to skip
payload decode for no-match vectors. It wins on a wide projection (ClickBench q24,
SELECT * : 6098->3395 ms) but regressed eight narrow aggregates 1.2-2x, because
the per-vector evaluation cannot pay for itself when there are few projected
columns to spare from decode. Gate it on payload width: run only when the
projected non-qual column count is >= pgcolumnar.qual_skipvec_min_payload_cols
(default 20; 0 = always gate, the pre-commandprompt#595 behaviour). Measured to recover the
ClickBench win count 25->33 while keeping q24's win.

The rejected runtime selectivity probe (its first-K sample is unrepresentative on
clustered data) is not shipped. test/native_decode_gate_width.sh is the TDD suite:
wide gates / narrow does not, each with a removal proof, the >= boundary, a
matches-everything control, and a heap differential.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW

@jdatcmd jdatcmd 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. This is the right fix for #595, and I verified it rather than trusting it — built the branch on PG17, ran the suites, and proved the new suite guards the change by mutation. It cleanly recovers the regression my own #452 phase 2 introduced.

The logic is correct

Width is the right discriminator, and it is a plan-time count with no data probe: pgcolumnar_native_payload_col_count counts colWanted[c] && !native_is_qual_column(rs, c). I want to flag one thing I checked carefully because it looked wrong at first: with numPredicates == 0 (exactly when this gate fires), native_is_qual_column's predicate loop is empty — but the function has a second branch that consults rs->nativeQualCols[col], so the executor qual's own columns are still excluded. The count is genuinely "columns sparable from decode", the comment is accurate, and the qual column (which must be decoded to evaluate the qual) is correctly not counted. Default 20 sits safely in the gap between q24 (~104 payload cols) and the narrow aggregates (~1-3); 0 = always gate is a clean escape hatch.

Verified on PG17 (my lane)

  • Builds clean, no -Wshadow/-Werror.
  • native_decode_gate_width 35/35, native_decode_gating 26/26, differential 201/201.
  • Removal proof, done by mutation, not by reading: I stripped the width condition back to the pre-#595 if (pass == 1 && allDescriptor && rs->numPredicates == 0) and rebuilt. The suite went RED on exactly the two #595 behaviors — narrow, default -> NOT gated failed (narrow gated: 32 vs 96 decodes) and wide, gate raised above width -> stops gating failed (GUC ignored: 32 vs 800). So the suite is non-vacuous and pins precisely what this PR adds. The >= boundary (24 gates, 25 does not), the matches-everything control, and the heap-oracle output-invariance arm are all real.

On the perf recovery (25 -> 33)

I did not re-run the full ClickBench A/B — but I do not need to for confidence, and here is why: I proved the gate decision flips correctly via the decode counter, and a narrow query that no longer gates executes the pre-phase-2 path exactly (no per-vector qual eval, full payload decode). That path is the "gating off" arm #596 measured as faster. So the recovery of the eight narrow queries follows by construction from the gating-decision behavior I verified; the ClickBench numbers are your measurement and the mechanism guarantees their direction.

One non-blocking nit (which you already flagged)

The two "rejected probe GUC did not ship" premises assert an empty SHOW, and q() sends stderr to /dev/null, so they pass because SHOW of an unknown GUC errors — empty stdout, not a real value. Your paired positive premise rules out a dead connection, which is the main false-pass, so this is fine to ship. The airtight form is to assert the unrecognized configuration parameter error (ideally SQLSTATE 42704) rather than emptiness — the same assert-SQLSTATE-not-absence discipline CONTEXT.md codifies. Worth a follow-up, not a blocker.

Correct, well-tested, and honest about its own trade and its one weak premise. Approving.

@jdatcmd
jdatcmd merged commit e9a9c6e into commandprompt:main Aug 13, 2026
11 checks passed
ChronicallyJD pushed a commit to ChronicallyJD/pgcolumnar that referenced this pull request Aug 13, 2026
…ode gate

Re-ran the full cross-engine ClickBench (heap, columnar, columnar_tuned,
citus, duckdb) on the commandprompt#598 build, one consistent run. The width gate on
commandprompt#452 phase-2 decode gating recovers the eight narrow queries the always-on
gating had regressed, so columnar now beats heap on 34 of 43 queries (was
25), ties 2, loses 7. q24 keeps its gating win.

Refreshed the 2026-08-12 run section from that single run: load table,
query totals, geomeans, the win count, the wide-text loss table, and the
tuned-arm note. Rewrote the commandprompt#452-trade blockquote: the selectivity
regression is resolved by the width gate, not an open item for a runtime
probe. Fixed a stale back-reference (2026-08-09 -> 2026-08-12).

Numbers verified against the raw run; STE style gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Refreshed the ClickBench benchmark docs on this branch from a full re-run on the width-gate build (6728a37).

I re-ran the complete cross-engine suite (heap, columnar, columnar_tuned, citus, duckdb) as one consistent run on the pg18n non-assert build with this PR installed. The width gate does what the design predicted:

  • columnar beats heap on 34 of 43 queries, ties 2, loses 7 (was 25 / 13 / 5 with No late materialization: decode cost scales with rows scanned, not rows emitted #452 phase-2 gating always on).
  • The eight narrow queries the always-on gating regressed all recover to wins: q11 (0.77), q12 (0.79), q14 (0.85), q15 (0.88), q25 (0.76), q27 (0.80), q31 (0.86), q32 (0.92) col/heap.
  • q24 keeps its gating win (col/heap 4.34, versus ~7.8x un-gated) — it is the one query wide enough to clear the 20-non-qual-column threshold.

The docs/benchmarks.md update rewrites the #452-trade blockquote accordingly: the selectivity regression is now resolved by the payload-width gate rather than an open item awaiting a runtime probe. Every number traces to the raw run and the STE style gate passes.

jdatcmd pushed a commit that referenced this pull request Aug 13, 2026
Re-ran the full cross-engine ClickBench (heap, columnar, columnar_tuned,
citus, duckdb) on the #598 build, one consistent run. The width gate on
#452 phase-2 decode gating recovers the eight narrow queries the always-on
gating had regressed, so columnar now beats heap on 34 of 43 queries (was
25), ties 2, loses 7. q24 keeps its gating win.

Refreshed the 2026-08-12 run section from that single run: load table,
query totals, geomeans, the win count, the wide-text loss table, and the
tuned-arm note. Rewrote the #452-trade blockquote: the selectivity
regression is resolved by the width gate, not an open item for a runtime
probe. Fixed a stale back-reference (2026-08-09 -> 2026-08-12).

Numbers verified against the raw run; STE style gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
jdatcmd added a commit that referenced this pull request Aug 13, 2026
docs: refresh ClickBench for the merged #598 payload-width decode gate
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.

Decode gating (#452 phase 2) is unconditional; gate it on selectivity so it stops regressing less-selective scans

2 participants