feat: dispatch the stripe flush across a worker pool, off by default (#445 slice 3) - #591
Conversation
…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
Design-before-code for the concurrency slice: dispatch flush_one_column across background workers reading the slice-2 dsm, backend collects in column order and keeps all I/O, degrading to serial when worker slots are unavailable. Captures the FSST-verdict-cache threading needed for byte-identity, the slot-starvation degradation, and two open questions for the design owner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
…ommandprompt#445 slice 3) Slice 3 of the commandprompt#445 in-COPY parallelism design. A new pgcolumnar.parallel_flush GUC (default off) dispatches flush_one_column across background workers reading the slice-2 dsm; the backend collects results in column order, applies the FSST verdict updates, and keeps all I/O and catalog writes. Degrades to serial completion in the backend for any column no worker produced (slot starvation or worker failure), so the row count is always correct. Off by default => no behaviour change; on => byte-identical to serial. build_column_def is extracted so the worker and the backend build an identical PgColumnarColumnDef. Encoding GUCs the encoder reads live are threaded through the dsm header so a worker's bytes match the launching session. Projection inner writers flush serially (their tupdesc is synthetic, not the relation's). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
…dprompt#445 slice 3) Move dsm_unpin_segment to right after attach so a throw during collection frees the segment via the resource owner instead of leaking it to postmaster restart (shrinks the leak to the narrow wait-window; the full cleanup callback for that window is the top slice-4 hardening item). Add a DEBUG1 line reporting how the flush split across workers vs serial, which is also the test premise that a starved parallel run did not silently fall back unnoticed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
|
Known issue, resolution pending on #590. This slice's OFF branch (the default) inherits slice 2's per-column dsm round-trip, which @jdatcmd measured as a ~26% write-path regression on #590. The fix under discussion there is to make the serial/OFF path a direct |
jdatcmd
left a comment
There was a problem hiding this comment.
The parallel path is right and fast — but the DEFAULT path still regresses ~22%
Verified everything rather than trusting it. The worker dispatch is genuinely good work: correct, and it delivers.
Byte-identity holds, ON and OFF. 400k-row text-heavy load (FSST + codec run inside the workers), parallel_flush off vs on:
off: size=3915776 content=84c76b03… desc=64e2fe13…
on: size=3915776 content=84c76b03… desc=64e2fe13…
Same size, content, and encoding-descriptor hash — so the FSST verdict threading, the live-GUC threading, and column-order assembly all reproduce the serial bytes exactly. The three correctness requirements in the body are met.
The parallelism pays off. 41-column / 500k load, pg18 non-assert, median of 3, max_worker_processes=16:
| load | |
|---|---|
| main (pre-series) | 1061 ms |
this branch, parallel_flush=on |
910 ms (−14% vs main) |
this branch, parallel_flush=off |
1297 ms |
ON beats main by 14% and OFF by 30%. The approach works.
Blocking: OFF is +22% over main, and OFF is the default
The design note says "GUC off … → slice-2/serial path unchanged", and that is the problem: OFF still runs slice-2's dsm round-trip — serialize_column_input → dsm_create → deserialize_column_input → flush_one_column, all in the same backend — which is pure overhead when no worker will ever read it. Measured 1297 vs 1061 ms = +22% on the default path, and it scales with column count (2×dsm_create/column persists). The GUC gates the workers; it does not gate the dsm. So merging this makes every writer 22% slower until they discover and set a non-default GUC — the opposite of #445's goal.
The fix (small, and it resolves my #590 request too)
Route the serial / non-dispatched case around the dsm entirely: when parallel_flush is off (or natts < 2, or slots are unavailable, or a column falls back), call flush_one_column(...) directly — slice 1's pure function — with no serialize/dsm_create/deserialize. The dsm is only needed to cross into a worker; the in-backend path should never pay for it. That makes:
- OFF == pre-slice-2 main (no regression, byte-identical) — the default is safe.
- ON == +14% (unchanged; the dsm round-trip stays exactly where it earns its keep, on the worker path).
This also retires #590's standalone regression, since the serial dsm round-trip stops being on any default path. The ON path here is ready; it's only the serial fallthrough that shouldn't touch the dsm. Happy to re-review the moment the default routes direct.
…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
|
Done — the serial path now routes around the dsm ( The +22% is gone. Interleaved A/B on pg18n non-assert,
OFF matches main on both shapes — every warm rep within noise — and every ON rep beats every main rep. The per-column Byte-identity is unchanged: OFF is now literally slice 1's loop (already merged and proven identical), and the ON path is untouched by this edit — re-ran the OFF differential + write suites to confirm no drift from the change. This also retires #590: the serial dsm round-trip is no longer a shipping path anywhere, so its standalone regression can't occur. I'll close #590 as superseded — its serialize/deserialize helpers live on in this PR, used solely by the worker dispatch. Ready for re-review. |
jdatcmd
left a comment
There was a problem hiding this comment.
Approve — the fix lands it. Re-verified (PG18).
fa22d63 routes the serial path around the dsm exactly as requested, and I re-measured all three properties:
| main | OFF (default) | ON | |
|---|---|---|---|
| load (41-col/500k, median of 3) | 1061 ms | 1058 ms | 916 ms |
- OFF regression is gone — 1058 vs main 1061 (within noise). The default path now calls
flush_one_columndirectly with no dsm round-trip. - ON is 14% faster than main and
ON < OFFconfirms the workers actually ran (not a silent serial fallback). - Byte-identical, ON and OFF — 400k-row text-heavy load (FSST + codec inside the workers): same size, content, and encoding-descriptor hash
64e2fe13…all three ways. - Correctness: differential, native_writer, native_dml, native_zonemap, native_bloom, native_roundtrip, write_fsst_compressed all pass.
Off-by-default, byte-identical when on, faster when on, and the serial path is now free of the dsm cost — this is exactly the shape #445's serial lever should have. It also retires #590's standalone regression (that's why #590 closed). Nice work on the verdict/GUC threading; the three byte-identity requirements all hold under measurement.
Approving. Merge is jd's per the no-self-merge rule.
…#445 slice 4) Two robustness fixes ahead of flipping the default: 1. Cancel-window leak: wrap the parallel flush's wait/collect in PG_ENSURE_ERROR_CLEANUP. On an error (statement cancel inside the wait is the realistic case) it terminates the workers and frees every published-but- uncollected pinned output segment; the collect loop invalidates each handle as it frees it so the cleanup never double-frees. Closes the leak-to-postmaster- restart window flagged on #591. 2. CREATE TABLE ...; INSERT (or CTAS) in one transaction: the table's pg_class row is uncommitted, so a worker's fresh-snapshot table_open fails and the flush did register-fail-warn-redo-serially. rel_new_in_current_xact() detects it via rd_createSubid and keeps the flush serial up front -- silent, no double work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW
#445 slice 3: dispatch the stripe flush across a worker pool — off by default
Third slice of the in-COPY parallelism design (#588), on slices 1 (#589) and 2 (#590). A new
pgcolumnar.parallel_flushGUC, default OFF, dispatchesflush_one_columnacross background workers reading the slice-2 dsm; the backend collects results in column order, applies the FSST verdict updates, and keeps all I/O and catalog writes. OFF ⇒ no behavior change; ON ⇒ byte-identical to serial.What changed
build_column_defextracted from the write-state setup so the backend and a worker build an identicalPgColumnarColumnDef.pgcolumnar_parallel_flush_worker(modeled oncolumnar_parallel_export.c): attaches the dsm, connects, opens the relation for the tupdesc, claims columns via an atomic counter, and per column reconstructsatt/def, runsflush_one_column, and publishes its results in its own output dsm.serialize_column_inputblobs + offsets + verdict seeds + slots + claim counter), registersmin(natts, ≤8)workers, waits, collects, then completes serially any column no worker produced.The three things byte-identity required (each proven by the differential)
fsst_verdict_reuse,fsst_min_gain_percent,encoding_sample_rows) — read at flush time, not in the write state; a worker is a fresh backend that never saw the session'sSET.tupdescIsRel): a projection inner writer'srelidis the base table but its tupdesc is synthetic, so a worker'stable_open(relid)would rebuild the wrongatt. (These design gaps are noted on docs: design for parallelising the serial COPY flush (#445) #588.)Degradation (the property the eventual default flip depends on)
Every column in
[0,natts)is written by a worker or by the backend itself. A failed registration is not an error (trackednstarted, workers self-balance); aFAILEDworker logs a WARNING and its columns are redone serially; if zero workers start, the whole flush is serial. Slot starvation degrades to serial, never to a wrong row count.Verification (prove-not-trust, higher bar for concurrency)
differential,native_zonemap,native_bloom, both projection suites — byte-identical, ASAN 0 reports.parallel_flush=off,on(16 workers), andon-but-starved (max_worker_processes=0) produces identical size + content md5, all matching the heap oracle.VERDICT: BYTE-IDENTICAL off==on==starved==heap.DEBUG1line) — a silent serial fallback can't masquerade as passing; the starved run confirms 0 workers (degradation, not luck).--enable-cassert(no TRAP/Assert/crash).-Wshadow/-Werrorclean.Known limitations (flagged for slice 4, off-by-default so no default impact)
WaitForBackgroundWorkerShutdownbefore collecting an already-DONE worker. The full fix is a cleanup callback (terminate workers + free published segments on error) — slice 4, where it can be tested with an injected cancel.pg_classrow is uncommitted, so a worker'stable_openfails → WARNING → serial redo. Output is correct; it just logs and does that flush's work twice. A slice-4 decision (detect-and-skip-parallel for an uncommitted relation).Next
Slice 4: the measured control + the default flip once the leak-window cleanup lands and the ClickBench speedup vs the ~17% ceiling is on the issue.
🤖 Generated with Claude Code
https://claude.ai/code/session_017N82wDmsawqSWoWkmxtHmW