Review: read only the columns a query references (#338) [already merged as #339] - #340
Review: read only the columns a query references (#338) [already merged as #339]#340ChronicallyJD wants to merge 1 commit into
Conversation
The reader read and decoded every column of every row group it visited, regardless of how few columns the query needed. The projection was computed by columnar_projected_columns, threaded down through ColumnarBeginRead, copied into the read state, and then never read. Measured on a 12-column, 4M-row, 351 MB table: sum(a) touched 45,094 of the relation's 44,962 buffers, and so did a sum over all twelve. One column cost the same I/O as twelve. columnar_native_load_group now fetches the chunk metadata before the data, and reads only the byte ranges the projected columns occupy, coalescing ranges that are adjacent in the file. Chunks are written column-major, so a projection is a small number of contiguous runs. The decode loop skips unwanted chunks as well; that cost as much as the I/O. Reads with no projection -- vacuum, parquet export, arrow, the tableam seqscan -- keep the whole-group path unchanged. On the TSBS shape from #289, a filtered aggregate over one metric column goes from 38,288 buffers / 1343 ms to 4,462 buffers / 226 ms: 8.6x less I/O and 5.9x faster, with identical results. Two things fell out of this that are not the happy path: A corrupt row_group.byte_length stopped being detected, because a projected read no longer uses that length to size anything. Rather than weaken corruption.sh, the reader now checks the invariant directly -- chunks must exactly tile their group -- which was verified across plain inserts, ADD COLUMN, stored generated columns, updates and deletes, compact, vacuum_sorted, block-compressed columns, and VACUUM FULL before being relied on. It names the inconsistency instead of surfacing as a short read. cancel_decode's scan filtered on one column of eight and no longer ran long enough to cancel; its own premise check caught that. Its query now names every column, so it again decodes the whole relation as intended. pgcolumnar.enable_column_projection (default on) is both the escape hatch and the A/B oracle the new suite compares against. test/column_projection.sh, 35 checks: projection on equals projection off and equals a heap oracle across qual-only columns, varlena, all-NULL columns, whole-row Vars, ctid, ADD COLUMN with a default across the group boundary, deletes, updates, and parallel scan; plus a buffer-count assertion that one column costs less than a third of reading all of them. Removal proofs: forcing allColumnsWanted true fails the buffer assertion (on 2517, off 2517); disabling the tiling check fails corruption.sh's byte_length assertion. Removing the explicit-NULL guard for unwanted columns fails nothing -- it is defensive, not load-bearing, and is documented as such rather than implied to be proven. Full 15-19 matrix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
CI status on this PR: one red, and it is not this change
I need to correct something I said when I merged #339. I reported that CI as 11/11 green. That reading was true of run Why I do not think it is this change, and what would prove me wrong
That is an argument, not a proof, and it is being checked rather than assumed. What is established so far
So it is currently unresolved whether #342 is a real defect in the extent watermark or a race in my test. I am not guessing, and I would rather this PR sit with a visible red than have me relabel a red as noise. Reviewing the projection change itself is unaffected -- its own suites are green and its behavior is independent of the recluster path. |
What
Closes #338. The reader read and decoded every column of every row group it visited, regardless of how few columns the query needed. The projection was computed correctly by
columnar_projected_columns, threaded down throughColumnarBeginRead, copied into the read state atcolumnar_reader.c:334— and then never read again. That assignment was the field's only occurrence.Measured
The TSBS shape from #289, 12 columns, 4M rows, 294 MB. Same data, same build,
enable_column_projectionoff vs on:8.6x less I/O, 5.9x faster, results identical (
401054|95.0028955075411both ways).What it looked like before, on a 12-column 351 MB table (44,962 buffers):
count(*)sum(a)count(*), avg(b) WHERE a>90One column cost the same bytes as twelve. Cross-checked against width: the identical two-column query read 100% of a 2-column table (7,620 buffers) and 100% of a 12-column one (45,097).
max()alone stays at 300 buffers either way — that is the zone-map fast path, correctly untouched.How
columnar_native_load_groupnow fetches the chunk metadata before the data read (it is a catalog read and touches no data pages), then reads only the byte ranges the projected columns occupy, coalescing ranges that are file-adjacent. Chunks are written column-major, so a projection is a small number of contiguous runs rather thannattsscattered reads. The group buffer is still allocated at full size so the existingbase = nativeBuffer + (pageOffset - fileOffset)arithmetic is unchanged; palloc does not touch the pages it returns, so unread regions cost no resident memory.The decode loop skips unwanted chunks too — that mattered as much as the I/O, since it previously ran the full decode over every column.
Blast radius is small by construction: a NULL projection means "all columns", and that is what every caller outside the custom scan and the two vectorized-aggregate paths passes — vacuum, parquet export, arrow, the tableam seqscan all keep the whole-group path byte for byte.
pgcolumnar.enable_column_projection(default on) is both the operational escape hatch and the A/B oracle the tests compare against.Two things that fell out, neither of them the happy path
A corrupt
row_group.byte_lengthstopped being detected.corruption.shinflates it and requires a clean error; the whole-group read produced one incidentally, by reading a length that ran past the end of the relation. A projected read never uses that length to size anything, so the corruption went unnoticed.Weakening the corruption test to match the new path would have been the wrong fix. Instead the reader now checks the invariant directly: chunks must exactly tile their group, no gap at the start and none at the end. That was verified to hold before being relied on — across plain inserts, ADD COLUMN, stored generated columns, updates and deletes,
compact,vacuum_sorted, block-compressed columns, andVACUUM FULL. It is the better check anyway: it names the inconsistency rather than surfacing as a short read. Enforced on the projected path only, soenable_column_projection=offstays a way back.cancel_decodebroke, and was right to. Its scan filtered on one column of eight, so with projection it no longer ran long enough to cancel — its own premise check caught it. The query now names every column, so it again decodes the whole relation as the test intends, and keeps testing cancellation on the default path instead of being pinned to the old behavior.Tests
test/column_projection.sh, 35 checks. Projection on equals projection off, and equals a heap oracle holding identical data, across: single and multi column targetlists, a qual on a column absent from the targetlist,count(*),SELECT *, whole-row Var,ctid, varlena, an all-NULL column, min/max over mixed types, a join, deletes, updates, and parallel scan.The ADD COLUMN case is covered specifically, on both sides of the boundary: an unmaterialised column and a column predating an ADD COLUMN both leave the validity pointer NULL but mean different things, and conflating them would hand back a default in place of stored data.
The win is asserted as a buffer count, not a timing — exact, stable on a shared runner, and it fails loudly if projection silently stops applying.
Removal proofs
allColumnsWantedtrue fails the buffer assertion:on: 2517, off: 2517.corruption.sh'sbyte_lengthassertion.One discarded proof worth naming: flipping the GUC's default proves nothing, because the suite sets the GUC explicitly on both arms. The default never reaches the code under test.
Gate
Full 15-19 matrix.
Design note:
design/COLUMN_PROJECTION.md.🤖 Generated with Claude Code
https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8