Split out of #766, which closed on a different question. This is the concrete cost-model defect that measurement exposed.
The defect
For a correlated key on an ordered columnar table, the planner prefers a fetching index scan over the custom scan through roughly 50,000 rows, while the custom scan does about 27x less work across that whole region. The cost ordering only flips near 100,000.
Measured by @linuxhikerpm on bfdd1f9, one million ordered rows, three narrow integer columns, btree on id, PostgreSQL 18.6 non-assert, serial, vectorization and JIT off. Read work is backend retired instructions, not wall time — backend pinned to CPU 4, hardware cpu_atom/instructions/, median of three, three executions each, every arm returning the same aggregate:
k chosen chosen instr custom instr index instr index/custom
1 Index Scan 18.46 M 5.67 M 18.27 M 3.22x
1,000 Index Scan 47.11 M 6.24 M 46.90 M 7.52x
10,000 Index Scan 304.09 M 11.26 M 303.83 M 26.98x
50,000 Index Scan 1,446.26 M 53.40 M 1,445.94 M 27.08x
100,000 Custom Scan 106.15 M 106.06 M 2,873.36 M 27.09x
800,000 Custom Scan 844.19 M 844.09 M 22,917.47 M 27.15x
The inversion is visible directly in the costs at k=50,000:
chosen index cost 2,365.02 -> 1.446 Ginstr
custom scan cost 2,634.32 -> 0.053 Ginstr
Reproduced independently by @jdatcmd on a different box with a differently-seeded fixture. Plan choice is cost-based and deterministic, so it verifies without timing:
k planner chose custom cost index cost
1000 Index Scan 2507.53 34.69
10000 Index Scan 2528.07 290.97
50000 Index Scan 2629.15 1545.65
100000 Custom Scan 2754.96 3107.16
200000 Custom Scan 5514.26 6261.62
Same crossover, same direction.
Why it is not #766
#766 asked whether the custom scan being priced below heap per unit of work causes a wrong choice. It does not, and #766 closed on that. This is the opposite direction — the custom scan priced too high against a competing index path — and the corrective term is different: it belongs to pgcolumnar_index_fetch_penalty, not pgcolumnar_refined_scan_cost.
Raising the custom scan's cost, which #766 was contemplating, would push the crossover past 100,000 and enlarge this wrong region.
What a fix has to do, and what it must not
The index path's per-row fetch cost is understated for a columnar table: each fetch pays group location and per-column decode that a heap tuple fetch does not. The penalty term exists for this; the measurement says it is too small by roughly the observed ratio through the correlated region.
Do not calibrate against heap cost-per-instruction. #766 established that the two access paths do structurally different work, so forcing their ratios to agree predicts the wrong winner on a live plan competition. seq_page_cost is a user-controlled storage assumption, not an empirical CPU conversion factor.
Verification this needs
A red-first plan-choice test, in both harnesses per the house rule, that asserts which plan is chosen at selectivities either side of the crossover rather than asserting a cost number. A cost is an implementation detail that will drift; the plan is the property.
- red before the change: at
k=50,000 on the fixture above, the planner picks the index path
- green after: it picks the custom scan, and the correctness arms still return identical aggregates from both paths
- a control at a selectivity where the index path is genuinely correct, so the fix is shown not to have simply disabled index scans
- and the removal proof: revert the penalty term and watch the plan-choice arm redden
Plan choice is deterministic, so this needs no timing and no instruction counting — which is what makes it a suitable CI gate, unlike the measurement that found it.
Split out of #766, which closed on a different question. This is the concrete cost-model defect that measurement exposed.
The defect
For a correlated key on an ordered columnar table, the planner prefers a fetching index scan over the custom scan through roughly 50,000 rows, while the custom scan does about 27x less work across that whole region. The cost ordering only flips near 100,000.
Measured by @linuxhikerpm on
bfdd1f9, one million ordered rows, three narrow integer columns, btree onid, PostgreSQL 18.6 non-assert, serial, vectorization and JIT off. Read work is backend retired instructions, not wall time — backend pinned to CPU 4, hardwarecpu_atom/instructions/, median of three, three executions each, every arm returning the same aggregate:The inversion is visible directly in the costs at
k=50,000:Reproduced independently by @jdatcmd on a different box with a differently-seeded fixture. Plan choice is cost-based and deterministic, so it verifies without timing:
Same crossover, same direction.
Why it is not #766
#766 asked whether the custom scan being priced below heap per unit of work causes a wrong choice. It does not, and #766 closed on that. This is the opposite direction — the custom scan priced too high against a competing index path — and the corrective term is different: it belongs to
pgcolumnar_index_fetch_penalty, notpgcolumnar_refined_scan_cost.Raising the custom scan's cost, which #766 was contemplating, would push the crossover past 100,000 and enlarge this wrong region.
What a fix has to do, and what it must not
The index path's per-row fetch cost is understated for a columnar table: each fetch pays group location and per-column decode that a heap tuple fetch does not. The penalty term exists for this; the measurement says it is too small by roughly the observed ratio through the correlated region.
Do not calibrate against heap cost-per-instruction. #766 established that the two access paths do structurally different work, so forcing their ratios to agree predicts the wrong winner on a live plan competition.
seq_page_costis a user-controlled storage assumption, not an empirical CPU conversion factor.Verification this needs
A red-first plan-choice test, in both harnesses per the house rule, that asserts which plan is chosen at selectivities either side of the crossover rather than asserting a cost number. A cost is an implementation detail that will drift; the plan is the property.
k=50,000on the fixture above, the planner picks the index pathPlan choice is deterministic, so this needs no timing and no instruction counting — which is what makes it a suitable CI gate, unlike the measurement that found it.