Skip to content

The index-fetch cost model sizes the decode from the emitted columns, but the fetch decodes the attribute prefix: 200x invisible to reltarget->width #363

Description

@ChronicallyJD

Split out of #362 at jdatcmd's request. It survives the path-ordering fix there: no
amount of reordering makes the model see a width it never computes.

Summary

columnar_index_fetch_penalty sizes the decode from the columns the scan
emits. The deferred index-fetch slot decodes the attribute prefix
0..max-referenced. For a query whose referenced column sits late in the tuple those
two differ by the whole span of columns in between, and the model cannot see it.

Two queries, same table, same 300 fetched rows, same plan, same emitted width,
differing only in which column is referenced:

max(a1):       975 ms
max(a10):  194,798 ms

200x, and rel->reltarget->width is identical for both.

Why the difference exists

columnar_slot_decode_upto (src/columnar_tableam.c) asks for a prefix, because
that is what slot_getsomeattrs gives it:

for (i = 0; i < natts; i++)
    needed = bms_add_member(needed, i);

so referencing attribute 10 decodes attributes 1..10, not attribute 10. The slot
comment says as much — "A query reading column 2 of 41 decodes two columns, not
forty-one" — which is the saving; the cost is that reading column 41 decodes
forty-one.

On the fixture above (10 text columns of ~208 B, 100,000 rows in one group):

query decoded prefix decoded bytes vs the 32 MB cap
max(a1) id, a1 ~21.6 MB under — cached, one decode
max(a10) id, a1..a10 ~208 MB over — re-decoded per fetch

They sit on opposite sides of the cap, and the model computes the same
decoded_width for both.

Where it bites in the model

Both of the model's notions of "the projection" are the emitted set, and both
understate for a late column:

  • src/columnar_customscan.c:650decoded_width = rel->reltarget->width * R, which
    gates the cap-crossing branch. On max(a10) it computes ~21 MB, stays under the
    cap, and the branch does not fire on a query that is 100% re-decoding.
  • src/columnar_customscan.c:625decode_per_group = ... + cpu_operator_cost * R * nproj, where columnar_scan_nproj is bms_num_members of the referenced attnos.
    max(a10) decodes ten columns and is charged for one.

Under the pre-#361 branch the understatement was partly masked, since crossing the cap
snapped straight to groups_max. #361 replaced that snap with a proportional blend on
COLUMNAR_FETCH_CACHE_MAX_BYTES / decoded_width, so an understated width now scales
the penalty down smoothly and silently instead of failing loudly.

Fix direction

The machinery is already there. columnar_scan_nproj collects the referenced attnos
with pull_varattnos over reltarget->exprs plus baserestrictinfo, and then throws
away everything except the cardinality. Keeping the maximum member gives the
prefix directly:

  • nproj becomes the prefix length — the count of attributes 1..max, not the count
    of referenced ones;
  • decoded_width becomes the summed width of attributes 1..max rather than
    reltarget->width.

Widths for unreferenced attributes in the prefix are not in reltarget, so they need
get_attavgwidth (falling back to get_typavgwidth) the way set_rel_width does.

Worth deciding deliberately: an index-only scan decodes nothing and is already
exempt, and a bitmap heap scan reaches the same deferred slot, so it wants the same
prefix treatment.

Test

Proven by removal, per the pattern jdatcmd asked for on #362: a fixture with a wide
gap between the first and last referenced attribute, asserting the plan flips for the
late-column query, and confirming the check fails when the prefix widening is
reverted. A count-based check is not enough on its own here — the two queries differ
in plan only once the width is right, so the assertion has to be on plan shape.

Reproduction

CREATE TABLE pfx (id bigint, a1 text, a2 text, a3 text, a4 text, a5 text,
                  a6 text, a7 text, a8 text, a9 text, a10 text) USING pgcolumnar;
INSERT INTO pfx SELECT g, repeat('x',200)||g, repeat('x',200)||g, repeat('x',200)||g,
  repeat('x',200)||g, repeat('x',200)||g, repeat('x',200)||g, repeat('x',200)||g,
  repeat('x',200)||g, repeat('x',200)||g, repeat('x',200)||g
FROM generate_series(1,100000) g;
CREATE INDEX pfx_id ON pfx (id);
ANALYZE pfx;

SET enable_seqscan=off; SET enable_bitmapscan=off;
SET pgcolumnar.enable_custom_scan=off; SET max_parallel_workers_per_gather=0;

EXPLAIN (ANALYZE) SELECT max(a1)  FROM pfx WHERE id BETWEEN 1 AND 300;  --    975 ms
EXPLAIN (ANALYZE) SELECT max(a10) FROM pfx WHERE id BETWEEN 1 AND 300;  -- 194,798 ms

PG18, -O2, container, one run at a time on an idle box. Both take the index scan.

Provenance

Found while reviewing #361, chasing why the penalty did not fire on the #359 query.
The related-but-separate path-freeing defect is #362; this one is mine too, from #360.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions