Decode a fetched row's columns when the executor asks for them (closes #157)#169
Conversation
An index fetch reconstructed every column of the row before returning it, because a virtual slot holds values and nothing else. On a wide table the decoded row group exceeds the fetch cache's size cap, so the entry is dropped after every fetch and each row re-reads and re-decodes the whole group. Issue jdatcmd#157 measured about seventeen minutes for 2,000 fetches of one column from a 41-column table. jdatcmd#164 added an entry point that takes a column set, but it could not reach this path: index_fetch_tuple receives (scan, tid, snapshot, slot) and the executor never says which columns it will read. It does say, though, just later and by asking. slot_getsomeattrs requests the smallest prefix the plan needs, so the access method supplies its own slot that holds the row's address instead of its values and decodes on demand. Visibility is still settled eagerly, by ColumnarRowIsLive, which decodes nothing, so a fetch still returns false for an invisible row without reading the group. The slot derives from VirtualTupleTableSlot rather than TupleTableSlot, and that is load-bearing: every callback inherited from TTSOpsVirtual casts to its own type and uses the `data` pointer that follows the base. Deriving from TupleTableSlot puts the first added field exactly where `data` belongs, so materialising a slot would scribble on it and clearing one would free it. A StaticAssertDecl pins the layout. Everything that needs a whole row -- materialize, copyslot, copy_heap_tuple, copy_minimal_tuple -- finishes the decode first. Everything that is not an index fetch still stores eagerly through ExecStoreVirtualTuple, which sets tts_nvalid to the full count, so getsomeattrs is never reached for those. copy_minimal_tuple gained a trailing Size argument in PG18, so the wrapper goes through a compat macro beside the others rather than a bare version guard. Measured on assert-enabled 18.4 at -O2, 2,000 index fetches reading one column: before after 3 columns 17.5 ms 18.3 ms 11 columns 64,859 ms 45.3 ms 41 columns 227,062 ms 122.6 ms
Two defects in the first draft of this branch, both found by running the full suite rather than the new one. columnar_slot_force_full cast whatever it was given to ColumnarSlot. copyslot takes a source slot of any type -- the executor copies an ordinary virtual slot into a columnar one on every INSERT -- so it read the deferred flag from past the end of a shorter slot and then dereferenced the relation pointer behind it. A plain "CREATE TABLE t (id int) USING pgcolumnar; INSERT INTO t SELECT ..." segfaulted the backend. It now returns immediately for any slot whose ops are not ours. The added fields were also never initialised. MakeTupleTableSlot does not know they exist, so a slot that never carries a deferred fetch still has them read; an init callback clears them. Eight suites failed on the first full run and none of them was the new one -- audit, concurrency, phase2, phase3, phase5 among them. The suite for this change passed throughout, because it exercises the path the change is about and the defect was in the slot type the change installs for everything. Also rescopes a check in native_fetch_projection. It counted occurrences of a call string across the whole file, and the index fetch legitimately became a second caller, so a correct change failed it. It now extracts columnar_index_delete_tuples and asserts what that function does.
# Conflicts: # test/run_all_versions.sh
|
Re-gated on the right cadence, after your note on #167. You were right that I had been gating 17.6 and 18.4 where the standard is PG18 and PG19 for suites plus Merged current The build preflight keeps 17 because it costs a minute and it is the major that has actually broken; the suites now match your cadence. That is three assert servers in the container (17.6, 18.4, 19beta2, all Nothing else changed since the description — the measurement, the two defects the full suite found in my first draft, and the fixture that had to be resized before the timing check discriminated all stand as written. |
jdatcmd
left a comment
There was a problem hiding this comment.
This closes the cliff, and I measured it rather than taking the table. Merging.
Verified independently
Same fixture I used on #164 and #157: 50,000 rows, 40-character text columns,
2,000 index fetches reading one column.
| columns | main | this branch | |
|---|---|---|---|
| 3 | 47.8 ms | 48.9 ms | unchanged |
| 11 | 284,148 ms | 159 ms | 1,786x |
| 41 | 1,001,374 ms | 614 ms | 1,630x |
Sixteen minutes to six tenths of a second, and a narrow table pays nothing for
it. The class really does change: a 41-column table is now 13x a 3-column one
instead of 21,000x.
The two defects you found on yourself
Both worth having in the record, and the first is the more useful.
columnar_slot_force_full casting any slot to ColumnarSlot segfaulting on a
plain INSERT, while the suite for this change passed throughout, is the
clearest statement of something this project keeps rediscovering: a new suite
tests the new thing, and the full run tests everything the new thing touches.
It is why I ran the whole 15 through 19 matrix on this rather than the two-major
cadence, given a custom slot sits on every read path. It is green on all five.
The VirtualTupleTableSlot derivation hazard is the kind of thing that would
have been a long afternoon later: inherited callbacks casting to their own type
and using the data pointer that follows the base means deriving from
TupleTableSlot puts your first field where data belongs, so materialise
scribbles and clear frees it. Catching that by reading the struct rather than by
running something, and pinning it with a StaticAssertDecl so a future field
addition fails to compile instead of aliasing, is the right pair of moves.
On the timing check that did not discriminate
Finding that 41 bigint columns at 20,000 rows stays under the 32 MB cap, so no
cliff exists to detect and the check passed against a reverted build, is exactly
the failure mode worth catching before merge rather than after. Changing the
fixture to cross the cap instead of loosening the threshold is the correct fix.
Two notes
I resolved a conflict in run_all_versions.sh: this branch and import_exclusion
from #167 both registered a suite on the same line. Both are registered in the
merge, and the harness check confirms nothing is unregistered and nothing listed
is missing.
The compat macro for copy_minimal_tuple gaining a trailing Size in PG18 is
handled the right way, beside the others rather than as a bare guard. Good to see
build_all_versions.sh catch that on 17 before you pushed; that is the gate
earning its cost on the exact class of defect it was written for.
Gate
Build preflight, five majors, zero warnings. Full 15 through 19 matrix,
ALL VERSIONS PASSED, native_lazy_slot green on all five.
Closes #157. This is the lazily-decoding slot from #164's description, which you asked to prioritise.
What it does
An index fetch reconstructed every column of the row before returning, because a virtual slot holds values and nothing else. The executor never says which columns it will read — but it does ask, through
slot_getsomeattrs, for the smallest prefix the plan needs. So the access method supplies its own slot that holds the row's address instead of its values and decodes when asked.Visibility is still settled eagerly by
ColumnarRowIsLive, which decodes nothing, so a fetch still returns false for an invisible row without touching the group.Only the index fetch defers. Everything else stores eagerly through
ExecStoreVirtualTuple, which setstts_nvalidto the full count, sogetsomeattrsis never reached for those.Measured
Assert-enabled 18.4 at
-O2, 2,000 index fetches reading one column:The class changes, not just the constant: a wide table is no longer in a different league from a narrow one.
Two defects of my own, found by the full suite and not by the new one
I want these on the record, because they are the reason I redid this rather than reporting the first numbers.
A segfault on the plainest
INSERTthere is.columnar_slot_force_fullcast whatever it was handed toColumnarSlot.copyslottakes a source slot of any type — the executor copies an ordinary virtual slot into a columnar one on every insert — so it read the deferred flag from past the end of a shorter slot and dereferenced the relation pointer behind it:Eight suites failed on that first run —
audit,concurrency,phase2,phase3,phase5among them — and the suite for this change passed throughout, because it exercises the path the change is about while the defect was in the slot type the change installs for everything. A new suite tests the new thing; the full run is what tests everything the new thing touches.Uninitialised fields.
MakeTupleTableSlotdoes not know the added fields exist, so a slot that never carries a deferred fetch still had them read. Aninitcallback clears them.A structural hazard worth naming
The slot derives from
VirtualTupleTableSlot, notTupleTableSlot, and that is load-bearing. Every callback inherited fromTTSOpsVirtualcasts the slot to its own type and uses thedatapointer that follows the base —tts_virtual_materializewrites it,tts_virtual_clearpfrees it. Deriving fromTupleTableSlotputs the first added field exactly wheredatabelongs, so materialising a slot would scribble on it and clearing one would free it.I had written it the wrong way first and caught it by reading the struct definitions before testing, not by running anything. A
StaticAssertDeclnow pins the layout, so ifVirtualTupleTableSlotever grows this fails to compile instead of aliasing silently.Version compatibility
copy_minimal_tuplegained a trailingSizeargument in PG18. The build preflight from #166 caught it on 17 before I pushed, which is the first time that gate has paid for itself on my work. It goes through a compat macro beside the others rather than a bare guard, with a note that the two must move together — the mistake that brokemainon 17.Tests
New suite
test/native_lazy_slot.sh, 10 checks, registered.Correctness is differential against a heap mirror across the shapes that decide how much gets decoded: first column, middle, last, all of them, and none (
count(*)). Plus the buffered-row path, and the four callbacks that need a whole row — sort,CREATE TABLE AS,SELECT *, and a unique check, which fetches while holding a buffer lock.The timing check did not discriminate at first and I fixed the fixture rather than keep it. At 20,000 rows of 41
bigintcolumns the decoded group is about 6 MB — under the 32 MB cap, so no cliff exists and the check passed against the deliberately reverted build. Forty text columns of forty characters cross the cap at a row count the suite can afford:It also rescopes a check I wrote in #164, which counted a call string across the whole file; the index fetch legitimately became a second caller, so a correct change failed it. It now extracts
columnar_index_delete_tuplesand asserts what that function does.Gate
Build preflight 17.6 and 18.4, zero warnings. Full suite on the
-O2assert build: 78 pass, 0 fail.I have 17 and 18 assert servers only, so 15, 16 and 19 are unrun.