Skip to content

Decode a fetched row's columns when the executor asks for them (closes #157)#169

Merged
jdatcmd merged 3 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/lazy-column-slot
Jul 27, 2026
Merged

Decode a fetched row's columns when the executor asks for them (closes #157)#169
jdatcmd merged 3 commits into
jdatcmd:mainfrom
ChronicallyJD:feat/lazy-column-slot

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Contributor

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 sets tts_nvalid to the full count, so getsomeattrs is never reached for those.

Measured

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 1,430x
41 columns 227,062 ms 122.6 ms 1,850x

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 INSERT there is. columnar_slot_force_full cast whatever it was handed 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 dereferenced the relation pointer behind it:

CREATE TABLE s (id int) USING pgcolumnar;
INSERT INTO s SELECT g FROM generate_series(1,25000) g;
-> client backend terminated by signal 11

Eight suites failed on that first run — audit, concurrency, phase2, phase3, phase5 among 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. MakeTupleTableSlot does not know the added fields exist, so a slot that never carries a deferred fetch still had them read. An init callback clears them.

A structural hazard worth naming

The slot derives from VirtualTupleTableSlot, not TupleTableSlot, and that is load-bearing. Every callback inherited from TTSOpsVirtual casts the slot to its own type and uses the data pointer that follows the base — tts_virtual_materialize writes it, tts_virtual_clear pfrees it. 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.

I had written it the wrong way first and caught it by reading the struct definitions before testing, not by running anything. A StaticAssertDecl now pins the layout, so if VirtualTupleTableSlot ever grows this fails to compile instead of aliasing silently.

Version compatibility

copy_minimal_tuple gained a trailing Size argument 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 broke main on 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 bigint columns 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:

correct build:   3 cols 25 ms,  41 cols 82 ms
eager (reverted): 3 cols 19 ms, 41 cols 69,930 ms   FAIL

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_tuples and asserts what that function does.

Gate

Build preflight 17.6 and 18.4, zero warnings. Full suite on the -O2 assert build: 78 pass, 0 fail.

I have 17 and 18 assert servers only, so 15, 16 and 19 are unrun.

ChronicallyJD added 3 commits July 26, 2026 20:12
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.
@ChronicallyJD

Copy link
Copy Markdown
Contributor Author

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 build_all_versions.sh for the compile check. I had 17 because it was the major that broke in #159, and then kept using it out of habit — which is exactly the gap you described, since it left 19 to nobody. I have built an assert-enabled 19beta2 and will run 18 and 19 from here.

Merged current main in as well; #167 had landed and its suite conflicted with this branch's SUITES line. Both registered now.

build preflight   17.6  OK   0 warnings
                  18.4  OK   0 warnings
                  19b2  OK   0 warnings

full suite        PG18  79 pass  0 fail
                  PG19  79 pass  0 fail

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 -O2 -g --enable-cassert), so a future version-guard mistake has somewhere to fail.

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 jdatcmd left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jdatcmd
jdatcmd merged commit 251e7b9 into jdatcmd:main Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fetch by row number decodes every column, so a wide table falls off the fetch cache and back to per-row group decode (#143 option D)

2 participants