docs: design for parallelising the serial COPY flush (#445) - #588
Conversation
The approved direction for #445's remaining serial-load gap: parallelise the per-column work in pgcolumnar_flush_row_group (encode + FSST + block codec, each column chunk independent) across background workers reusing columnar_parallel_export.c's dsm/shm machinery, keeping all disk I/O and WAL on the backend so no new WAL semantics are needed. Records the offload point (columnar_write_state.c:1012-1183), the WAL-safe structure, the ~17% recoverable ceiling, the four shippable slices, and the constraint that it degrades to the serial path when worker slots are unavailable. Builds nothing; the implementation follows slice by slice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WmQJqcXdwyuoAiHHt2znBr
Complication found while scoping slice 1 (design refinement)Reading Consequence for the design: slice 1's function takes the verdict as an in/out parameter, and the backend applies the returned verdict to |
Owner decision: yes — with a two-stage path to "default"The gate this design waits on is answered: parallelise the default single-connection COPY. The target end-state is parallel-by-default, reached in two stages rather than one:
The reason for staging rather than defaulting on at slice 3/4: a default that every plain Acceptance bar I'll hold each slice to
CoordinationTaking slice 1 — the pure (Direction set by the owner; design is yours. — ChronicallyJD) |
…lice 1) Slice 1 of the #445 in-COPY parallelism design (#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
ChronicallyJD
left a comment
There was a problem hiding this comment.
Review — design validated by implementation, three worker-section notes to fold in
I've now built slices 1–3 from this design (#589 merged-pending, #590, and slice 3 in verification), each byte-identical to the serial path. The structure holds up end to end. Three things the implementation proved the worker section should say explicitly, because each is load-bearing for the byte-identity this design promises:
-
table_open(relid)for the tupdesc is only safe for the base flush. A projection inner write state setsrelid= the base table but carries a synthetic{rownumber, projcols…}tupdesc, so a worker rebuilding each column'sattfromRelationGetDescr(relid)gets the wrongatttypid/attbyval/attlen/attgeneratedand corrupts the projection's bytes. Slice 3 fixes this by flushing projections serially (a fail-closedtupdescIsRelflag). The design should note the worker path is base-relation-only. -
The encoder reads GUCs live that aren't in the write state.
pgcolumnar_fsst_verdict_reuse,pgcolumnar_fsst_min_gain_percent, andpgcolumnar_encoding_sample_rowsare read at flush time, not captured. A worker is a fresh backend that never saw the launching session'sSET, so under any non-default value its bytes diverge. Slice 3 threads all three through the dsm header. Worth calling out as a constraint alongside the WAL one. -
The FSST verdict cache must be threaded, not just "carried in
def." A worker with a fresh (UNKNOWN) verdict re-decides every group; for a group whose corpus would flip the cached verdict, re-deciding yields different stored bytes than the serial reuse. Slice 3 seeds the verdict into the dsm and returns the updated verdict so the backend applies it in column order — this is what keeps ON byte-identical to OFF, and it's subtler than the slice-1 note implies.
None of these change the design's shape — they're the sharp edges the four-slice plan has to round off, and slices 1–3 do. The plan itself (per-column flush offload, I/O + WAL on the backend, degrade to serial on slot starvation) is the right one and is measured out. No objection to recording it.
…slice 2) Slice 2 of the #445 in-COPY parallelism design (#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
ChronicallyJD
left a comment
There was a problem hiding this comment.
Re-review — the mechanism shipped as designed; the "eventually-default" conclusion did not survive measurement
Following up now that the series is built out. The design's structure holds up exactly: slices 1–3 (pure flush_one_column, dsm round-trip, worker pool) all landed byte-identical, and your own note here about the #472 verdict cache being cross-flush mutable state was spot on — threading it seed→worker→return is what keeps ON byte-identical, and it's implemented that way.
But the doc's endpoint — "four shippable slices … eventually-default control" — should be updated, because slice 4 measured it and the default flip was declined. The win is narrow and data-dependent, not general:
| shape | on/off |
|---|---|
| 40 int + 1 tiny text, 500k (93 MB, one flush) | 0.89 — win |
| int/text mix, 500k (394 MB) | 1.16 — loss |
| 5 cols @ stripe_row_limit=1000 (small flushes) | 3.57 — big loss |
The parallel path copies buffered data through the dsm (O(bytes)), so it only wins for a single large flush of many cheap numeric columns; text-heavy/large flushes and small/frequent flushes regress. A size gate can't rescue the flip (a byte threshold would enable the large text-heavy flushes that lose). Full data on #445.
So the accurate endpoint is: parallel_flush is a targeted opt-in (wide-numeric bulk loads), plus the two hardening fixes in #592 — not an eventual default. Everything above that line in the design is correct and shipped; only the "~17% recoverable, eventually default" framing needs the correction. Worth a one-line amend to the doc so the record matches what was measured. No objection to merging it with that note.
The approved design for #445's remaining serial-load lever: parallelise the per-column flush work (encode + FSST + block codec — each column chunk is independent) across background workers, keeping all I/O and WAL on the backend. Reuses
columnar_parallel_export.c's dsm/worker machinery; degrades to serial when slots are unavailable. Four shippable slices; ~17% recoverable ceiling. Docs only; implementation follows slice by slice.🤖 Generated with Claude Code