feat: read only the columns a query references (#338) - #339
Merged
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
Collaborator
Author
|
Local 15-19 matrix on this head: Worth noting how the two suites this touched behaved, since both started red and neither was silenced:
I also want to be straight about one of the three removal proofs: removing the explicit-NULL guard for unprojected columns fails nothing. It is defensive, not load-bearing, and the PR says so rather than implying it was proven. |
This was referenced Aug 2, 2026
Closed
jdatcmd
added a commit
that referenced
this pull request
Aug 3, 2026
Parallel-aware ungrouped vectorized batch fold. Verified independently: the construct_array portability fix on PG15/16/17 where it was broken, full suite 16/16 on all five majors including the in-transaction delete (H2) case, and 1.93x over core parallel agg on current main (the PR body's 1.49x was measured against a pre-#339 baseline that read every column). Composed with projection and the serial fold: 1951.6 ms -> 39.5 ms at 8M rows, count exact against a heap oracle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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