Skip to content

The #355 index-fetch penalty changes the estimate but not the plan: the cheaper path is already freed when it is applied #362

Description

@ChronicallyJD

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:

  1. 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.
  2. Re-offer the columnar path after the mutation (keep a reference and add_path it
    again once costs are final).
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions