Skip to content

Size the index-fetch decode from the attribute prefix (#363) - #368

Merged
jdatcmd merged 2 commits into
commandprompt:mainfrom
ChronicallyJD:fix/363-prefix-width
Aug 4, 2026
Merged

Size the index-fetch decode from the attribute prefix (#363)#368
jdatcmd merged 2 commits into
commandprompt:mainfrom
ChronicallyJD:fix/363-prefix-width

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

What

Closes #363. The index-fetch cost model sized the per-fetch decode from the columns
the scan emits. The deferred index-fetch slot decodes the attribute prefix
0..max-referencedslot_getsomeattrs asks for a prefix and cannot ask for a set
— so a query referencing a late column decodes every column before it.

Both of the model's inputs understated it:

  • decoded_width came from rel->reltarget->width, which gates the cap-crossing
    branch;
  • nproj was the count of referenced columns rather than the length of the prefix.

Measured on a ten-text-column table, same 300 fetched rows, same emitted width,
same plan, varying only which column is referenced:

max(a1):     975 ms
max(a10): 194,798 ms

200x, and rel->reltarget->width is identical for both — the two queries sit on
opposite sides of the 32 MB fetch cache cap while the model computed the same
decoded_width for each.

columnar_scan_decode_shape() returns the prefix length and its summed width.

Why it matters more now than when it was filed

Before #365 the penalty could not change a plan at all, so a wrong input to it was
inert. It is load-bearing for plan choice now, which is jdatcmd's argument on #367 for
not deferring this past alpha, and I agree with it — I had it as post-alpha and was
wrong.

Design decisions worth a reviewer's eye

  • Widths come from pg_statistic, falling back to the type average, the way
    set_rel_width does. The unreferenced columns in the prefix are not in reltarget
    at all, so there is nothing else to read them from.
  • A whole-row reference widens the prefix to the whole tuple. pull_varattnos
    reports it as attribute 0; treating that as "no columns" would understate by the
    entire table.
  • A dropped column holds its place and decodes nothing — it stays in the prefix
    length, contributes no width.
  • System columns are ignored: they cost no decode.
  • The penalty arithmetic is untouched; only what is fed into it changed.

Tests

test/analyze_stats.sh gains a case that holds row count, emitted width and plan
shape fixed and varies only which column is referenced — a1 versus a10 on an
eleven-column table. a1's prefix decodes ~15 MB per group and stays under the cap;
a10's decodes ~156 MB and does not.

Proven by removal. Same test file, PG18 assert:

build max(a1) — must keep the index max(a10) — must lose it
unfixed 702d125 PASS Index Scan using o363_id FAIL Index Scan using o363_id
this branch PASS Index Scan using o363_id PASS Custom Scan (ColumnarScan)

and every existing #355 and #362 check passes on both builds, so the change does not
over-fire into the cases #171/#159 protect: the clustered ORDER BY, the selective
point lookup, and the early-column query all keep their indexes.

Gate

Five-major build: BUILD_OK, 0 warnings on PG15/16/17/18/19. Full matrix on
PG18 + PG19 running; I will post it as a comment.

The one expected red is the pre-existing wide-table ANALYZE timing ratio in
analyze_stats, which fails identically on clean main and is unrelated to the
planner (see #359 — ANALYZE never enters columnar_fetch_row).

🤖 Generated with Claude Code

Joshua (D) Drake and others added 2 commits August 3, 2026 19:22
…ompt#363)

Holds row count, emitted width and plan shape fixed and varies only which column
is referenced. a1's prefix decodes ~15 MB per group and stays under the fetch
cache cap; a10's decodes ~156 MB and does not, so the early column should keep
its index and the late one should not.

A build that sizes the decode from rel->reltarget->width cannot tell the two
apart -- it is identical for both -- so this fails on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ompt#363)

The cost model sized the per-fetch decode from the columns the scan emits. The
deferred index-fetch slot decodes the attribute prefix 0..max-referenced,
because slot_getsomeattrs asks for a prefix and cannot ask for a set, so a query
referencing a late column decodes every column before it.

Both of the model's inputs understated it: decoded_width came from
rel->reltarget->width, and nproj was the count of referenced columns rather than
the length of the prefix. Measured on a ten-text-column table, same 300 fetched
rows, same emitted width, same plan: max(a1) 975 ms against max(a10) 194,798 ms,
the two sitting on opposite sides of the 32 MB fetch cache cap while the model
computed the same decoded_width for both.

columnar_scan_decode_shape returns the prefix length and its summed width,
taking widths from pg_statistic where ANALYZE has run and the type average
otherwise, as set_rel_width does -- the unreferenced columns in the prefix are
not in reltarget at all. A whole-row reference widens the prefix to the whole
tuple; a dropped column holds its place and decodes nothing.

This matters more after commandprompt#365 than it did before: the penalty could not change a
plan then, and now it can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Gate: full matrix on PG18 + PG19

pgcolumnar-audit, assert builds.

  • Preflight, all five majors: rc=0, BUILD_OK, 0 warnings on PG15/16/17/18/19.
  • Full matrix, PG18 and PG19: every suite PASS on both, except analyze_stats.
$ grep -oE "[a-z_]+=FAIL" matrix.log | sort | uniq -c
      2 analyze_stats=FAIL

The one red is the pre-existing wide-table ANALYZE timing ratio (3,557 ms against a
57 ms scan on PG18; 2,707 against 57 on PG19). It fails identically on clean main on
this box and is unrelated to the planner — ANALYZE never enters columnar_fetch_row,
which I measured on #359 with an instrumented build (0 calls during ANALYZE against 2
for a control point query).

Every plan-shape check passes on both majors, including the ones that guard the
opposite direction:

PASS  the fetch penalty leaves a clustered ORDER BY on its index (#355 must not over-fire)
PASS  the fetch penalty leaves a selective point lookup on the index (#355 vs #171)
PASS  the penalty is applied before the columnar path is offered, so it can still win (#362)
PASS  an early column's short decode prefix leaves it on the index (#363)
PASS  a late column's wide decode prefix costs it off the index (#363)

The early-column check is the one that matters for over-firing: widening the model's
notion of the decode makes the penalty larger everywhere, so a table where the prefix
is wide but the query is genuinely selective must still keep its index. It does.

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

Approved. Premise verified in code, matrix run here.

The load-bearing claim is correct and I checked it rather than taking it.
columnar_slot_decode_upto builds needed as {0 .. natts-1} and its own comment
says "Decodes a prefix because that is what slot_getsomeattrs asks for". So sizing
the model from reltarget->width really was measuring the wrong thing, and the two
queries in your test really are indistinguishable to the old code.

Details I specifically looked for and found handled:

  • Dropped columns occupy a place in the prefix and decode nothing (continue on
    !OidIsValid(typid)).
  • Whole-row references take the prefix to rel->max_attr.
  • Widths come from pg_statistic with a type-average fallback, which is
    necessary rather than incidental: the unreferenced columns in the prefix are not
    in reltarget at all, so there is nowhere else to get them.
  • nproj becoming the prefix count rather than the referenced count is right for
    the same reason, since decode_per_group charges per decoded column.

Matrix, run here

fix/363-prefix-width at cd0e140, full suite matrix:

PASS  PG18  (112 suites)
FAIL  PG19  native_cancel only

That red is not yours. It is cancel=85ms full=158ms against the old
cancel < full / 2 threshold, and your branch predates #373, which replaced
that threshold on main this evening because it has only 27 ms of headroom above a
50 ms floor and was failing PG17 two runs in three. Under #373's window the limit on
that run is 104 ms, so 85 ms passes. Merging onto current main resolves it; nothing
to change here.

One correction to the PR body

You listed the wide-table analyze_stats ANALYZE timing ratio as an expected red.
It passed on both majors here, and it also passed on all five majors in the full
15-19 matrix I ran at 488a2c0. So that red is specific to your container rather
than pre-existing in the code. Worth knowing before it gets treated as a known-bad
and discounted somewhere it matters.

On the result itself

975 ms against 194,798 ms on the same emitted width is the kind of thing a cost
model has no business being blind to, and after #362 the penalty is load-bearing for
plan choice rather than decorative, so this closes the gap I flagged on #367 as my
one reservation about not holding alpha for #363. Good that it is fixed rather than
documented.

Merging.

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.

The index-fetch cost model sizes the decode from the emitted columns, but the fetch decodes the attribute prefix: 200x invisible to reltarget->width

2 participants