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:650 — decoded_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:625 — decode_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.
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_penaltysizes the decode from the columns the scanemits. The deferred index-fetch slot decodes the attribute prefix
0..max-referenced. For a query whose referenced column sits late in the tuple thosetwo 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:
200x, and
rel->reltarget->widthis identical for both.Why the difference exists
columnar_slot_decode_upto(src/columnar_tableam.c) asks for a prefix, becausethat is what
slot_getsomeattrsgives it: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
textcolumns of ~208 B, 100,000 rows in one group):max(a1)id, a1max(a10)id, a1..a10They sit on opposite sides of the cap, and the model computes the same
decoded_widthfor 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:650—decoded_width = rel->reltarget->width * R, whichgates the cap-crossing branch. On
max(a10)it computes ~21 MB, stays under thecap, and the branch does not fire on a query that is 100% re-decoding.
src/columnar_customscan.c:625—decode_per_group = ... + cpu_operator_cost * R * nproj, wherecolumnar_scan_nprojisbms_num_membersof 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 onCOLUMNAR_FETCH_CACHE_MAX_BYTES / decoded_width, so an understated width now scalesthe penalty down smoothly and silently instead of failing loudly.
Fix direction
The machinery is already there.
columnar_scan_nprojcollects the referenced attnoswith
pull_varattnosoverreltarget->exprsplusbaserestrictinfo, and then throwsaway everything except the cardinality. Keeping the maximum member gives the
prefix directly:
nprojbecomes the prefix length — the count of attributes1..max, not the countof referenced ones;
decoded_widthbecomes the summed width of attributes1..maxrather thanreltarget->width.Widths for unreferenced attributes in the prefix are not in
reltarget, so they needget_attavgwidth(falling back toget_typavgwidth) the wayset_rel_widthdoes.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
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.