feat: round-trip flush_one_column through a dsm segment, serial (#445 slice 2) - #590
feat: round-trip flush_one_column through a dsm segment, serial (#445 slice 2)#590ChronicallyJD wants to merge 2 commits into
Conversation
…dprompt#445 slice 1) Slice 1 of the commandprompt#445 in-COPY parallelism design (commandprompt#588): move the per-column flush body of pgcolumnar_flush_row_group into a standalone flush_one_column(inputs) -> {chunk, descriptor, codec, zonemap, bloom} that reads only its arguments, no writeState reach-through. The backend assembles the column chunks into the stripe in column order and keeps all I/O and catalog writes. No workers yet; output is byte-identical to the serial path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
…andprompt#445 slice 2) Slice 2 of the commandprompt#445 in-COPY parallelism design (commandprompt#588): serialise each column's flush_one_column input (its per-chunk-group buffers + min/max Datums + counts) into a dsm segment and its result (chunk bytes, descriptor, codec, zone rows, bloom) back, run serially in the backend with no workers. Proves the input/output serialisation is byte-identical before slice 3 adds the worker pool. Reconstructs a minimal per-column chunkGroups on the read side so slice 1's flush_one_column signature is untouched; every buffer is copied out of the dsm before detach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
jdatcmd
left a comment
There was a problem hiding this comment.
Correctness is solid — but this regresses the write path ~26% on the merge target
Verified the byte-identical claim rather than trusting it, and it holds: same 400k-row text-heavy load on this branch and clean main is identical in size, content hash, encoding-descriptor hash, and chunk bytes (3915776 / 64e2fe13… both). differential, native_writer, native_zonemap, native_bloom, native_dml, native_roundtrip, write_fsst_compressed, encode_effort all pass. The serialization is careful — datumSerialize for min/max, deserialize copies out of the dsm into palloc'd memory, length prefixes throughout. As a proof that the input and result cross a dsm byte-identically, slice 2 does its job.
The blocker is performance on main, which the suites don't measure. This slice does dsm_create + dsm_detach twice per column per flush (columnar_write_state.c :968, :982, :1377-8, inside the per-column loop), and segment creation is expensive. Measured, 41-column / 500k-row load, pg18 non-assert, median of 3:
| load | |
|---|---|
| main | 1061 ms |
| this branch | 1333 ms |
~26% slower, with no benefit yet (still serial), and it scales with column count — a 105-column ClickBench table does 2×105 dsm_create/flush. Merging this to main as a standalone slice means every writer pays that until slice 3 lands. That is the exact opposite of #445's direction for anyone loading in that window.
What I'd change before this lands on main
Any one of:
- Reuse one dsm segment, not per-column. Create a single segment per flush (or, better, a persistent per-writer segment grown as needed) and round-trip every column through it. That removes the
dsm_createchurn that is the whole cost here — and slice 3's workers will want a stable segment to attach to anyway, so this is the shape that slice actually needs, not throwaway scaffolding. - Gate it off by default behind the GUC/table-option slice 4 introduces, so
mainkeeps the serial in-backend path until the full pipeline is proven a net win. - Land it with slice 3, so
mainnever carries the serialize-without-parallelism cost alone.
I'd take (1): it is the least scaffolding-thrown-away and it is what the worker slice needs regardless. The extraction and wire format below it are right; it's the per-column dsm_create that shouldn't reach main. Happy to re-review promptly on any of the three.
|
You're right, and thank you for A/B-ing the write path — I verified byte-identity and memory-safety but did not measure load time, which is my miss: a perf A/B is exactly the discipline I hold my own findings to, and I didn't apply it to a regression I introduced. The per-column One thing that strengthens the case, which you may not have seen yet: #591 (slice 3) inherits this. Its OFF branch is verbatim slice 2 — I confirmed reading the That points past "make the round-trip cheaper" to "the serial path shouldn't round-trip at all." The serialization only earns its cost when there's a worker to hand the bytes to; in-backend-serial, the right path is a direct So my recommendation, which is your option (3) sharpened:
Your option (1) (one segment, keep slice 2 standalone) also works and is less restructuring, but it leaves a small serialize/deserialize cost on the serial path and doesn't fix #591's default. Since it's your design, your call — I'll re-verify with perf either way. If you're good with the fold, I'll close #590, revert slice 3's OFF branch to the direct path, and re-run the gate with the A/B. |
…lice 3) Per jdatcmd's commandprompt#591/commandprompt#590 review: the dsm serialize round-trip only earns its cost crossing into a worker. When parallel_flush is off (or natts<2, or a column falls back), call flush_one_column directly instead of serialize->dsm_create-> deserialize. OFF is now byte-identical to and as fast as main (was +22% from the per-column dsm_create); ON keeps its ~14% win, dsm crossing intact on the worker path. This also retires commandprompt#590's standalone regression -- the serial dsm round-trip is no longer on any default path; the serialize/deserialize helpers remain, used solely by the worker dispatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
Superseded by #591 — closingPer the review discussion here and on #591: the serial dsm round-trip this PR introduced should not be a shipping path (it's ~22-26% overhead when no worker reads the bytes, scaling with column count). The resolution landed in #591: the serial / non-dispatched flush calls So this standalone slice is retired rather than revised: there's no serial-round-trip path left to merge. Measured after the fix, the default ( |
#445 slice 2: round-trip
flush_one_columnthrough a dsm segment (serial, no workers)Second slice of the in-COPY parallelism design (#588), on top of slice 1 (#589). Still serial in the backend; output is byte-identical. No workers yet — the whole job of this slice is to prove the per-column input and result can cross a dsm segment byte-identically, so slice 3 can move the call to a worker with the serialization already settled.
What changed
The per-column flush loop now, for each column:
flush_one_columninput — its per-chunk-group buffers (existsStream,valueStream,hashBuf,valueCount,sum,hasMinMax, and the min/maxDatums viadatumSerialize) — into adsm_created segment behind auint32length prefix;chunkGroups(every buffer copied out of the dsm into palloc'd memory), and runsflush_one_columnon the round-tripped input;chunkbytes, descriptor, block codec, zone-map rows incl. the numericsumviadatumSerialize, bloom filter) into a second segment, deserializes it, and assembles into the stripe exactly as slice 1 did;dsm_detaches both segments.flush_one_column's slice-1 signature is untouched: the read side reconstructs a minimalchunkGroupswhosecolumnsarray is sized(columnIndex+1)with only[columnIndex]populated (the only entry the function reads). Four static helpers (serialize_column_input/deserialize_column_input/serialize_column_result/deserialize_column_result) carry a fixed little wire format;datumSerialize/datumRestorehandle the min/max and numeric-sum Datums.Why it's safe
initStringInfo+append for streams;palloc+memcpyfor descriptor/min/max/filter;datumRestorefor Datums), so nothing references the segment afterdsm_detach. Lifetimes match slice 1 (flush context).Assert(cursor == base + 4 + payloadLen)).Verification (prove-not-trust)
differential.shPASSES.native_zonemap,native_bloom,write_minmax_fastpathPASS — these exercise the parts of the wire format most likely to drift.native_writer,native_dml,write_fsst_compressed,encode_invariants,encode_effort,corruption,native_reclaim,native_roundtripPASS.-Wshadow=compatible-local/-Werrorclean.Next
Slice 3: dispatch
flush_one_columnacrossmin(natts, N)background workers reading the slice-2 dsm, backend collects in column order, degrading to the serial path when worker slots are unavailable (never a wrong row count). Slice 4: GUC + measure vs the ~17% ceiling, then the default flip.🤖 Generated with Claude Code
https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW