Skip to content

A projection is priced by clauses that merely mention its sort key, not by clauses it can prune on #1126

Description

@OffgridwithJD

Found while re-reviewing #1107, which fixes the wider form of this. Not a blocker for
that PR
— it narrows this defect rather than introducing it, and the plan choice is the
same before and after. Filing so the remainder is not lost.

The rule the planner uses, and the rule the executor uses

Both eligibility and pricing ask the same question: does this clause mention
sortKey[0]?

/* eligibility */
skips = (p->sortKeyLen > 0 &&
         bms_is_member(p->sortKey[0] - FirstLowInvalidHeapAttributeNumber, restrictCols));

/* pricing, after #1107 */
pull_varattnos((Node *) ri->clause, rel->relid, &cols);
if (bms_is_member(sortAttno - FirstLowInvalidHeapAttributeNumber, cols))
    clauses = lappend(clauses, ri);

Mentioning the sort key is not the same as being prunable by it. A single
RestrictInfo that ORs a sort-key range with a predicate on another column mentions the
sort key, is therefore counted whole, and contributes a selectivity the sort order cannot
deliver.

The executor already makes the distinction the planner does not. It reports
Columnar Usable Skip Predicates, and that counter is exactly the difference between the
two cases below.

Measured

pgcolumnar-audit, /usr/local/pg18a (assert), PR #1107 at b15fb8a. 20,000 rows,
stripe_row_limit => 1000, chunk_group_row_limit => 500, physical order scrambled so
the base scan cannot prune. Projection onsk over (sk, kind) sorted on sk.
kind = 'odd' where sk % 1000 = 0, so its 20 rows are spread across the whole sk
domain.

control   WHERE sk BETWEEN 1 AND 2000                     -- 2000 rows, contiguous
residual  WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd'      -- 2018 rows, 18 of them scattered

Both are priced the same:

control residual
proj run cost 36.83 50.85
base run cost 368.67 504.50
ratio 0.0999 0.1008

The runtime is not the same. From EXPLAIN (ANALYZE, BUFFERS) on the residual query:

                                   projection ON    projection OFF
Columnar Pushed-Down Filters:            0                0
Columnar Usable Skip Predicates:         0                0
Columnar Vectors Skipped:                0                0
Columnar Chunk Groups Read:             20               20   (of 20)
Columnar Vector Decodes:               120               80
Execution Time:                      2.653 ms         1.996 ms

The projection prunes nothing — no pushed-down filter, no usable skip predicate, no
zone-map probe — reads every chunk group, decodes 50% more vectors, and finishes 33%
slower. It is quoted at 10% of the base.

The control, for contrast, earns its discount: Usable Skip Predicates: 2,
Vectors Skipped: 20, and Rows Removed by Filter falls from 18000 to 8000.

Why it is not a regression

Before #1107 the constant 0.5 priced this query at half the base, which also beats the
base and is also chosen. So the planner picks the projection for this shape either way;
#1107 changes how confidently. I am recording that explicitly so the entry is not read as
something that PR broke.

The shape of a fix

Key the test on usability as a skip predicate on the sort key, not on the sort key being
mentioned — the property the executor already computes and prints. A clause that is an OR
with a non-sort-key disjunct is not usable, and should contribute selectivity 1.0 to the
projection's price even though it mentions the key.

A guard arm belongs with it, because both wrong versions of this rule are silent: an
over-inclusive one quotes a discount nothing delivers, and an under-inclusive one declines
a projection that would have won. The existing suite's prsk fixture already has the two
columns needed; this needs one more query shape.

Reproduction

test/projection_scan_cost.sh's fixture with one query added. I ran it as a standalone
probe rather than a suite edit, since the suite belongs to #1107 while that is open.

CREATE TABLE pror (sk int, kind text) USING pgcolumnar;
SELECT pgcolumnar.set_options('pror', stripe_row_limit => 1000, chunk_group_row_limit => 500);
INSERT INTO pror SELECT sk, CASE WHEN sk % 1000 = 0 THEN 'odd' ELSE 'usual' END
  FROM generate_series(1, 20000) sk ORDER BY md5(sk::text);
SELECT pgcolumnar.add_projection('pror', 'onsk', ARRAY['sk','kind'], ARRAY['sk']);
ANALYZE pror;

SET max_parallel_workers_per_gather = 0; SET jit = off;
SET pgcolumnar.enable_ungrouped_vector_agg = off;
SET pgcolumnar.enable_group_vectorization = off;
EXPLAIN (ANALYZE, BUFFERS)
  SELECT sk FROM pror WHERE sk BETWEEN 1 AND 2000 OR kind = 'odd';

One note on the fixture, because my first version of it was vacuous: with the range at
sk BETWEEN 1 AND 100 both arms land on the one-stripe floor (20 stripes, so 0.05) and
the two cases are indistinguishable. The sort-key range has to be well above the floor for
the question to be askable at all.

🤖 Generated with Claude Code

https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions