Skip to content

bench: a bulk Citus arm, so the bulk row compares like with like (#445) - #529

Merged
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:feat/526-citus-bulk-arm
Aug 9, 2026
Merged

bench: a bulk Citus arm, so the bulk row compares like with like (#445)#529
jdatcmd merged 1 commit into
commandprompt:mainfrom
ChronicallyJD:feat/526-citus-bulk-arm

Conversation

@ChronicallyJD

Copy link
Copy Markdown
Collaborator

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 #445 was opened about.

Citus does have a bulk path

Measured on the bench, its own cluster on a free port, citus_columnar 14.1-1 and pgcolumnar co-loaded, 4,000,000 rows, one split shared by every arm, three runs:

arm seconds
citus, serial COPY 3.06, 3.07, 3.06
citus, 8 concurrent COPY into one table 0.509, 0.518, 0.513
pgcolumnar, serial COPY 4.80, 4.80, 4.79
pgcolumnar, parallel_copy 8 1.137, 1.138, 1.135

All 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 hits is 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_offsets through COPY ... FROM PROGRAM. That is our splitter, the same newline-aligned boundaries parallel_copy gives 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_processes is now preflighted beside max_prepared_transactions. An N-worker parallel_copy needs 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 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 minimum max_worker_processes
2 4
4 6
8 10

One 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.sh covers the new guard the way it already covers max_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_columnar on a 200,000-row fixture before shipping, with content checks rather than cardinality:

offsets: 5 (want 5)     rows: 200000/200000 OK
sum(a):  exact OK       duplicate keys: 0 OK
per-worker COPY counts: 49305 + 49306 + 50534 + 50855 = 200000

A boundary that loses one row and gains another passes a count check and fails that one.

Gate

bench_guards 28/0, docs_style 6/0, harness_selftest 54/0, full matrix on PG18 and PG19 ALL VERSIONS PASSED.

Refs #445, #465, #526.

…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 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.

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.

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