Skip to content

Cost a columnar index scan's per-row heap fetch (#355) - #360

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:fix/355-index-fetch-cost
Aug 3, 2026
Merged

Cost a columnar index scan's per-row heap fetch (#355)#360
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:fix/355-index-fetch-cost

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

What

Closes #355. The planner took an index scan on a columnar table to satisfy an
ORDER BY without modelling what the ordering costs here: each row is fetched by
number, and a columnar fetch decodes the whole row group the row lives in. Core
prices the fetch as a page or two, so for an unclustered ordering column the index
scan looked cheap, beat a sort, and then ran for minutes decoding the table many
times over.

ColumnarSetRelPathlist now adds columnar_index_fetch_penalty to the
total_cost of the surviving heap-fetching index and bitmap paths. The penalty is
the cost of the row-group decodes the fetches force, counted as distinct groups
touched
: the statement-scoped fetch cache (#143) decodes a group once per scan,
so a clustered ordering visits ceil(rows/R) groups and an unclustered one up to
one group per row. It interpolates between the two on the square of the leading-key
correlation, read from pg_statistic the way btcostestimate reads it.

Behaviour

query before after
unclustered ORDER BY scat Index Scan (then per-row fetch) Sort over the scan
clustered ORDER BY id Index Scan Index Scan (unchanged)
selective WHERE id = ? (#171) Index Scan Index Scan (unchanged)

Gated behind pgcolumnar.enable_index_fetch_penalty (default on); off restores the
previous planner behaviour.

Design decisions worth a reviewer's eye

  • total_cost only, never startup_cost — a LIMIT that stops the scan early
    pays the penalty proportionally (the planner fractions total - startup).
  • Non-parameterized paths only. A parameterized index scan is a nested-loop
    inner side rescanned per outer row, and the fetch cache spans those rescans, so
    the single-pass distinct-group count this models would over-penalize the join.
    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 is the standalone ordering/lookup case, where param_info is NULL.
  • Applied after every add_path in the hook, on purpose. It mutates
    total_cost in place, which unsorts rel->pathlist, and no add_path may see an
    unsorted list. add_path's dominance test compares pairs directly
    (order-independent); only its insertion position depends on the sort, and
    set_cheapest — which core runs right after this hook — rescans the whole list.
  • Missing statistic ⇒ correlation 0.0 (treat as unclustered, the penalize
    direction), so a missing ANALYZE is pessimistic rather than a silent under-cost
    that drops a needed index.

Relationship to #359

The penalty's worst-case branch keys on the fetch cache's 32 MB cap, hoisted to
columnar.h as COLUMNAR_FETCH_CACHE_MAX_BYTES so both sites name the same value.
That branch is the #359 cliff; when #359 makes cache overflow proportional
rather than total, it should scale by the overflow fraction. I'm taking #359 next
and will keep the two consistent.

Tests

test/analyze_stats.sh gains a paired plan-choice section: the same unclustered
ORDER BY takes the index with the penalty off (the #355 premise — so the test
proves there was something to prevent) and sorts with it on, while a clustered
ORDER BY and a point lookup stay on their index. The checks are plan-shape, not
timing, so they are stable under matrix contention.

Gate

Assert builds, pgcolumnar.audit container.

The one red check is pre-existing (#359), not from this change

analyze_stats.sh's wide-table ANALYZE timing check fails on this container —
but it fails identically on clean main (23bd094), three runs, 2601 / 2870 /
2653 ms, versus this branch's 2569 ms. It is the #359 cliff surfacing in the
ANALYZE-by-row-number path (the test's own comment says the wide group exceeds the
fetch cache cap and re-decodes per offered row), and this branch is planner-only —
it does not touch that path. Every other analyze_stats check, including the four
this PR adds, passes. The check will go green when #359 makes cache overflow
proportional.

🤖 Generated with Claude Code

The planner took an index scan on a columnar table to satisfy an ORDER BY
without modelling what the ordering costs here: each row is fetched by number,
and a columnar fetch decodes the whole row group the row lives in. Core prices
the fetch as a page or two, so for an unclustered ordering column the index scan
looked cheap and beat a sort, then ran for minutes decoding the table many times
over.

ColumnarSetRelPathlist now adds columnar_index_fetch_penalty to the total_cost
of the surviving heap-fetching index and bitmap paths. The penalty is the cost
of the row-group decodes the fetches force, counted as distinct groups touched:
the statement-scoped fetch cache (commandprompt#143) decodes a group once per scan, so a
clustered ordering visits ceil(rows/R) groups and an unclustered one up to one
per row. It interpolates between the two on the square of the leading-key
correlation, read from pg_statistic the way btcostestimate reads it. A clustered
ORDER BY and a selective point lookup (commandprompt#171) keep their index; an unclustered
ORDER BY sorts instead.

Design decisions worth noting for review:

- total_cost only, never startup_cost, so a LIMIT that stops the scan early pays
  the penalty proportionally (the planner fractions total - startup).
- Non-parameterized paths only. A parameterized index scan is a nested-loop
  inner side rescanned per outer row, and the fetch cache spans those rescans, so
  the single-pass distinct-group count this models would over-penalize the join.
  commandprompt#355 is the standalone ordering/lookup case, where param_info is NULL.
- Applied after every add_path in the hook, on purpose: it mutates total_cost in
  place, which unsorts rel->pathlist, and no add_path may see an unsorted list.
  add_path's dominance test compares pairs directly (order-independent); only its
  insertion position depends on the sort, and set_cheapest -- which core runs
  right after this hook -- rescans the whole list.
- Correlation returns 0.0 (treat as unclustered, the penalize direction) whenever
  a statistic is missing, so a missing ANALYZE is pessimistic rather than a silent
  under-cost that drops a needed index.

Gated behind pgcolumnar.enable_index_fetch_penalty (default on); off restores
the previous planner behaviour.

The penalty's worst-case branch keys on the fetch cache's 32MB cap, hoisted to
columnar.h as COLUMNAR_FETCH_CACHE_MAX_BYTES so both sites name the same value.
That branch is the commandprompt#359 cliff, and should scale by the overflow fraction once
commandprompt#359 makes cache overflow proportional rather than total.

test/analyze_stats.sh gains a paired plan-choice section: the same unclustered
ORDER BY takes the index with the penalty off (the commandprompt#355 premise) and sorts with
it on, while a clustered ORDER BY and a point lookup stay on their index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
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.

Planner chooses an Index Scan on columnar for ordering; per-row fetch cost is not modelled and the plan is orders of magnitude slower

2 participants