bench: SQL benchmark suite for null-aware (NOT IN) joins - #25386
Conversation
A NOT IN subquery plans as a null-aware join, where an outer row that finds no match is TRUE only if neither side has a NULL in scope and UNKNOWN otherwise. Deciding that is cheap for an uncorrelated NOT IN, but a correlated one leaves its correlation predicate behind as a join filter, and the join has to evaluate that filter per candidate (build row x probe row) pair to work out which rows the NULLs actually reach. Without an equality correlation there is no scope key to narrow those pairs, so the cost grows with the NULL count times the opposite table's size. Nothing measured that shape, so add a null_aware_join suite covering it: - Q01-Q03 uncorrelated NOT IN across NULL fractions, linear in the table size, as the regression guard for the plain null-aware path. - Q04 the correlated shape with nullable keys that hold no NULL, so the zero-NULL baseline is separated from the per-pair filter work. - Q05-Q07 the same correlation at 1% and 50% NULL on each side, which is where that work shows up. - Q08 the same NULL fraction as Q06 but with an equality correlation, so the candidate pairs come from a hash lookup instead; the gap between the two is what the scope key buys. All tables are built inline from range(), so there is no data step. Sizes are knobs: NAJ_ROWS for the correlated queries, NAJ_LARGE_ROWS for the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tS7mXDfq2faC9Xe6EXtZB
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #25386 +/- ##
==========================================
+ Coverage 81.93% 82.32% +0.38%
==========================================
Files 1136 1137 +1
Lines 428859 431825 +2966
Branches 428859 431825 +2966
==========================================
+ Hits 351376 355488 +4112
+ Misses 56462 54848 -1614
- Partials 21021 21489 +468 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
kosiew
left a comment
There was a problem hiding this comment.
Thanks for adding this benchmark coverage. The new suite does a nice job covering both correlated and uncorrelated NOT IN shapes. I found one small integration issue with the default all benchmark path, but it is non-blocking.
| mkdir -p "${RESULTS_DIR}" | ||
| mkdir -p "${DATA_DIR}" | ||
| case "$BENCHMARK" in | ||
| all) |
There was a problem hiding this comment.
Small integration nit: all(default) is documented as running all benchmarks, but the all branch does not currently call run_null_aware_join, so the new regression benchmark gets silently skipped in the default aggregate run. Could we add run_null_aware_join here? If the default runtime makes it unsuitable for all, it would be good to explicitly document that instead.
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @adriangb , some non blocking suggestions
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec |
There was a problem hiding this comment.
expect_plan matches the {:#?} output, and HashJoinExec's Debug impl doesn't print null_aware, so this guard also passes for a plain (non-null-aware) join — Q08 on main is exactly that. Worth pinning, since the whole suite is about that flag:
.field("mode", &self.mode)
+ .field("null_aware", &self.null_aware)
.field("metrics", &self.metrics)then on Q02–Q08:
-expect_plan HashJoinExec
+expect_plan null_aware: trueFine as a follow-up
| -- same query at 50%. | ||
| SELECT count(*) | ||
| FROM small_outer o | ||
| WHERE o.id_n1 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z); |
There was a problem hiding this comment.
On main this suite measures wrong answers for Q05–Q08, so the "base" column in the table is the cost of skipping the work, not a slower/faster comparison. Checked against DuckDB at default sizes:
| Query | correct | main |
|---|---|---|
| Q05 | 7460 | 7450 |
| Q06 | 5010 | 5000 |
| Q07 | 10 | 0 |
| Q08 | 5530 | 10000 |
parquet_row_filter_skip already uses assert as a correctness canary for the same reason. Please add one per query (in #25339 if they need to stay red on main until it lands), and note in this PR's description that the base numbers for Q05–Q08 come from incorrect results. Example for Q07:
expect_plan HashJoinExec
+assert I
+SELECT count(*)
+FROM small_outer o
+WHERE o.id_n0 NOT IN (SELECT i.id_n50 FROM small_inner i WHERE i.z < o.z);
+----
+10
+
runFine as a follow-up. We can revisit the result later to figure out whether there's a bug in DataFusion, or whether the expected result legitimately differs from DuckDB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HashJoinExec's Debug output did not include null_aware, so `expect_plan HashJoinExec` also passed for a plain anti join. Add the field to Debug and require `null_aware: true` on Q02-Q07. Q01 has non-nullable keys, so it is not null-aware. Q08 plans as a plain mark join on main until apache#25339 lands, so it keeps only the HashJoinExec check here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each assert compares the NOT IN count with a reference count that does not use NOT IN, so it holds at every NAJ_ROWS / NAJ_LARGE_ROWS value. Q05-Q08 give wrong results on main (apache#25336), so their asserts go in apache#25339 together with the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed reviews, thanks for the approvals folks! |
|
It seems Q1-Q4 are fixed, should we go with Q5-Q8? The queries the PR's 3x–112x numbers come from have no correctness canary, and on main they return wrong results, so the "base" column is timing an incorrect fast path.
These asserts fail on main, so land them in #25339 (where they should pass) rather than here, and note in this PR's table that base Q05–Q08 are wrong-result timings: -- q05 / q06 (swap id_n1 for id_n50 in q06)
assert I
SELECT count(*) = (
SELECT count(*) FROM small_outer o
WHERE o.z = 0
OR (o.id_n1 IS NOT NULL AND NOT (o.id % 2 = 0 AND (o.id / 2) % 1000 < o.z))
)
FROM small_outer o
WHERE o.id_n1 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z);
----
true
-- q07: inner v = 0 is NULL and has z = 0, so it is in scope for every o.z > 0
assert I
SELECT count(*) = (SELECT count(*) FROM small_outer WHERE z = 0)
FROM small_outer o
WHERE o.id_n0 NOT IN (SELECT i.id_n50 FROM small_inner i WHERE i.z < o.z);
----
true
-- q08
assert I
SELECT count(*) = (
SELECT count(*) FROM small_outer o
WHERE o.z > 900
OR NOT EXISTS (SELECT 1 FROM small_inner i WHERE i.k = o.k AND i.z < o.z)
OR (o.id_n50 IS NOT NULL
AND NOT (o.id % 2 = 0 AND (o.id / 2) % 16 = o.k AND (o.id / 2) % 1000 < o.z))
)
FROM small_outer o
WHERE o.z > 900
OR o.id_n50 NOT IN (
SELECT i.id_n0 FROM small_inner i WHERE i.k = o.k AND i.z < o.z
);
----
true |
|
Never mind that could be done follow-up 😆 |
Which issue does this PR close?
mainfirst, and that PR can then be measured against it.Rationale for this change
A
NOT INsubquery becomes a null-aware join. An outer row that finds no match is TRUE only when neither side has a NULL in scope. If a NULL is in scope, the result is UNKNOWN.This decision is cheap for an uncorrelated
NOT IN. For a correlatedNOT IN, the correlation predicate stays behind as a join filter. The join must then evaluate that filter for each candidate (build row x probe row) pair, to find which rows the NULLs reach. A non-equality correlation gives no equality key, so there is no scope key to reduce the number of pairs. The cost then grows with the NULL count multiplied by the size of the opposite table.No benchmark measured this shape, so there was no way to see the cost, or to tell a change from noise. Review on #25339 asked for this benchmark.
These are the measured results for #25339. Each number is the median of 60 iterations, taken as 6 interleaved rounds of 10 iterations on an Apple M4 Pro in release mode. The two sides are the base commit of #25339 and its head commit, each with this suite applied, so the comparison isolates the change in that PR.
Q01 to Q04 are the comparable rows, and they show no change. The base gives wrong results for Q05 to Q08, which is the bug that #25339 corrects. Thus the base numbers for those four rows are the time to calculate an incorrect result. They show the cost of correct results, not a regression. These are the results at the default sizes. DuckDB agrees with the "correct" column.
Q06 and Q07 are the rows that the review of #25339 asked about. They also give the baseline to measure any later optimization of that path against. Q08 has the same NULL fraction as Q06 and is 6 times cheaper, which is the value of the equality correlation.
What changes are included in this PR?
A
null_aware_joinSQL benchmark suite. There are no Rust changes. The runner finds suites inbenchmarks/sql_benchmarks/, and the load SQL makes each table fromrange(), so there is no data generation step.NOT INat different NULL fractions. Their cost is linear with the table size. They are the regression guard for the plain null-aware path.Both table sizes are knobs.
NAJ_ROWS(default 10000) sets the size for the correlated queries, whose cost grows with its square.NAJ_LARGE_ROWS(default 1000000) sets the size for the uncorrelated queries../bench.sh run null_aware_join # One query, with more rows for the correlated shape NAJ_ROWS=20000 ./bench.sh run null_aware_join 6This PR also adds the suite to
bench.sh(includingall) and documents it inbenchmarks/README.mdandbenchmarks/sql_benchmarks/README.md.There is one Rust change: the
Debugoutput ofHashJoinExecnow includesnull_aware. The suite'sexpect_plandirective matches that output, so Q02 to Q07 can requirenull_aware: true. Before this change,expect_plan HashJoinExecalso passed for a plain anti join.What is the testing strategy for this PR?
This PR adds benchmarks, so it adds no new tests. The existing
checked_in_suites_cover_benchmark_directoriestest inbenchmarks/src/sql_benchmark_suite.rscovers suite discovery, and it passes with the new directory.Each query has these checks:
expect_plan HashJoinExec. Q02 to Q07 also requireexpect_plan null_aware: true. Q01 has non-nullable keys, so it is not null-aware. Q08 plans as a plain mark join onmain, so fix: correlated NOT IN with a non-equality correlation returns wrong results #25339 adds itsnull_aware: truecheck.assertcorrectness canary. The assert compares theNOT INcount with a reference count that does not useNOT IN, so it is correct for all values ofNAJ_ROWSandNAJ_LARGE_ROWS. I checked each reference against theNOT INresult in DuckDB at six pairs of sizes. The same asserts for Q05 to Q08 fail onmain, so fix: correlated NOT IN with a non-equality correlation returns wrong results #25339 adds them together with the fix. I ran them on this branch merged with fix: correlated NOT IN with a non-equality correlation returns wrong results #25339, and all eight queries pass at the default sizes and at-r 3000 -l 1001.I ran all eight queries on this branch at the default sizes and at
-r 1500 -l 101. As a counterfactual check onmain, the Q05 to Q08 asserts fail, andnull_aware: truefails on Q01 and Q08.Each query also runs on
mainas written. Q08 uses the mark join form on purpose. The plainWHERE ... NOT INform with an equality correlation does not plan onmain, and a query that runs on only one branch cannot compare two branches.Are there any user-facing changes?
No. This PR changes benchmarks and documentation only. It does not change library code.
🤖 Generated with Claude Code