Price index fetches before offering the columnar path (#362) - #365
Conversation
…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>
Gate: full matrix on PG18 + PG19, as promised in the body
CI: 11/11 green. Preflight (build + suites, all five majors): rc=0. BUILD_OK and 0 warnings on Full matrix, PG18 and PG19: every suite PASS on both, except The one red is the pre-existing ANALYZE timing ratioEvery other check in that suite passes on both majors. It fails identically on clean The checks this PR is actually about, on both majorsBoth safety directions hold: the clustered |
…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
left a comment
There was a problem hiding this comment.
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_cmpmatcheslist_sort's comparator signature, andlist_sortsorts in place, sorel->pathliststays the same List object core holds.- Nothing else adds base paths after
set_rel_pathlist_hook, so there is no lateradd_paththat 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.
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>
What
Closes #362. The #355 index-fetch penalty ran after every
add_pathinColumnarSetRelPathlist.add_pathfrees a path it judges dominated, so the columnarpath — 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
EXPLAINprinted with nothing left to switch to.My reasoning in #360's body was half right: I argued that
add_path's dominance testis 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
WHERE h = 7)ORDER BY(#355)ORDER BY(#355)Measured on the 100M bench before this change: an
Index Scanpriced at 13,954,742chosen 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
rel->pathlistafter mutating is not optional. It is the invariantthe old ordering existed to protect: mutating
total_costin place unsorts the listand
add_pathinserts positionally. At this point the list holds only index andbitmap paths (the seqscans having just been dropped), so the sort is over a handful
of entries.
keeps
pathlistsorted bydisabled_nodesand thentotal_cost; PG15/16/17 have nodisabled_nodesfield and sort bytotal_costalone.columnar_path_order_cmpspells both out under
PG_VERSION_NUMrather than assuming one.on The #355 index-fetch penalty changes the estimate but not the plan: the cheaper path is already freed when it is applied #362: a path
add_pathhas already freed is a dangling pointer, so it would meanconstructing a second
CustomPathrather than keeping a reference.alters the magnitude, so Planner chooses an Index Scan on columnar for ordering; per-row fetch cost is not modelled and the plan is orders of magnitude slower #355's tuning and Hold what fits in the fetch cache instead of dropping it all (#359) #361's proportional overflow blend carry
over unmodified.
Tests
test/analyze_stats.shgains a selective index condition case — the shape theexisting #355 checks do not cover, because an unrestricted
ORDER BYis preciselywhere the columnar path survives
add_pathon its own merits and the penalty gets todecide.
Proven by removal, as asked on the issue. Same test file, PG18 assert:
cd06471Index Scan using o362_hIndex Scan using o362_hIndex Scan using o362_hCustom 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.which is what exercises the
disabled_nodesversion split.analyze_stats.shon PG18 assert: 23 checks, every one passing except thepre-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 — isuntouched here and survives this change. Kept separate deliberately so each has its
own removal proof.
🤖 Generated with Claude Code