Skip to content

Price index fetches before offering the columnar path (#362) - #365

Merged
jdatcmd merged 4 commits into
jdatcmd:mainfrom
ChronicallyJD:fix/362-penalty-before-add-path
Aug 3, 2026
Merged

Price index fetches before offering the columnar path (#362)#365
jdatcmd merged 4 commits into
jdatcmd:mainfrom
ChronicallyJD:fix/362-penalty-before-add-path

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

What

Closes #362. The #355 index-fetch penalty ran after every add_path in
ColumnarSetRelPathlist. add_path frees a path it judges dominated, so the columnar
path — offered while the index paths still carried their un-penalized costs — was
discarded there and then, and inflating the surviving index path afterwards changed
what EXPLAIN printed with nothing left to switch to.

My reasoning in #360's body was half right: I argued that add_path's dominance test
is order-independent so applying the penalty last was safe. Order-independence of the
comparison says nothing about a path that has already been freed.

The penalty now runs immediately after the seqscan paths are dropped and before any
columnar path is offered, so every dominance test sees final costs.

Behaviour

query before after
selective scattered condition (WHERE h = 7) Index Scan, then per-row fetch Custom Scan (ColumnarScan)
unclustered ORDER BY (#355) Sort over the scan Sort over the scan (unchanged)
clustered ORDER BY (#355) Index Scan Index Scan (unchanged)
selective point lookup (#171/#159) Index Scan Index Scan (unchanged)

Measured on the 100M bench before this change: an Index Scan priced at 13,954,742
chosen over a columnar path priced at 589,348 — one the planner could no longer
see — running 224,055 ms where the columnar path runs 4,728 ms. jdatcmd
reproduced the same shape on a 2M fixture with a 140x margin.

Design decisions worth a reviewer's eye

Tests

test/analyze_stats.sh gains a selective index condition case — the shape the
existing #355 checks do not cover, because an unrestricted ORDER BY is precisely
where the columnar path survives add_path on its own merits and the penalty gets to
decide.

Proven by removal, as asked on the issue. Same test file, PG18 assert:

build #362 premise (penalty off) #362 check (penalty on)
unfixed cd06471 PASS — Index Scan using o362_h FAIL — Index Scan using o362_h
this branch PASS — Index Scan using o362_h PASS — Custom Scan (ColumnarScan)

and on the unfixed build all four existing #355 checks still pass, which is the point:
they cannot see this defect.

Gate

pgcolumnar-audit, assert builds.

  • Build on all five majors (PG15/16/17/18/19): BUILD_OK, 0 warnings on each —
    which is what exercises the disabled_nodes version split.
  • analyze_stats.sh on PG18 assert: 23 checks, every one passing except the
    pre-existing wide-table ANALYZE timing ratio, which fails identically on clean
    main
    (3,706 ms against a 63 ms scan there, 2,741 against 59 here) and is unrelated
    to the planner — see The fetch cache cliff persists after #357: 4 to 5 aggregate columns is a 47x jump #359 for why that check is not the fetch-cache cliff.

Full matrix on 18+19 to follow in a comment; posting now so the approach can be
argued, since the reorder changes an invariant the previous ordering was chosen to
protect.

Note on #363

The second defect in the same function — the model sizing the decode from
reltarget->width (emitted) while the fetch decodes the attribute prefix — is
untouched here and survives this change. Kept separate deliberately so each has its
own removal proof.

🤖 Generated with Claude Code

Joshua (D) Drake and others added 2 commits August 3, 2026 14:39
…fered (jdatcmd#362)

The jdatcmd#355 checks all use an ORDER BY with no restriction, which is the case where
the columnar path survives add_path on its own and the penalty gets to decide.
They pass on a build where the penalty cannot change a plan at all.

This adds the failing shape: a selective index condition, where add_path frees
the columnar path as dominated while the index path still carries its
un-penalized cost. Fails on main, passes with the reordering that follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The jdatcmd#355 penalty ran after every add_path in ColumnarSetRelPathlist, on the
reasoning that add_path's dominance test is order-independent so only the list
sort mattered. That covers ordering and not rejection: add_path frees a path it
judges dominated, so a columnar path offered while the index paths still carry
their un-penalized costs is discarded there and then, and raising those costs
afterwards changes what EXPLAIN prints with nothing left to switch to.

Measured on the 100M bench before this: an Index Scan priced at 13,954,742
chosen over a columnar path priced at 589,348 that the planner could no longer
see, running 224,055 ms where the columnar path runs 4,728 ms. jdatcmd
reproduced it on a 2M fixture with a 140x margin.

Move the penalty to just after the seqscan paths are dropped and before any
columnar path is offered, and re-sort rel->pathlist afterwards -- which is the
invariant the old ordering existed to protect. The sort key is per version:
PG18+ orders by disabled_nodes then total_cost, earlier majors by total_cost
alone.

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

Copy link
Copy Markdown
Collaborator Author

Gate: full matrix on PG18 + PG19, as promised in the body

pgcolumnar-audit, assert builds, gating c83e2bb.

CI: 11/11 green.

Preflight (build + suites, all five majors): rc=0. BUILD_OK and 0 warnings on
PG15/16/17/18/19 — which is what exercises the disabled_nodes version split, since
the field exists only on 18+.

Full matrix, PG18 and PG19: every suite PASS on both, except analyze_stats.
That includes the ones this change could plausibly disturb —
native_index, native_ios, index_only, native_fetch_cache,
native_fetch_projection, native_fetch_position, column_projection,
native_lazy_slot, projections, sorted_projection, parallel,
harness_selftest, docs_style.

The one red is the pre-existing ANALYZE timing ratio

PG18:  FAIL  ANALYZE on a wide table is not many times a full scan of it
             (3797 ms against a 55 ms scan)
PG19:  FAIL  same check (2905 ms against a 53 ms scan)

Every other check in that suite passes on both majors. It fails identically on clean
main on this box (3,706 ms against 63 ms measured there today), it is a wall-clock
ratio rather than a plan assertion, and it is not the #359 fetch-cache cliff — ANALYZE
never enters columnar_fetch_row (0 calls under instrumentation against 2 for a
control point query; details on #359). This branch is planner-only and does not touch
that path.

The checks this PR is actually about, on both majors

PASS  without the fetch penalty an unclustered ORDER BY takes the index (#355 premise)
PASS  the fetch penalty makes an unclustered ORDER BY sort rather than fetch per row (#355)
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)
--    selective h = 7, penalty off: Index Scan using o362_h on o362
PASS  without the penalty a selective scattered condition takes the index (#362 premise)
--    selective h = 7, penalty on: Custom Scan (ColumnarScan) on o362
PASS  the penalty is applied before the columnar path is offered, so it can still win (#362)

Both safety directions hold: the clustered ORDER BY and the selective point lookup
stay on their indexes, so the earlier application of the penalty does not over-fire
into the cases #171/#159 protect.

…datcmd#362)

The partial columnar path was conditional on seqpath != NULL, and add_path
frees the seqscan whenever an index path beats it -- which is precisely the
selective queries where the index is attractive. So no parallel columnar path
existed exactly where one was most needed.

That was invisible while the index path won those queries anyway. Once the
fetch penalty prices the index scan out (the commit before this one), the only
alternative left was the serial columnar scan: measured on the 100M fixture,
the planner took the serial path at cost 2,350,535 / 25.3 s while a parallel
path it prices at 589,348 / 4.6 s was unavailable.

Cost the partial path from the serial columnar path instead. cpath already
carries the seqscan's costs when one survived and its own computed costs when
none did, so this is identical wherever the old condition fired and defined
wherever it did not.

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

add_path frees a path it judges dominated, so cpath is not safe to read once it
has been offered. Two places did: the parallel partial path added in the commit
before this one, and -- older than this branch -- the projection path, which has
costed itself from cpath->path since it was written.

It fails as a zero-cost path rather than a crash, which is how it surfaced: on
the 100M fixture a selective point lookup came back as

    Parallel Custom Scan (ColumnarScan) ... (cost=0.00..0.00 rows=0)

instead of the index scan it must keep (jdatcmd#171/jdatcmd#159), because the index path had
dominated the columnar scan, add_path freed it, and both derived paths then read
the freed struct.

Capture startup_cost and total_cost into locals before add_path and cost both
derived paths from those.

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

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

Verified independently rather than taken on the body's word.

The fix works on a repro I wrote before this PR existed. My #362 probe (2M rows, throwaway container, unrelated to analyze_stats.sh) flips from the defect to the intended plan:

build chosen plan cost
main cd06471 Index Scan 5,695,111
this branch Custom Scan (ColumnarScan) 40,588

40,588 is exactly the cost of the path that only appeared under enable_indexscan=off before, which is the whole point: the columnar path now survives add_path and gets chosen on its merits.

The version split is right, and I checked it rather than trusting it. The #if PG_VERSION_NUM >= 180000 guard around disabled_nodes is the part that compiles either way, so I went to the headers on all five majors:

pg15 15.18   disabled_nodes absent      pg18 18.4     disabled_nodes present
pg16 16.14   disabled_nodes absent      pg19 19beta2  disabled_nodes present
pg17 17.10   disabled_nodes absent

I went in expecting this to be wrong — I had it as a PG17 addition — and it is not. The guard matches the field's actual introduction.

Full matrix, PG18 + PG19 assert, 112 suites each: PASS. I ran it here since the body listed it as still to follow. One caveat that is mine and not yours: my first run showed harness_selftest FAIL on both majors with unregistered: v362 — that was my own probe script, which I had copied into the tree before tarring it across. Removed it and the suite passes 14/14. Everything else, including analyze_stats and every planner suite, passed on the first run.

On the approach

Penalising before offering is the right call, and re-sorting is genuinely the price of it rather than an optional tidy-up. Two things I checked specifically:

  • columnar_path_order_cmp matches list_sort's comparator signature, and list_sort sorts in place, so rel->pathlist stays the same List object core holds.
  • Nothing else adds base paths after set_rel_pathlist_hook, so there is no later add_path that could see the list in a state this function did not leave it in.

The mutated flag avoiding a pointless sort when the penalty is zero is a nice touch — it means the common no-index case pays nothing at all.

One note for the follow-up, not blocking

The parameterized-path exemption is now load-bearing in a place it was not before. Previously a parameterized path kept its un-penalized cost and the ordering was already whatever core left; now the list is explicitly re-sorted with those paths still un-penalized alongside penalized ones. That is correct — the sort key is uniform and add_path compares parameterizations separately — but it is worth a check in whatever addresses #363, since that one changes the penalty's magnitude and so widens the gap between the two groups.

Merging.

@jdatcmd
jdatcmd merged commit 702d125 into jdatcmd:main Aug 3, 2026
9 checks passed
jdatcmd pushed a commit that referenced this pull request Aug 4, 2026
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 #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>
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 #355 index-fetch penalty changes the estimate but not the plan: the cheaper path is already freed when it is applied

2 participants