Skip to content

The index-fetch penalty is too small: the planner prefers a fetching index scan doing 27x the work through ~50,000 rows #913

Description

@jdatcmd

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions