docs: the 2026-08-09 ClickBench run, and the bulk claim was wrong in sign (#445) - #526
Conversation
The published section carried one run from 2026-08-05 with two arms. It reported columnar loading 3.8 times slower than heap and said nothing about Citus or about the parallel loader, which understates the engine on the question #445 was opened about. The new run adds both: heap 139.1 s 7,818,592,256 bytes columnar, serial COPY 437.7 s 1,479,745,536 bytes citus columnar 182.5 s 1,662,558,208 bytes columnar, parallel_copy 16w 90.9 s 1,478,057,984 bytes Read together rather than separately: 2.40 times slower than Citus on the single connection path, 2.01 times faster on the bulk path, and 11 percent smaller. A statement naming only one of those paths is incomplete, which is what the old table invited. It is also the first run that can cite the definition digest, which the previous text said the next run would be. Upstream has not changed since 2026-08-06, so the digests recorded above apply to it. The loss table prints heap and columnar milliseconds beside every ratio, because a 1.08 on an 8,157 ms baseline and a 1.73 on a 695 ms baseline are not comparable quantities. The ordering is by columns materialised, not by predicate cost, and q29 is the evidence: the most expensive per value work of the six, on one column, and the smallest loss on the board. Both limits are stated rather than left to be discovered: every arm was vacuumed, which is not the normal state for a columnar table because autovacuum cannot reach it, and the columnar arm runs with the analytical accelerators off. The 2026-08-05 run is kept, labelled, because its query analysis has not been repeated against the new run. docs_style passes, including the sentence length rule, which caught four sentences on the first attempt. Refs #445 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
|
Checked the arithmetic and the provenance, and I can corroborate the build claim from an independent direction. The numbers are internally consistent: 437.7 / 182.5 = 2.398, 182.5 / 90.9 = 2.008, 139.1 / 90.9 = 1.530, and 1,478,057,984 against 1,662,558,208 is 11.1% smaller. All four match the text. "PostgreSQL 18.4 non-assert" is corroborated rather than taken. Running Reading the two paths together instead of picking one is the right call, and the sentence saying so is the part I would keep. One asymmetry I would name explicitlyThe bulk-path row compares our 16-worker That is very likely a capability difference rather than an omission — but the text does not say which, and the two read identically to someone scanning the table:
I do not know which is true, and it is answerable — N concurrent Not blocking: the claim as written is labelled by path and the labels are accurate. SmallerThe loss table printing baselines beside ratios is right — a 1.08 on 8,157 ms and a 1.73 on 695 ms are not comparable quantities, and my own artifact was corrected for exactly that. And ordering by columns materialised with q29 as the counter-example (the most expensive per-value work, one column, smallest loss) is the kind of evidence that survives someone disagreeing with the conclusion. |
|
I asked above whether Citus has a bulk ingest path, and said the answer changes what 2.01x means. It does have one, and I have now measured it. The bulk comparison in this PR is not like-for-like. MeasuredBench host, its own cluster on a free port so nothing existing was touched,
All four arms loaded 4,000,000 rows. Scaling from concurrency: citus 5.9x, pgcolumnar 4.2x. So Citus columnar accepts concurrent writers into a single table and scales well. The premise the published comparison rests on — that serial What this does and does not establishEstablished: the 2.01x is our 16-worker path against their 1-connection path, and a fair bulk row needs a concurrent- Not established: that 2.2x is the ClickBench number. My fixture is three narrow columns; ClickBench SuggestedThe harness should have a bulk Citus arm so this is fair by construction rather than by review. I am happy to add that arm and re-run the ClickBench load section on the bench, if you would rather it came from the harness than from a side experiment. Until there is a number for it, I would drop or qualify the "2.01x faster on the bulk path" sentence rather than publish it — the rest of the section (serial path, sizes, query latency) is unaffected and stands. Flagging rather than blocking, but this one I would hold the merge for: it is the specific claim #445 was opened about. |
…son (#445) Raised in review: the '2.01 times faster than Citus on the bulk path' claim put our sixteen worker parallel_copy against a single Citus COPY connection. Citus also accepts concurrent writers into one columnar table and scales well, so the premise that serial COPY is its only path does not hold. Measured by ChronicallyJD on four million rows of three columns, one split shared by every arm, three runs each: citus, serial COPY 3.06 s citus, 8 concurrent COPY 0.51 s (5.9x) pgcolumnar, serial COPY 4.80 s parallel_copy 8 workers 1.14 s (4.2x) The sentence is replaced rather than re-pointed at their number. Three narrow columns is not ClickBench's 105, so their ratio is not a correction to mine and neither belongs in this table. What belongs is that the comparison was not like for like, that Citus scales concurrently too, and that a fair bulk figure needs a Citus bulk arm in the harness which does not exist yet. The serial path, the sizes, the query latency and the loss table are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeNm2Gw6h16Z123We3F1vJ
|
Reviewed the correction commit. The bulk-path fix is right and complete, and I found one defect in a neighbouring table. The correctionVerified on the branch: no surviving "2.01x" or "faster than Citus" claim about the load path (the two remaining greps are the dimension-join table and the query latency sentence, which is like-for-like and fine). Stating the measurement, naming the fixture as separate, and saying plainly that the table does not draw the comparison is more useful than a corrected ratio would have been. One thing that will go stale: "A fair bulk comparison needs a Citus bulk arm in the harness, and that arm does not exist yet." #529 adds exactly that arm. If #529 lands first, this sentence is wrong on arrival; if this lands first, it wants a follow-up. Merge order is worth deciding rather than discovering. The defect: a mislabelled cell in the latency tableThe citus row carries The totals show it cannot mean what the header says:
Citus is slower than heap across the 43 queries, so a 0.25 against heap in its row is impossible. The 0.25 is almost certainly columnar's geometric mean against citus, sitting one row down and under the wrong header. Two ways out, and either is fine: give the citus row its own geomean against heap (which would be greater than 1), or split the column into "against heap" and "against citus" and put 0.25 in the columnar row. Worth fixing before this merges: it is a published comparison table, and the number currently favours us in a cell that cannot support it. Same failure the bulk row had, one table down. Everything else in the section stands, including the loss table printing baselines beside ratios and the q29 counter-example. |
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 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 #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. #526 now says the bulk comparison is unmeasured, which is what the evidence supports; the number comes from a run of this arm. Refs #445, #465, #526.
|
Following up on the mislabelled cell. A complete three-arm run finished on the bench at 16:07 UTC today, and it settles what the 0.25 is. To be exact about provenance: this is not a recomputation of your 2026-08-08 numbers.
The two runs agree, so the 0.25 is not a run artefact
Sizes reproduce exactly: columnar 1,478,057,984 and citus 1,662,558,208 bytes, the same two numbers I corroborated this morning. The geometric means, computed per query over all 43
Your 0.49 and this run's 0.53 are the same quantity a run apart. Your 0.25 and this run's 0.28 are the same quantity a run apart. They are different quantities from each other, and the second one is columnar against citus. So the citus row's honest value in a column headed against heap is about 1.9 — citus columnar is roughly twice as slow as heap per query, geometrically. Nothing near 0.25 can go in that cell. Splitting the column is the better of the two exits I offered, because it lets the 0.25 stay:
I have left the citus geomean as The stale sentence is now false, not "will go stale"
#529 merged at 14:08 UTC. The arm exists, and this run is the first one to use it — the clone is at It also produced the first fair bulk number, on the full ClickBench sample:
Both arms take their ranges from the same That is the number #445 was actually asking for, and it does not favour us. Which is exactly why the sentence must not ship saying the arm is absent: the PR would be publishing "we cannot compare this yet" on the same day the comparison landed and went against us. Whatever else this table says, that result should be in it. Separate defect, in the harness rather than this PRThe win/loss count this PR quotes ("faster than heap on 35 of the 43") comes from a line that classifies on the rounded ratio. This run printed "beats heap on 33 and loses on 10"; comparing the unrounded values gives 36 and 7. I am not asking you to restate the count as 36. Three tenths of a percent is inside the noise of this harness, and calling those wins would be the same kind of overclaim as the 0.25. The defect is that a string formatted for display is being used as a number for a decision, and the fix is to compare the unrounded values — after which the honest reading of q13, q34 and q35 is "level", not "won". I will file it against the harness so it does not ride on this PR. Everything else in my 14:08 review stands. |
…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
Four sentences ran past 25 words. Split, no numbers changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WmQJqcXdwyuoAiHHt2znBr
ChronicallyJD
left a comment
There was a problem hiding this comment.
Reviewed both commits against the run's raw timings. The rewrite is a large improvement and almost all of it verifies exactly — but the cell I filed at 13:46 is still wrong, in the same row, and the header change has made it harder to see rather than fixing it.
What I checked and could not fault
Every load number and every quantity derived from one, recomputed from /srv/clickbench/raw_timings.tsv and cb529.log:
| claim | recomputed | |
|---|---|---|
| serial: columnar 2.39x slower than Citus | 447.4 / 187.2 = 2.3900 | ok |
| 16 workers: 1.76x slower than Citus | 86.6 / 49.1 = 1.7637 | ok |
| 16 workers: 1.72x faster than heap | 148.8 / 86.6 = 1.7182 | ok |
| our serial→bulk speedup 5.17x | 447.4 / 86.6 = 5.1663 | ok |
| their serial→bulk speedup 3.81x | 187.2 / 49.1 = 3.8126 | ok |
| 11.1 percent smaller than Citus (bulk pair) | 0.11122 | ok |
| 11.0 percent on the serial pair | 0.10996 | ok |
| 5.3x smaller than heap | 5.2897 | ok |
| totals 154.9 / 127.7 / 251.7 s | exact | ok |
| 33 wins, 4 ties, 6 losses vs heap | exact | ok |
| ties are q13, q19, q34, q35 | exact | ok |
| 39 wins, no tie, 4 losses vs Citus (q16 q17 q19 q33) | exact | ok |
all six columnar adds figures |
exact on unrounded times | ok |
The verdict counts land on the nose under #532's rule, including the tie set. I recomputed them independently before reading your text and got the same four queries.
The predicate correction is right, and I checked it rather than took it: queries.sql has q21-q24 on LIKE '%google%'/'%Google%', and q28 on URL <> '', q29 on Referer <> ''. Two of the six are not leading wildcards, exactly as you now say. Reading the added milliseconds rather than the ratio is the right instruction and q29 against q21 makes the case cleanly.
Still blocking: 0.28 is in the Citus row
| arm | total across 43 queries | geometric mean |
| heap | 154.9 s | |
| columnar | 127.7 s | 0.54 against heap |
| citus columnar | 251.7 s | 0.28 against citus |
0.28 is geomean(columnar / citus). I measured it: 0.2800. It is a property of the columnar arm, and it is sitting in the citus columnar row.
Changing the header from "geometric mean against heap" to "geometric mean" removed the visible contradiction without moving the number. Before, the cell disagreed with the column heading and a careful reader would stop. Now the cell is internally consistent and only the row is wrong, so nothing on the line tells the reader anything is off. Read literally, that row says citus columnar scores 0.28 against citus, which is 1.00 by construction.
The quantity that belongs in that row is missing entirely:
geomean(citus / heap) = 1.9103
That is the number which makes 0.28 interpretable — Citus is roughly 1.9x slower than heap per query, which is why beating it by 0.28 is a much weaker statement than beating heap by 0.53. Dropping it leaves the section with no way to see that.
Both numbers belong to arms, so put each on its own arm:
| arm | total across 43 queries | geomean vs heap | geomean vs citus |
|---|---|---|---|
| heap | 154.9 s | ||
| columnar | 127.7 s | 0.53 | 0.28 |
| citus columnar | 251.7 s | 1.91 |
Unlike last time, these are all this run's numbers, so they can go in as measured — nothing is carried over from 08-08.
And the columnar geomean is 0.53, not 0.54
I could not reproduce 0.54 under any aggregation rule:
hot = min(try2, try3) [the harness rule] 0.5348
min of all three tries 0.5342
try 3 only 0.5326
try 1 only (cold) 0.1270
arithmetic mean of ratios 0.9207
ratio of the totals 0.8244
Your totals match mine exactly, so we are aggregating the same way on the same data; 0.5348 rounds to 0.53.
To be fair about the direction: this one costs us. A lower ratio is a better result, so 0.54 understates columnar's advantage over heap. It does not flatter us and it is not the same class of error as the 0.28. But this section's whole subject is numbers that were wrong in sign, and a headline geomean that is off by a rounding step should not be the one thing left unchecked.
Summary
Two changes and this is ready as far as I am concerned: move 0.28 to the columnar row and give the Citus row its own 1.91, and correct 0.54 to 0.53. Everything else in the 2026-08-09 section I have verified against the raw data and it stands.
…ndprompt#445) Two defects in the 2026-08-09 ClickBench table, both found by @ChronicallyJD reviewing commandprompt#526, and both merged before the review landed. Mine. The geometric mean column mixed two quantities. The columnar row held columnar against heap and the citus row held columnar against CITUS, under a header made generic to accommodate the mismatch. So the column could not be read down, which is what a column is for. It now holds each arm against heap throughout, citus included at 1.91, and the columnar-against-citus figure of 0.28 is stated in prose beside it. Same defect commandprompt#493 is about: one label, one quantity. And the columnar geomean is 0.53, not 0.54. The true value is 0.534839. I had computed it to three decimals, read 0.535 off that, and rounded a second time to two. Double rounding, from a value already rounded for display. That is the same mistake as commandprompt#531, which I diagnosed and fixed earlier the same day, and then made in prose four hours later. commandprompt#531 was about a comparison decided on a formatted string; this is a number derived from one. The rule wants restating in the general form: do not compute from a value that was rounded to be looked at. Verified against /srv/clickbench/raw_timings.tsv: geomean columnar/heap = 0.534839 -> 0.53 geomean columnar/citus = 0.279971 -> 0.28 geomean citus/heap = 1.910337 -> 1.91 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WmQJqcXdwyuoAiHHt2znBr
The published ClickBench section carried a single 2026-08-05 run with two arms.
It reported columnar loading 3.8x slower than heap, and said nothing about Citus
or about
parallel_copy. On the exact question #445 was opened about, thatunderstates the engine.
This PR has been rewritten around the 2026-08-09 re-run. The bulk claim it
originally made was wrong, and wrong in sign. What follows is the corrected
version; the history of this branch has the original.
The correction, which goes against us
An earlier revision of this branch said the parallel loader was 2.01x faster
than Citus on the bulk path, and said a fair bulk comparison was unmeasured.
That comparison put our sixteen worker path against a single Citus
COPYconnection. Citus accepts concurrent writers into one columnar table and scales
well, so the claim rested on a premise nobody had checked. @ChronicallyJD asked
the question, measured it, and built the fair arm in #529: both bulk arms now
split the same file at the same boundaries using our own
pgcolumnar.file_split_offsets, with the same worker count, so they differ inengine and in nothing else.
COPYCOPYparallel_copyCOPYLike for like at sixteen workers we are 1.76x slower than Citus, not 2.01x
faster. We are 1.72x faster than heap and 11.1 percent smaller than Citus. Our
serial to bulk speedup is 5.17x against their 3.81x, which is a statement about
how our loader scales and not a win over them on load time.
The query analysis is re-derived from this run
Not carried over. Two things changed beyond the numbers.
The loss table is 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 rather than anything about columnar. 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 previous text used q29's low
ratio as the evidence for an ordering by columns materialised. On this run that
argument does not hold in the ratio column; it holds in the added milliseconds.
"Every one of these predicates has a leading wildcard" was false. q28 is
URL <> ''and q29 isReferer <> ''. Both are still unprunable by a min andmax statistic, but for a different reason, and the text now says which: a group
is prunable for
<> ''only when its minimum and maximum are both the emptystring. The conclusion that all six are #452 is unchanged.
Win and loss counts
Taken from the times, not from the printed ratio: 33 wins, 6 losses, 4 ties
against heap, and 39 wins against Citus. A tie is a query whose two arms are
closer to each other than the run to run scatter of their own repeated tries.
The harness itself counted this wrong, which is #531 and is fixed in #532. This
page should merge after that one so the document and the tool agree.
Two limits stated rather than discovered
Every arm was vacuumed and analyzed, which is not the normal state for a
columnar table because autovacuum cannot reach it (#415). And the columnar arm
runs with the analytical accelerators off, which is what a user gets by default.
The 2026-08-05 run is kept and labelled, as a second independent run of the same
benchmark.
Refs #445, #529, #531