bench: a bulk Citus arm, so the bulk row compares like with like (#445) - #529
Conversation
…mandprompt#445) The ClickBench section compared pgcolumnar's 16-worker parallel_copy against Citus's SINGLE-connection COPY, because the harness had no bulk Citus arm. That is our best path against their non-best one, on the exact question commandprompt#445 was opened about. Citus columnar does accept concurrent writers into one table. Measured on the bench, 4M rows, own cluster, three runs: citus, serial COPY 3.06 3.07 3.06 citus, 8 concurrent COPY 0.509 0.518 0.513 scales 5.9x pgcolumnar, serial COPY 4.80 4.80 4.79 pgcolumnar, parallel_copy 8 1.137 1.138 1.135 scales 4.2x All four arms loaded 4,000,000 rows. So the premise that serial COPY is the only path Citus has does not hold, and the published ratio compares unlike things. ## The arm N concurrent connections, each fed a byte range from pgcolumnar.file_split_offsets -- OUR splitter, the same newline-aligned boundaries parallel_copy gives its own loaders -- through COPY ... FROM PROGRAM. So the two bulk arms differ in the engine and not in how the file was divided, and nothing copies a 15 GB file N times. It carries the same row assertion as the pgcolumnar bulk arm, for the same reason: an arm that errored leaves an empty table and returns fast, which reads as a win. That is commandprompt#465's failure with a different cause. ## The preflight that would have caught my own mistake max_worker_processes is now preflighted beside max_prepared_transactions. parallel_copy registers one background worker per loader plus a coordinator, and the logical replication launcher already holds a slot, so an N-worker arm needs N + 2. The stock default is 8, so an 8-worker arm fails at "could not register pgcolumnar parallel_copy loader 7 of 8" and leaves an EMPTY table. N + 2 is measured, not reasoned. Sweeping the setting against three worker counts, the smallest value that loaded every row was: workers 2 -> 4 workers 4 -> 6 workers 8 -> 10 and one below each failed on the LAST loader with the table left empty. I hit exactly this while measuring the Citus arms, and only caught it because the row count was asserted; the arm returned quickly with nothing in it. ## Tests bench_guards.sh covers the new guard the way it covers max_prepared_transactions, including the two thresholds either side of the measured minimum, a serial arm needing none, and non-numeric input being refused rather than compared. 17 checks to 28. The arm's mechanism was verified end to end against real citus_columnar on a 200,000-row fixture before shipping: 5 offsets for 4 workers, every row present, sum(a) exact, zero duplicate keys, and the per-worker COPY counts summing to the file. Cardinality alone would not have caught a boundary that loses one row and gains another. The ClickBench numbers themselves are unchanged here. commandprompt#526 now says the bulk comparison is unmeasured, which is what the evidence supports; the number comes from a run of this arm. Refs commandprompt#445, commandprompt#465, commandprompt#526.
jdatcmd
left a comment
There was a problem hiding this comment.
Approved. This is the arm I asked for and it is fairer by construction than the one I would have written.
The design decision that matters
The split is ours. Both bulk arms divide the file with pgcolumnar.file_split_offsets — the same newline-aligned byte boundaries parallel_copy gives its own loaders — and run the same PGC_CB_PCOPY_WORKERS. So the two arms differ in the engine and not in how the file was divided, which is the only way this comparison can be trusted. Handing Citus a different split, or a different worker count, would have reproduced the original defect with the sign flipped.
FROM PROGRAM 'tail -c +N | head -c LEN' feeding each connection its range, rather than materialising a 15 GB file N times, is the right mechanism, and the 1-based +$((start + 1)) is commented where someone would otherwise ask.
Verified rather than read
run_clickbench.sh / cb_guards.sh / bench_guards.sh syntax OK
bench_guards.sh 28 checks, 0 fails
And the slot guard at its boundaries, which is where an off-by-one would live:
workers=2 needs=4 at 3: REFUSED at 4: ok
workers=4 needs=6 at 5: REFUSED at 6: ok
workers=8 needs=10 at 9: REFUSED at 10: ok
workers=16 needs=18 at 17: REFUSED at 18: ok
serial arm (0 workers) on stock 8: ok
empty / non-numeric inputs: REFUSED / REFUSED
Exact at every boundary, the serial arm is not blocked by a setting it does not use, and both sides refuse garbage rather than comparing it — the #418 shape, handled.
The two failures this closes are the same failure
max_worker_processes at the stock 8 fails an 8-worker arm at "could not register loader 7 of 8" and leaves an empty table, which returns fast and reads as excellent scaling. That is #465 with a different cause, and the row-count assertion on the new arm is what stops the Citus side producing it too. Preflighting a PGC_POSTMASTER setting before any arm loads, rather than discovering it in a load log, is the same construction as max_prepared_transactions and for the same reason.
N + 2 measured rather than reasoned is the part I would have got wrong. I would have written N + 1 from the coordinator and never accounted for the logical replication launcher already holding a slot. Sweeping three worker counts and recording the smallest value that loaded every row is how that number becomes a fact instead of an argument.
What I will do with it
I will re-run ClickBench on the bench with this arm and update #526 and #445 with the result. The docs currently say the bulk comparison is unmeasured, which was the only honest position available; this is what replaces it with a number.
Merging.
…sign (#445) Replaces the 2026-08-08 record with the 2026-08-09 re-run, which exists to correct a published claim of ours. The load section previously said the parallel loader was 2.01 times faster than Citus on the bulk path, and said the fair bulk comparison was unmeasured. Both are now settled and the result goes against us. That comparison put our sixteen worker path against a single Citus COPY connection. Citus accepts concurrent writers and scales well, so the claim rested on a premise nobody had checked. With the fair arm from #529, both bulk arms split the same file at the same boundaries with the same worker count: columnar_pcopy 86.6s citus_pcopy 49.1s so we are 1.76 times SLOWER, not 2.01 times faster. We remain 1.72 times faster than heap and 11.1 percent smaller than Citus. The query analysis is re-derived from this run rather than carried over. Two corrections beyond the numbers: The loss table is now ordered on the milliseconds columnar adds, not on the ratio. The ratio does not order the six losses the same way, and the reason is the baseline: q29 and q21 both touch one column and add 433 ms and 671 ms, but sit on baselines of 8,001 ms and 675 ms, so comparable overheads read as 1.05 and 1.99. The claim that every loss predicate has a leading wildcard was false. q28 is URL <> '' and q29 is Referer <> ''. Both are still unprunable by a min and max statistic, but for a different reason, and the text now says which. The conclusion that all six are #452 is unchanged. Win and loss counts are taken from the times. Against heap this run is 33 wins, 6 losses and 4 ties, where a tie is a query whose two arms are closer than the run to run scatter of their own tries. See #531. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WmQJqcXdwyuoAiHHt2znBr
The ClickBench section compared pgcolumnar's 16-worker
parallel_copyagainst Citus's single-connectionCOPY, because the harness had no bulk Citus arm. That is our best path against their non-best one, on the exact question #445 was opened about.Citus does have a bulk path
Measured on the bench, its own cluster on a free port,
citus_columnar14.1-1 andpgcolumnarco-loaded, 4,000,000 rows, one split shared by every arm, three runs:COPYCOPYinto one tableCOPYparallel_copy8All four loaded 4,000,000 rows. Scaling from concurrency: citus 5.9x, pgcolumnar 4.2x. So the premise the published ratio rested on does not hold.
This PR does not publish a corrected ratio. My fixture is three narrow columns; ClickBench
hitsis 105, and one fixture is not the claim. The number comes from a run of this arm on the real data, which is @jdatcmd's — the run and the record are his.The arm
N concurrent connections, each fed a byte range from
pgcolumnar.file_split_offsetsthroughCOPY ... FROM PROGRAM. That is our splitter, the same newline-aligned boundariesparallel_copygives its own loaders, so the two bulk arms differ in the engine and not in how the file was divided — and nothing copies a 15 GB file N times.Same row assertion as the pgcolumnar bulk arm, for the same reason: an arm that errored leaves an empty table and returns fast, which reads as a win. #465's failure with a different cause.
The preflight that would have caught my own mistake
max_worker_processesis now preflighted besidemax_prepared_transactions. An N-workerparallel_copyneeds N + 2: one background worker per loader, one coordinator, and one already held by the logical replication launcher. The stock default is 8, so an 8-worker arm fails atcould not register pgcolumnar parallel_copy loader 7 of 8and leaves an empty table.N + 2 is measured, not reasoned. Sweeping the setting against three worker counts, the smallest value that loaded every row was:
max_worker_processesOne below each failed on the last loader with the table left empty. Three counts rather than one, because a rule extrapolated from a single observation is a guess wearing a measurement's clothes.
I hit this exactly while measuring the Citus arms. The only reason I caught it is that the row count was asserted: the arm returned in about no time with nothing in it.
Tests
bench_guards.shcovers the new guard the way it already coversmax_prepared_transactions: both thresholds either side of the measured minimum, the rule at a second worker count, a serial arm needing none, and non-numeric input refused rather than compared. 17 checks to 28.The arm's mechanism was verified end to end against real
citus_columnaron a 200,000-row fixture before shipping, with content checks rather than cardinality:A boundary that loses one row and gains another passes a count check and fails that one.
Gate
bench_guards28/0,docs_style6/0,harness_selftest54/0, full matrix on PG18 and PG19 ALL VERSIONS PASSED.Refs #445, #465, #526.