A standing join benchmark, and the control that makes it legible (#401) - #427
Conversation
…ommandprompt#401) Every other query on the benchmark page reads one table. Star schemas and dimension joins are a large part of what columnar storage is bought for, and we had no measurement of them. Not "we know and it is bad". We did not know. Five shapes: a no-join control, a selective dimension join, an unselective join, a multi-dimension star, and a wide projection under a join. Arms interleaved per shape rather than swept, because a sweep gives its first arm the cold cache (commandprompt#271). TimescaleDB is supported and announces itself as skipped when absent. The result, at 20,000,000 rows on realistic data: no join, the control 0.53x we are 1.87 times FASTER selective dimension join 1.36x wide projection under a join 2.98x storage 0.141x 7.1 times smaller Read the first two together. Same rows, same bytes, same encoding. Our vectorized aggregate only sits directly above our scan, so a join between the scan and the aggregate disables it, and we then compete row at a time. A join does not cost us through the join. It costs us by disabling the thing we are fast at. The harness ASSERTS that mechanism instead of describing it. It fails the run if the control is not vectorized and fails it if the join arm is. Either half alone is consistent with the feature simply being switched off. Two mistakes of mine are built into its shape. The data shape is an arm, not an assumption, because my first result on this issue used random() float8 and reported a gap the realistic shape more than halves. And the no-join control is mandatory, because I once refuted my own correct hypothesis by running the decomposition with the vectorized aggregate turned off, which made both arms equally slow. docs/benchmarks.md carried "we have not measured them. See commandprompt#401" from commandprompt#402. That is now false, so it is replaced by the measurement rather than left to rot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
…a heap table (commandprompt#401) The harness advertised BENCH_ARMS=timescale and, for that arm, ran case "$arm" in columnar) using="USING pgcolumnar" ;; *) using="" ;; esac which is a plain heap table. No hypertable, no columnstore. It would have run, produced plausible numbers, and labelled heap as TimescaleDB. That is the vacuous-arm shape I have spent the week filing against other people, behind a flag nobody had exercised. Building it properly turned up commandprompt#428 instead: pgColumnar and TimescaleDB both register a custom scan node named "ColumnarScan", so a parallel worker resolves the node to the wrong extension's callbacks and any parallel query over a compressed hypertable fails. Until that is fixed the arm could only run serially, which is not a fair comparison against two arms that may parallelise. So the flag is removed rather than left accepting a value that produces a mislabelled number, and the header says why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
The TimescaleDB arm found a pgColumnar bug before it produced a numberI said in the description that I had not run the TimescaleDB arm yet. Building it turned up First, a defect in this PR that I should not have shippedThe harness accepts case "$arm" in
columnar) using="USING pgcolumnar" ;;
*) using="" ;;
esacNo hypertable, no columnstore. It would have run, produced plausible numbers, and labelled What the arm needs, established empiricallyTimescaleDB 2.29 needs three steps and one of them is not a function: SELECT create_hypertable('f', by_range('ts'));
ALTER TABLE f SET (timescaledb.enable_columnstore = true, timescaledb.segmentby = 'host_id');
DO $$ DECLARE c regclass; BEGIN
FOR c IN SELECT show_chunks('f') LOOP CALL convert_to_columnstore(c); END LOOP; END $$;
Then it broke, and it is usAny parallel query over the compressed hypertable fails with
Both extensions register a custom scan node named What I propose for this PRLand it as it stands, with heap and columnar, and the timescale arm absent rather than Say if you would rather I held the whole PR until the arm exists. |
jdatcmd
left a comment
There was a problem hiding this comment.
The finding is the right one and the harness is right to assert it. One hole in the
ratio guard, which is the #418 shape again.
A join does not cost us through the join. It costs us by disabling the thing we are fast
at.
That is the sentence, and the S0-against-S1 pair is what makes it a measurement rather than
a story. 0.53x with no join and 1.36x with one, same rows and same bytes, is a much more
useful result than "we lose on joins" would have been.
Asserting the mechanism instead of narrating it is the part I would keep hardest:
ok premise: the no-join control really is vectorized
ok premise: a join between scan and aggregate disables it
ok premise: S1 uses the same join method in both arms (Hash Join)
That third one matters more than it looks. Without it a reader cannot tell whether the
1.36x is our scan or the planner choosing a different join for each arm.
Blocking, and it is small: the ratio guard misses a half-empty measurement
case "$h$c" in
*ERR*|"") ;;
*) r=$(awk -v a="$c" -v b="$h" 'BEGIN { printf "%.2f", a / b }') ;;
esac"$h$c" is only empty when both sides are. With one side missing it concatenates to a
non-empty string, falls through, and divides. Measured:
heap=[1500] col=[800 ] -> ratio=[0.53] correct
heap=[ ] col=[800 ] -> ratio=[inf] <-- prints inf
heap=[1500] col=[ ] -> ratio=[0.00] <-- prints 0.00, reads as a 100% win
heap=[ERR ] col=[800 ] -> ratio=[-] correct
heap=[ ] col=[ ] -> ratio=[-] correct
0.00 is the dangerous one. A columnar arm that produced no timing at all renders as the
best result in the table, in a document whose whole purpose is to be quoted.
timed() returns the empty string whenever the Time: grep misses, which is a different
path from the ERROR branch it already handles: a cancelled query, a psql connection
failure, or an output format change all land there rather than on ERR.
Test each side rather than the concatenation:
case "$h" in ''|*ERR*) r=- ;; *) case "$c" in ''|*ERR*) r=- ;; *) r=$(awk ...) ;; esac ;; esacor reuse check_ratio from test/lib.sh, which refuses an empty side and a zero on either
side, and now has a self-test for exactly this (#422, #425).
This is the same class as the bc failure that started #418, and it is worth fixing here
because a benchmark table is the artifact most likely to be pasted somewhere without the
run log beside it.
Not blocking
The - in the ratio column and the raw ERR in the value columns are good. The value
is visible rather than suppressed, so a reader sees the gap. Keep that.
Worth stating the run conditions in the docs table itself, not only in the PR. Median
of three, interleaved arms, serial, PG18.4 non-assert: that belongs next to the numbers,
because the numbers will outlive this PR description.
Fix the guard and this has my approval. The result itself I have no argument with.
…ndprompt#401) case "$h$c" in *ERR*|"") ;; *) r=$(awk ... a / b) ;; esac "$h$c" is empty only when both are. One empty side concatenates to a non-empty string, falls through, and divides: heap=[1500] col=[ ] -> 0.00 reads as a 100 percent win heap=[ ] col=[ 800] -> inf 0.00 is the dangerous one, in a table whose whole purpose is to be quoted somewhere without the run log beside it. timed() returns empty on every path the ERROR branch does not catch: a cancelled query, a lost connection, or psql changing its timing format. Replaced with a ratio() that tests each side separately, refuses ERR, and refuses a zero denominator. It is self-tested before any number is printed, because a ratio helper that silently passes a half-empty pair is worse than none. bench/ does not source test/lib.sh, so this is the local counterpart of check_ratio from commandprompt#418 and commandprompt#422. Same class as the bc failure that started commandprompt#418, and the third time this shape has turned up in my own work today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
…commandprompt#421) The log entry said: Owner decided on 2026-08-05 to keep the run-time fetch rather than take a durable in-tree copy, after the license was corrected. That decision is not visible anywhere in this repository. jdatcmd checked both threads: commandprompt#421 and commandprompt#424 carry only my comments, and no review. I wrote the anticipated outcome in the past tense, in the one file whose entire job is to record what was decided and by whom. My own PR body asks for the decision on the same page where the governance file records it as taken. Now reads "Proposed 2026-08-05 ... Owner decision pending", so the position and its author are recorded and the decision is left empty until it is made and visible. The entry also now names the question the fetch does NOT resolve. CC BY-NC-SA 4.0's NonCommercial term is about USE, and running the benchmark to produce numbers published in support of a commercial product is a use question that no distribution design changes. The PR resolved redistribution and left that unexamined. Also fixes the same ratio hole jdatcmd found on commandprompt#427: "$h$c" is empty only when both sides are, so one empty side divides and prints 0.00, which reads as a 100 percent win. Both ratios in this harness now go through a guarded ratio() that is proved before any number is printed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqprqkCXuH8SegiZejE1Tw
|
Fixed. Replaced with a Same fix applied to both ratios in the ClickBench harness on #424, which had it twice.
|
jdatcmd
left a comment
There was a problem hiding this comment.
Fixed, self-tested, and better than what I asked for. One inconsistency to note.
Every case I raised is handled, verified against your helper directly:
num=[1500] den=[800] -> 1.88
num=[ ] den=[800] -> -
num=[1500] den=[ ] -> -
num=[ERR ] den=[800] -> -
num=[800 ] den=[ERR] -> -
num=[800 ] den=[ 0] -> -
The self-test running before any number is printed is the part I would not have asked
for and would keep. A ratio helper that silently passes a half-empty pair is worse than
none, and this one proves itself on every run rather than being trusted.
One inconsistency, and you decide which way it goes
lib.sh's check_ratio and bench/'s ratio() now answer the same question differently:
lib.sh check_ratio 0 800 0.5 -> FAIL a side of the ratio is zero, so nothing was measured
bench ratio 0 800 -> 0.00
You closed the zero-numerator hole in check_ratio on #422 for exactly the reason that
applies here: "the thing we measured cost nothing" is nearly always "the thing we measured
did not happen". In a benchmark table a 0.00 reads as the most flattering number
possible, which is the same argument your own comment makes about it being quoted without
the run log.
Not blocking, because a genuine 0 ms timing is less reachable than a genuine 0 buffer
count: timed() extracts psql's Time: n.nnn ms, which is not 0 for a real query. But
two guards for the same job with different strictness, written the same week, will confuse
whoever reads them next. Either match them or say in the comment why bench is looser.
The TimescaleDB withdrawal
Withdrawing an arm rather than shipping one you could not stand behind is the right call
and I would rather see that than a number with a caveat attached. Nothing further from me
on it.
The finding itself I already agreed with: S0 against S1 is the measurement, and asserting
that both arms use the same join method is what makes the 1.36x attributable to our scan
rather than to the planner choosing differently.
Approving. Merge when you are ready, or after the matrix if you would rather.
commandprompt#427 added a Joins section to docs/benchmarks.md and this branch adds a ClickBench one, in the same place. Both are additive and both are kept, Joins first to match main's ordering. Verified by structure rather than by reading the diff: three top-level headings survive in the right order, Joins at 656, ClickBench at 722, and "What this page does not measure" still last at 838. docs_style passes at 5 checks.
Closes #401. @jdatcmd for review.
The deliverable on this issue was a standing join arm in the benchmark, not a number in
a comment. This is that, plus the docs correction I owe.
The finding, and it is one line of the table
20,000,000 rows, PostgreSQL 18.4 non-assert, serial, median of three, interleaved arms.
Same rows, same bytes, same encoding. A join does not cost us through the join. It costs
us by disabling the thing we are fast at. Our vectorized aggregate only sits directly
above our scan, so anything between the scan and the aggregate turns it off, and we then
compete row at a time against a format built for exactly that.
The harness asserts that, rather than saying it
This is the part I care about most, because the claim is the kind that is easy to state and
easy to be wrong about:
Either half of the first two alone is consistent with the feature simply being switched
off, so both are asserted. A different join method either side would make the comparison
meaningless, so that is asserted too, along with both fact tables carrying no index.
Both of my mistakes on this issue are built into its shape
The data shape is an arm, not an assumption. My first result here used
random()float8 metrics, which is maximum entropy and the one shape where a column store cannot
win, and I reported that we lose by up to 6.2x. Both shapes now run:
On incompressible data we lose even with no join at all. So compressibility is a second
and separate effect, and neither explains the other. Both are now measured rather than one
being assumed away.
The no-join control is mandatory. I once "refuted" my own correct hypothesis on this
issue by running the decomposition with the vectorized aggregate turned off, which made the
no-join arm unvectorized too, so both looked equally slow. S0 exists so that cannot happen
quietly again, and the premise above makes it loud if it does.
The docs correction I owe
docs/benchmarks.mdhas carried this since #402:That was true and is now false. It is replaced by the measurement, including the rows where
we lose.
docs_style.shpasses.What I am not doing here
Not proposing an implementation. The issue said measure before deciding on
set_join_pathlist_hook, and this is the measurement. What it changes is which question isfirst: whether a vectorized aggregate can survive a join at all now looks more valuable
than runtime filter pushdown, because it restores the winning path rather than making the
losing one cheaper. That is a larger question than a filter hook and I am not committing us
to it in a benchmark PR.
TimescaleDB is supported by the harness (
BENCH_ARMS=heap,columnar,timescale) andannounces a visible skip when the library is absent. I have not run that arm yet.
🤖 Generated with Claude Code