Fast-decode attbyval fixed-width columns on the read path (#289) - #307
Conversation
|
Full matrix gate is green on PG18 and PG19 (assert builds), whole suite list including The assert build is the one that matters here: the fast path's shadow decode runs A measured q4/q5 A/B (main vs this branch, interleaved, true-cold + warm, on the bench) is running; I'll post the numbers next. |
|
Measured A/B on the bench (pg18n non-assert; interleaved arms, alternating order per round; true-cold + warm median). Two pictures, and the second is why I'm converting this to draft. Wide aggregates (the target), warm median:
Narrow filtered scan (adversarial), warm median:
Root cause (mine): the producer widens every present value at group load. A scan that materializes few columns pays the full widen as overhead, plus the extra Datum-array memory traffic. It only pays off when many byval columns are read (wide aggregates), where the column-sequential widen is cache-friendlier than per-row Plan: converting to draft. Reworking to the inline variant — hoist the per-row decode dispatch (inline the attlen-specialised load, drop the Correctness is unaffected either way: the full PG18+PG19 assert matrix (shadow-decode active) is green on the current branch; this is purely about whether the perf trade is worth it, and as measured it isn't yet. |
columnar_native_next_row decoded every present value through ColumnarDecodeValue, an out-of-line call that re-checks attbyval and switches on attlen per value. For a by-value fixed-width column that call is the per-row decode dispatch commandprompt#289 profiled as hot (~13% of a serial q4 scan). Inline it: for an attbyval column do the same fetch_att + advance the call would, directly in the row loop; by-reference and varlena keep the call (they copy into rowContext). This is the whole change -- no new state, no per-group work, no extra memory -- so a scan that materialises few columns pays nothing extra. It covers both baseline and descriptor chunks, since both leave nativeValueCursor pointing at the present-value bytes. An earlier revision widened each column into a typed array once per group; measured on the bench it helped wide aggregates (~+4% q5) but regressed a narrow filtered scan ~13%, because it did widen work the query never read and added array memory traffic. This inline form removes the dispatch without that overhead, so it does not regress the narrow case. test/native_fastdecode.sh is a heap-oracle suite: every byval fixed type with interleaved NULLs across many groups, adversarial bit patterns, every fixed-width encoding, active per-vector skipping, deletes and ADD COLUMN, with uuid/text/numeric as controls that keep the call path. The full projection compare is exact per value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UX1jrWiQsJJA1t4pkmkb4T
21c8bbc to
014dca0
Compare
|
Reworked to the inline form and re-measured. This is a clean win now, no regression on the narrow case that sank the widen revision. Bench A/B (pg18n non-assert; interleaved arms, alternating order per round; warm median):
The narrow case went from -13% (widen) to +2.4% (inline), and q4 actually improved (+1.5% → +3.8%) — the widen's per-group array fill and 40 MB/col of Datum traffic was taxing even the wide path. Dropping the array and just inlining the Correctness re-confirmed: full PG18 + PG19 assert matrix ALL PASSED, |
|
Reviewed and verified. Merging. The correctness question, which is the only one that could have sunk this
It does not, and your comment says exactly why: Sanitizer gate, run locally because a fork branch cannot dispatch the nightly: That is ASAN plus UBSAN with alignment checking, which is the tool that would Five-major matrix: ALL VERSIONS PASSED, The test
The performance claim, reproduced independentlyI measured it rather than accepting it, interleaved, on a different box and Your narrow filtered scan reported -2.4%. Same direction, same magnitude, on Being precise about what that establishes: the distributions overlap, so at a 3% The part I liked mostDropping the earlier widen-into-a-typed-array revision because it regressed Framing it as a down payment on #289 rather than the answer is also right: this |
|
Thanks for the careful merge — verifying the alignment property by reading Adopting the sanitizer gate on read/decode-path changes going forward — PR checks and the normal matrix don't include it, and it's the one tool that would catch an alignment hazard on packed bytes. I'll run it on the #289 grouped-aggregate follow-on (the design's up next), which touches the same read path. |
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
The refresh in #320 rebuilt the open list from issue STATE. The follow-up commit on this branch fixed the #155 entry but repeated the same mistake on the entry it wrote to replace it. An audit of every entry against its issue thread, its pull requests and main found that all four were wrong, in three different ways. #289 was a copy of the issue body and gave no sign that work is in flight. The decompression half already merged (#307, 3.8 percent on q4 and 3.2 on q5) and the aggregation half is open as #321. The "about 4x behind TimescaleDB" line reads as the size of the prize for that work, but #321 measures 1.20x and 1.38x, and by its own account the larger lever is dictionary-coded grouping. The widest gap, q6 at 5.3x behind and 3.1x slower than heap, is the only shape where columnar loses to heap and nothing in flight touches it. #300 was framed as core COPY's per-field parse. #300's own profile refuted that before the entry was written: parse is about 21 percent, encode about 53 percent, so bypassing the parser cannot make columnar beat heap. The measured top lever is parallelism over the existing encoder with COPY unchanged, prototyped at 7.39x. IMPORT_THROUGHPUT_PLAN.md was cited as the reference and is the wrong pointer: it predates the #283 to #286 work and puts COPY under "Not in scope". reltuples is removed. It was fixed on 2026-07-28 by #189 and is now exact on every measured shape, and the cause the entry gave was explicitly disproven: it was a block-offset mismatch, not blocks holding no row-group data. The line was written about nine hours before the fix and survived two refreshes. #310 is no longer listed as work. Both causes are merged and it was re-measured at 100M, 273,212 buffers to 8,917. It stays open for a confirmation reading on the real dataset. #291 was open and absent from the list; added, with the note that its documentation half landed in #298. Also fixed, all verified: the "Deferred, not yet built" paragraph listed two things that have been on main since 2026-07-23; a cross-reference to "item 0" that #320's renumbering left dangling; six Done rows naming the extension schema as columnar rather than pgcolumnar, which a reader copying them would find does not exist; and a closed-since line with the wrong date and three omissions. Refs #289, #300, #291, #310. No issue is closed by this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011miCFRSatixeNRw3w5yNq8
Addresses the decode-dispatch half of #289.
What and why
columnar_native_next_rowdecoded every present value throughColumnarDecodeValue, an out-of-line call that re-checksattbyvaland switches onattlenper value. For a by-value fixed-width column that call is the per-row decode dispatch #289 profiled as hot (~13% of a serial q4 scan).The change inlines it: for an
attbyvalcolumn, do the samefetch_att+ advance the call would, directly in the row loop; by-reference and varlena keep the call (they copy intorowContext). That is the whole change — 24 lines in the reader, no new state, no per-group work, no extra memory — so a scan that materialises few columns pays nothing extra. It covers both baseline and descriptor chunks, since both leavenativeValueCursorpointing at the present-value bytes.Correctness
The inline path runs the identical
fetch_att(p, true, attlen)the call path uses (attbyvalguaranteesattlen ∈ {1,2,4,8}), so values are bit-identical by construction — sign bits,-0.0,NaN,INT_MIN, subnormals.test/native_fastdecode.sh(new) is a heap-oracle suite: every byval fixed type with interleaved NULL patterns across many groups, adversarial bit patterns, every fixed-width encoding forced, active per-vector skipping, scattered deletes and an ADD COLUMN boundary — with uuid/text/numeric as controls that keep the call path. The full-projection compare is exact per value.Full PG18 + PG19 assert matrix: ALL VERSIONS PASSED (every suite, both majors).
Measured (bench, pg18n non-assert, interleaved arms, warm median)
Consistent single-digit win, no regression on any shape.
Honest scope
This removes the per-row decode dispatch only. It is a modest, safe improvement and a down payment — the larger win for #289 is full vectorized aggregation (typed column vectors consumed directly, bypassing the row-at-a-time executor), which is separate follow-on work. See the comment thread for why an earlier widen-into-a-typed-array revision was dropped (it regressed narrow scans ~13%).