Skip to content

feat: dispatch the stripe flush across a worker pool, off by default (#445 slice 3) - #591

Merged
jdatcmd merged 5 commits into
commandprompt:mainfrom
ChronicallyJD:feat/445-slice3-worker-pool
Aug 12, 2026
Merged

feat: dispatch the stripe flush across a worker pool, off by default (#445 slice 3)#591
jdatcmd merged 5 commits into
commandprompt:mainfrom
ChronicallyJD:feat/445-slice3-worker-pool

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

#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_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. OFF ⇒ no behavior change; ON ⇒ byte-identical to serial.

Stacked on #589/#590. Rebases to main once they land.

What changed

  • build_column_def extracted from the write-state setup so the backend and a worker build an identical PgColumnarColumnDef.
  • Worker pgcolumnar_parallel_flush_worker (modeled on columnar_parallel_export.c): attaches the dsm, connects, opens the relation for the tupdesc, claims columns via an atomic counter, and per column reconstructs att/def, runs flush_one_column, and publishes its results in its own output dsm.
  • Backend builds the input dsm (header + per-column serialize_column_input blobs + offsets + verdict seeds + slots + claim counter), registers min(natts, ≤8) workers, waits, collects, then completes serially any column no worker produced.

The three things byte-identity required (each proven by the differential)

  1. FSST verdict cache threaded (seed → worker → returned verdict applied in column order). A worker with a fresh cache would re-decide and could diverge from the serial reuse path; threading keeps ON == OFF.
  2. Live encoder GUCs threaded (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's SET.
  3. Projections flush serially (fail-closed tupdescIsRel): a projection inner writer's relid is the base table but its tupdesc is synthetic, so a worker's table_open(relid) would rebuild the wrong att. (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 (tracked nstarted, workers self-balance); a FAILED worker 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)

  • OFF regression: 14 write suites incl. differential, native_zonemap, native_bloom, both projection suites — byte-identical, ASAN 0 reports.
  • ON + starvation, authoritative standalone byte-comparison (ASAN and pg18a assert): the same 8-column text-heavy 200k–400k-row load under parallel_flush=off, on (16 workers), and on-but-starved (max_worker_processes=0) produces identical size + content md5, all matching the heap oracle. VERDICT: BYTE-IDENTICAL off==on==starved==heap.
  • Premise checks (non-vacuous): the ON run confirms ≥1 column was actually processed by a worker (a DEBUG1 line) — a silent serial fallback can't masquerade as passing; the starved run confirms 0 workers (degradation, not luck).
  • Two toolchains: forced ASAN+UBSAN (0 sanitizer reports) and pg18a --enable-cassert (no TRAP/Assert/crash). -Wshadow/-Werror clean.

Known limitations (flagged for slice 4, off-by-default so no default impact)

  1. Narrow pinned-segment leak on statement cancel. Output segments are unpinned immediately after the backend attaches them, so a throw during collection frees them via the resource owner; the residual window is a cancel during WaitForBackgroundWorkerShutdown before 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.
  2. CREATE+INSERT+COMMIT in one transaction: at the pre-commit flush the table's pg_class row is uncommitted, so a worker's table_open fails → 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

ChronicallyJD and others added 4 commits August 11, 2026 19:30
…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
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

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 flush_one_column call (= merged main, zero overhead) and keep the dsm round-trip only in the parallel path. Once the direction is settled I'll revise this branch's OFF path accordingly and add a perf A/B (OFF == main, ON faster) to the verification below. Holding merge until then.

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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_inputdsm_createdeserialize_column_inputflush_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
@ChronicallyJD

Copy link
Copy Markdown
Collaborator Author

Done — the serial path now routes around the dsm (fa22d63). When parallel_flush is off (or natts < 2, or a column falls back), the flush calls flush_one_column directly, exactly slice 1; the serialize/dsm_create/deserialize round-trip is gone from every non-dispatched path. The dsm crossing stays only in flush_columns_parallel, on the worker path, where you measured it earning its keep.

The +22% is gone. Interleaved A/B on pg18n non-assert, max_worker_processes=16, median of the warm reps:

shape main this branch OFF this branch ON
wide (41 col, 500k) 2.19 s 2.19 s (±0%) 1.95 s (−11%)
text (6 col, 300k) 0.89 s 0.90 s (≈main) 0.83 s (−7%)

OFF matches main on both shapes — every warm rep within noise — and every ON rep beats every main rep. The per-column dsm_create pair no longer touches the default path, so the column-count scaling that drove the regression is gone with it.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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_column directly with no dsm round-trip.
  • ON is 14% faster than main and ON < OFF confirms 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.

@jdatcmd
jdatcmd merged commit 9bb7348 into commandprompt:main Aug 12, 2026
11 checks passed
jdatcmd pushed a commit that referenced this pull request Aug 12, 2026
…#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
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.

2 participants