Let a fetch by row number ask for only the columns it needs (part of #157)#164
Conversation
…y changed
main does not build on PostgreSQL 17:
src/columnar_tableam.c: In function 'columnar_scan_analyze_next_block':
error: 'blockno' undeclared (first use in this function)
scan_analyze_next_block took (BlockNumber, BufferAccessStrategy) through PG16
and (ReadStream *) from PG17, which is the read-stream ANALYZE rework.
columnar_compat.h has always split COLUMNAR_ANALYZE_NEXT_BLOCK_ARGS at 170000
and says so in its comment. jdatcmd#159 guarded the callback body at 180000 instead, so
PG17 compiled the pre-17 branch against a PG17 signature and referenced a
parameter it does not have.
PG15 and PG16 build because they really are pre-17, and PG18 and PG19 build
because they are past both guards. PG17 is the only major the mismatch can
show up on, which is why a PG18-and-PG19 gate reported the change green: I
approved it on that gate, and the full matrix is what caught this.
The two guards now agree with the compat macro. Builds clean with no warnings
on 15, 16, 17, 18 and 19.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uKWWwBDt5TWWS5DR2tzDb
Fetching a row decoded every column of its row group whatever the caller wanted. Two entry points join it: ColumnarReadRowByNumberCols(..., Bitmapset *needed) decodes only those columns ColumnarRowIsLive(...) decodes nothing at all Both are wrappers over one worker; the existing entry point is a third, passing "every column", so no caller had to change to keep working. Three callers were asking for far more than they used: - columnar_index_delete_tuples reconstructed every column of a row to compute a boolean, then freed the values unread, once per candidate index tuple on a path nbtree drives during deletion. It now calls ColumnarRowIsLive. - read_projection fetched the whole base row for its visibility alone; every value it emits comes from the projection's own storage. Also ColumnarRowIsLive. - reconstruct_via_projection reads the base row only for columns the projection does not carry, so it now asks for exactly those. A Bitmapset cannot distinguish empty from NULL, so a computed set that comes out empty asks for every column rather than none. That is the safe direction and it is documented at both declarations; a caller wanting none wants ColumnarRowIsLive. This does NOT fix the cliff in jdatcmd#157. That one is on the index-scan path, and the table AM gives scan_analyze/index_fetch_tuple no projection to work from -- the executor never tells the access method which columns it will read. See the PR. Measured on assert-enabled 18.4 at -O2, 41 columns: read_projection, 100,000 rows 339.0 ms -> 284.4 ms Full suite on the assert build: 76 pass, 0 fail.
The projected fetch took a Bitmapset where NULL meant "every column". A Bitmapset does not distinguish empty from NULL -- an empty one *is* NULL -- so a caller that computed its set and found nothing in it silently asked for every column instead of none. No assertion can catch that, because the two cases are the same value. The first version of this branch documented the hazard at both declarations and called it the safe direction. That is not a resolution: a comment warning a reader about behaviour we control is a record of having left it in place. The ambiguity is now gone rather than annotated. The worker takes an explicit `bool allColumns`. "Every column" is ColumnarReadRowByNumber, which takes no set at all and cannot be misread; ColumnarReadRowByNumberCols decodes exactly what it is given, and an empty set decodes nothing, which is what it says. This is a live behaviour change, not only a tidy-up. reconstruct_via_projection computes its set, and when a projection covers every column that set is empty -- so it used to decode all of them and now decodes none. Both are correct, because every value comes from the projection either way, but only one of them is what the code meant. That path is now covered: an all-covering projection is built and its reconstructed values checked against the base table. Two source checks keep the convention from coming back, and the mutation that first exposed this -- emptying the computed set -- now fails loudly instead of being a no-op. Full suite on the -O2 assert build: 77 pass, 0 fail.
|
Fixed the hazard in The first push described the It is a live behaviour change, not a tidy-up, and that is the part worth checking rather than asserting. Two source checks keep the convention from returning, and the mutation that first exposed this — emptying the computed set — now fails loudly where before it was a no-op. That no-op is how I found the problem: a mutation that changes nothing is itself a finding. Full suite on the The rest of the PR description stands, including the part that matters most: this still does not fix the cliff in #157, because the index-scan path gets no projection from the executor. I would keep that issue open for the lazily-decoding slot. |
jdatcmd
left a comment
There was a problem hiding this comment.
Correct, well scoped, and right to say it does not close #157. Merging, and
leaving #157 open as you propose.
I verified the cliff, and it is worse than your figures
Same shape, my box, 50,000 rows of 40-character text columns, 2,000 index
fetches reading one column:
| columns | main | this branch |
|---|---|---|
| 3 | 47.1 ms | 44.1 ms |
| 11 | 283,406 ms | 271,058 ms |
| 41 | 1,014,445 ms | 986,126 ms |
Seventeen minutes for two thousand index fetches on a 41-column table. That is a
21,500x cliff between 3 columns and 41, and your patch moves it by about 3%,
which is what you said it would do.
Two things follow. Your reading of the cause is right: this is the cache entry
exceeding its cap and being dropped after every fetch, so the group is re-read
and re-decoded per row, and skipping columns at the worker cannot help a caller
that cannot say which columns it wants. And #157 deserves to stay open at a
higher priority than its current framing suggests, because a wide columnar table
with an index is effectively unusable for index-driven access, not merely slow.
The patch itself
The three conversions are each a strict reduction in work for the same answer.
read_projection is the one I would have missed too: every value it emits comes
from the projection's own storage, so the base fetch existed purely to ask
whether the row was visible, and basevals was written and never read.
ColumnarRowIsLive reduces to exactly the old predicate, since both go through
the same worker and the old caller's live was precisely
ColumnarReadRowByNumber returning true. No semantic change on the nbtree
deletion path, which is the one place a wrong answer here would be expensive.
Setting skipped columns to null rather than leaving whatever the array held is
the right call, and the reason to prefer it over leaving them undefined is worth
the comment you gave it.
On the Bitmapset hazard
Documenting that an empty set means "everything" rather than "nothing" is the
honest way to handle a representation that cannot tell those apart, and it fails
in the safe direction. Pointing the reader at ColumnarRowIsLive for the case
that genuinely wants nothing is the right escape hatch.
One thing about the measurement table
The three index-fetch rows in the description are identical to the tenth of a
millisecond across both columns. If that is one measurement reported twice
because the code path is untouched, say so; it reads as two runs that landed on
the same number, which would be surprising. It does not change the conclusion,
which my own numbers above support independently.
The lazily-decoding slot
The shape is right, and #159 already established that this AM can supply its own
slot ops, which is the hard prerequisite. Worth its own PR and worth doing: it
fixes the cliff for every read path without any caller declaring anything, and
the executor's access pattern becomes the projection for free. The interaction
with the copy_heap_tuple override is the part to work out first, since that
override exists to carry tts_tid and a lazily-materialising slot has to keep
that working.
Gate
Build preflight, all five majors, zero warnings. Full suite on PG18 and PG19,
ALL VERSIONS PASSED, with native_fetch_projection, projections,
native_index and native_dml green. Registered in the matrix without needing
to be asked, which is the first PR in this run where that was true.
Towards #157, and I do not think it closes it. The API is here and the callers that can use it are converted, but the cliff that issue leads with is on a path this cannot reach. That is the interesting finding and it is worth more than the patch.
What is here
Both wrap one worker; the existing
ColumnarReadRowByNumberis a third wrapper passing "every column", so nothing had to change to keep working.Three callers were asking for far more than they used:
columnar_index_delete_tuplespfreed unreadColumnarRowIsLiveread_projectionColumnarRowIsLivereconstruct_via_projectionThe first is the one from the issue: it reconstructed a whole row to produce a boolean, once per candidate index tuple, on a path nbtree drives during deletion.
The second I had not spotted when I filed #157.
read_projectionfetched the base row purely to ask whether it was visible — every value it emits comes from the projection's own storage.basevalswas written and never read.What it does not fix, and why
The headline in #157 is an index scan on a wide table: 2,000 fetches reading one column cost 10.8 ms at 3 columns and 225,929 ms at 41, because decoding every column pushes the cache entry past its size cap and the entry is then dropped after every fetch.
This patch does not move that, and I measured it rather than assuming:
The reason is structural:
index_fetch_tuplereceives(scan, tid, snapshot, slot)and nothing else. The executor never tells the access method which columns it will read — it fills the whole slot and projects afterwards. There is no projection to pass, so the entry point that accepts one cannot be used there.I looked for a channel and did not find one.
IndexFetchTableDatacarries no target list, there is no analogue of the custom scan'sprojectedColumnson the index path, and PG18'sTableAmRoutinehas no hook that would supply it.What would fix it
A lazily-decoding slot.
#159established that this AM can supply its ownTupleTableSlotOps, andgetsomeattrsexists precisely so a slot can materialise attributes on demand. A slot that decoded column c on first access, rather than the fetch filling all of them eagerly, would fix the cliff for every read path without needing anyone to declare a projection — the executor's access pattern would be the projection.That is a real design change: it needs a slot struct carrying the fetch context, and it interacts with the
copy_heap_tupleoverride from #159. It wants its own PR and probably its own discussion, so I have not started it. Happy to, if you think the shape is right.I would leave #157 open for that, and treat this as the part of option D the current API allows.
Measured
Assert-enabled 18.4 built at
-O2, 41 columns:read_projection, 100,000 rowsModest, and honestly so: #148's decoded-group cache already amortises the decode across a group, so skipping columns saves the decode once per group rather than once per row. The structural win in
columnar_index_delete_tuplesI could not isolate into a reliable figure — nbtree calls it only under specific deletion conditions — so I am not quoting one.A hazard worth naming
A
Bitmapsetcannot distinguish empty fromNULL; an empty one isNULL. So a caller whose computed set comes out empty asks for every column rather than none. That is the safe direction, and it is documented at both declarations, with a pointer toColumnarRowIsLivefor the caller that genuinely wants nothing.I found this the honest way: my first mutation test set the projection to empty expecting the suite to fail, and it passed, because the mutation was a no-op.
Tests
New suite
test/native_fetch_projection.sh, 9 checks, registered inrun_all_versions.sh.Neither new entry point has a SQL surface, so it exercises them through the two projection functions that use them, with the base table as the oracle — so the check does not share the code under test.
Proven by mutation. Dropping one column from the computed set:
That second line is the failure mode this change could introduce: a column that is not decoded reads as null, silently, with nothing raising.
Gate
Full suite on the
-O2assert build: 76 pass, 0 fail.Not run on 17. Given #163 I am now building all five majors for anything touching a version guard; this touches none, but say the word and I will build them anyway.