Summary
The index-fetch penalty I added in #360 changes what EXPLAIN prints but not which
plan is chosen, on the query it was written for. On cpu_pgc with its
(hostname, time DESC) index, the five-aggregate-column #359 query:
| arm |
plan |
estimated |
actual |
enable_index_fetch_penalty=on (default) |
Index Scan |
13,954,742 |
224,055 ms |
enable_index_fetch_penalty=off |
Index Scan |
5,090 |
224,946 ms |
penalty on, enable_indexscan=off |
Parallel Custom Scan (ColumnarScan) |
589,348 |
4,728 ms |
The penalty fires — it moves the estimate from 4,975 to 13,954,348, a factor of
2,800. The planner then chooses that path anyway, over an alternative it estimates
at 1/24th the cost and which runs 47x faster. So the catastrophic plan #360 was
supposed to prevent is still chosen by default, and #359's cliff is still reachable
through it.
Same result with max_parallel_workers_per_gather=0, so this is not about parallel
path generation.
Mechanism
ColumnarSetRelPathlist offers the columnar path to add_path before applying
the penalty:
add_path(rel, &cpath->path); /* columnar scan, ~587k+ */
...
/* #355 penalty: "This runs last, after every add_path above, on purpose" */
add_path frees a new path it judges dominated. At that moment the index path still
carries its un-penalized cost (4,975), so against a selective index condition the
columnar path is dominated on cost and is freed. The penalty then inflates the index
path — and there is no longer an alternative in rel->pathlist to switch to, which
is why set_cheapest returns a 13.9M path while a 589k path exists only once the
index path is disabled outright.
The hook's own comments already document this hazard for the seqscan path:
add_path frees a path it finds dominated, so the seqscan is gone from
rel->pathlist by the time this hook runs exactly when some index path beat it on
both cost and pathkeys -- which is to say, precisely on the selective lookups where
using the index matters most.
I reasoned about this in #360's body and got it half right. I wrote that applying the
penalty last was safe because "add_path's dominance test compares pairs directly
(order-independent); only its insertion position depends on the sort, and
set_cheapest rescans the whole list." That covers list ordering. It does not cover
path rejection: a path add_path has already freed cannot be reconsidered no matter
what set_cheapest does afterwards.
Why #360's tests did not catch it
test/analyze_stats.sh's #355 checks use an unclustered ORDER BY with no selective
qual. There the index path does not dominate the columnar path at add_path time, so
the columnar path survives and the penalty does flip the plan — which is exactly what
those checks assert, and they pass. The failing shape is the one with a selective
index condition, where the index path dominates early. The tests cover the case
where the mechanism works and not the case where it does not.
Impact
Fix direction
The penalty has to be visible to add_path's dominance test, not applied after it.
Options, roughly in order of how much I like them:
- Apply the penalty to the index/bitmap paths first, at hook entry, then add the
columnar path — so every add_path comparison sees final costs. The reason it was
ordered the other way was to avoid handing add_path an unsorted pathlist; that
is solvable by re-sorting rel->pathlist by total_cost after mutating and before
adding.
- Re-offer the columnar path after the mutation (keep a reference and
add_path it
again once costs are final).
- Stop mutating and instead cost the fetch penalty into the paths at creation, which
means a get_relation_info-time or costsize hook rather than this one.
Mine to fix — I introduced it in #360. Filing rather than pushing straight to a PR so
the approach can be argued first, since (1) changes the invariant the current ordering
was chosen to protect.
Reproduction
Bench, cpu_pgc (100M TSBS, (hostname, time DESC)), current main:
SET max_parallel_workers_per_gather = 4;
SET pgcolumnar.enable_index_fetch_penalty = on;
EXPLAIN SELECT date_trunc('minute',time) m, max(usage_user), max(usage_system),
max(usage_idle), max(usage_nice), max(usage_iowait)
FROM cpu_pgc
WHERE hostname = 'host_1'
AND time >= '2024-01-01' AND time < '2024-01-01' + interval '12 hours'
GROUP BY 1;
-- Index Scan, cost 13,954,348, runs 224 s
SET enable_indexscan = off; -- same query
-- Parallel Custom Scan (ColumnarScan), cost 589,348, runs 4.7 s
Related
A second, separable defect in the same function, which I will file or fold in
depending on how (1) lands: the model measures rel->reltarget->width — the width of
the columns emitted — while the deferred index-fetch slot decodes the attribute
prefix 0..max-referenced. Measured on a ten-text-column table, same 300 fetched
rows, same emitted width, same plan, varying only which column is referenced:
max(a1): 975 ms
max(a10): 194,798 ms
200x, invisible to reltarget->width. So the cap-crossing branch keys on a width that
can understate the decoded bytes by the ratio of prefix to projection.
Summary
The index-fetch penalty I added in #360 changes what
EXPLAINprints but not whichplan is chosen, on the query it was written for. On
cpu_pgcwith its(hostname, time DESC)index, the five-aggregate-column #359 query:enable_index_fetch_penalty=on(default)enable_index_fetch_penalty=offenable_indexscan=offThe penalty fires — it moves the estimate from 4,975 to 13,954,348, a factor of
2,800. The planner then chooses that path anyway, over an alternative it estimates
at 1/24th the cost and which runs 47x faster. So the catastrophic plan #360 was
supposed to prevent is still chosen by default, and #359's cliff is still reachable
through it.
Same result with
max_parallel_workers_per_gather=0, so this is not about parallelpath generation.
Mechanism
ColumnarSetRelPathlistoffers the columnar path toadd_pathbefore applyingthe penalty:
add_pathfrees a new path it judges dominated. At that moment the index path stillcarries its un-penalized cost (4,975), so against a selective index condition the
columnar path is dominated on cost and is freed. The penalty then inflates the index
path — and there is no longer an alternative in
rel->pathlistto switch to, whichis why
set_cheapestreturns a 13.9M path while a 589k path exists only once theindex path is disabled outright.
The hook's own comments already document this hazard for the seqscan path:
I reasoned about this in #360's body and got it half right. I wrote that applying the
penalty last was safe because "
add_path's dominance test compares pairs directly(order-independent); only its insertion position depends on the sort, and
set_cheapestrescans the whole list." That covers list ordering. It does not coverpath rejection: a path
add_pathhas already freed cannot be reconsidered no matterwhat
set_cheapestdoes afterwards.Why #360's tests did not catch it
test/analyze_stats.sh's #355 checks use an unclusteredORDER BYwith no selectivequal. There the index path does not dominate the columnar path at
add_pathtime, sothe columnar path survives and the penalty does flip the plan — which is exactly what
those checks assert, and they pass. The failing shape is the one with a selective
index condition, where the index path dominates early. The tests cover the case
where the mechanism works and not the case where it does not.
Impact
pgcolumnar.enable_index_fetch_penaltyis on by default, so this is the defaultbehaviour.
degrade gracefully, which reduces the damage, but the planner is still choosing a
plan it prices at 24x the alternative.
ORDER BY→ Sort") is true forthe tested shape and does not generalise.
Fix direction
The penalty has to be visible to
add_path's dominance test, not applied after it.Options, roughly in order of how much I like them:
columnar path — so every
add_pathcomparison sees final costs. The reason it wasordered the other way was to avoid handing
add_pathan unsortedpathlist; thatis solvable by re-sorting
rel->pathlistbytotal_costafter mutating and beforeadding.
add_pathitagain once costs are final).
means a
get_relation_info-time orcostsizehook rather than this one.Mine to fix — I introduced it in #360. Filing rather than pushing straight to a PR so
the approach can be argued first, since (1) changes the invariant the current ordering
was chosen to protect.
Reproduction
Bench,
cpu_pgc(100M TSBS,(hostname, time DESC)), current main:Related
A second, separable defect in the same function, which I will file or fold in
depending on how (1) lands: the model measures
rel->reltarget->width— the width ofthe columns emitted — while the deferred index-fetch slot decodes the attribute
prefix
0..max-referenced. Measured on a ten-text-column table, same 300 fetchedrows, same emitted width, same plan, varying only which column is referenced:
200x, invisible to
reltarget->width. So the cap-crossing branch keys on a width thatcan understate the decoded bytes by the ratio of prefix to projection.