Cost a columnar index scan's per-row heap fetch (#355) - #360
Merged
jdatcmd merged 1 commit intoAug 3, 2026
Conversation
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
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Closes #355. The planner took an index scan on a columnar table to satisfy an
ORDER BYwithout modelling what the ordering costs here: each row is fetched bynumber, 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.
ColumnarSetRelPathlistnow addscolumnar_index_fetch_penaltyto thetotal_costof the surviving heap-fetching index and bitmap paths. The penalty isthe 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 toone group per row. It interpolates between the two on the square of the leading-key
correlation, read from
pg_statisticthe waybtcostestimatereads it.Behaviour
ORDER BY scatORDER BY idWHERE id = ?(#171)Gated behind
pgcolumnar.enable_index_fetch_penalty(default on); off restores theprevious planner behaviour.
Design decisions worth a reviewer's eye
total_costonly, neverstartup_cost— aLIMITthat stops the scan earlypays the penalty proportionally (the planner fractions
total - startup).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_infoisNULL.add_pathin the hook, on purpose. It mutatestotal_costin place, which unsortsrel->pathlist, and noadd_pathmay see anunsorted 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.direction), so a missing
ANALYZEis pessimistic rather than a silent under-costthat 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.hasCOLUMNAR_FETCH_CACHE_MAX_BYTESso 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.shgains a paired plan-choice section: the same unclusteredORDER BYtakes the index with the penalty off (the #355 premise — so the testproves there was something to prevent) and sorts with it on, while a clustered
ORDER BYand a point lookup stay on their index. The checks are plan-shape, nottiming, so they are stable under matrix contention.
Gate
Assert builds,
pgcolumnar.auditcontainer.analyze_stats(see below). Includesnative_index,native_ios,index_only,native_fetch_cache,column_projection,harness_selftest,docs_style.warnings on all three; all four new 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 checks PASS on all three;
native_index/native_ios/index_onlyPASS.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_statscheck, including the fourthis PR adds, passes. The check will go green when #359 makes cache overflow
proportional.
🤖 Generated with Claude Code