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% 81.93%
========================================
Files 1136 1136
Lines 428859 429152 +293
Branches 428859 429152 +293
========================================
+ Hits 351376 351628 +252
- Misses 56462 56479 +17
- Partials 21021 21045 +24 ☔ 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.
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, so those four rows show the cost of correct results, not a regression.
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.shand documents it inbenchmarks/README.mdandbenchmarks/sql_benchmarks/README.md.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. All eight queries were run on this branch. Each one assertsHashJoinExecin its plan through theexpect_plandirective.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