-
Notifications
You must be signed in to change notification settings - Fork 2.4k
bench: SQL benchmark suite for null-aware (NOT IN) joins #25386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| name Q01 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q1: uncorrelated NOT IN, non-nullable key on both sides. | ||
| -- No null-aware handling is needed at all, so this is the floor for the shape | ||
| -- and a guard against the anti-join path getting slower for everyone else. | ||
| SELECT count(*) | ||
| FROM large_outer o | ||
| WHERE o.id NOT IN (SELECT i.id FROM large_inner i); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| name Q02 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q2: uncorrelated NOT IN, 1% NULL on the subquery side. | ||
| -- A single NULL anywhere in the subquery makes every unmatched outer row | ||
| -- UNKNOWN, which is the cheap bulk path. Linear in the table sizes. | ||
| SELECT count(*) | ||
| FROM large_outer o | ||
| WHERE o.id NOT IN (SELECT i.id_n1 FROM large_inner i); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| name Q03 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q3: uncorrelated NOT IN, 50% NULL on the outer side. | ||
| -- Every NULL outer row is UNKNOWN on its own. Linear in the table sizes. | ||
| SELECT count(*) | ||
| FROM large_outer o | ||
| WHERE o.id_n50 NOT IN (SELECT i.id FROM large_inner i); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| name Q04 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q4: non-equality-correlated NOT IN, nullable keys that hold no NULL. | ||
| -- The correlation `i.z < o.z` has no equality, so it stays behind as a join | ||
| -- filter and the join has no correlation scope keys. With no NULL on either | ||
| -- side there is nothing to mark, so this is the zero-NULL baseline: it shows | ||
| -- what the correlated NOT IN shape costs before any per-pair filter work. | ||
| SELECT count(*) | ||
| FROM small_outer o | ||
| WHERE o.id_n0 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql | ||
| Original file line number | Diff line number | Diff line change | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,18 @@ | |||||||||||||||||
| name Q05 | |||||||||||||||||
| group null_aware_join | |||||||||||||||||
|
|
|||||||||||||||||
| load sql_benchmarks/null_aware_join/init/load.sql | |||||||||||||||||
|
|
|||||||||||||||||
| expect_plan HashJoinExec | |||||||||||||||||
|
|
|||||||||||||||||
| run | |||||||||||||||||
| -- Q5: non-equality-correlated NOT IN, 1% NULL on the outer side. | |||||||||||||||||
| -- With no correlation scope keys the join filter is evaluated over every | |||||||||||||||||
| -- (NULL outer row x probe row) pair, so the work scales with the NULL count | |||||||||||||||||
| -- times the subquery size. At 1% NULL that product is still small; Q06 is the | |||||||||||||||||
| -- 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); | |||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On
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. |
|||||||||||||||||
|
|
|||||||||||||||||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql | |||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| name Q06 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q6: non-equality-correlated NOT IN, 50% NULL on the outer side. | ||
| -- Worst case for the pairing that has no correlation scope keys: half the | ||
| -- outer rows are NULL, and the join filter is evaluated for each of them | ||
| -- against every subquery row, including for outer rows already known to be | ||
| -- UNKNOWN. Compare against Q05 (same query, 1% NULL) to see how the cost | ||
| -- tracks the NULL fraction. | ||
| SELECT count(*) | ||
| FROM small_outer o | ||
| WHERE o.id_n50 NOT IN (SELECT i.id_n0 FROM small_inner i WHERE i.z < o.z); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| name Q07 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q7: non-equality-correlated NOT IN, 50% NULL on the subquery side. | ||
| -- The mirror of Q06: a NULL subquery row makes every outer row in its | ||
| -- correlation scope UNKNOWN, so the filter is evaluated over every | ||
| -- (outer row x NULL probe row) pair. | ||
| 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); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| name Q08 | ||
| group null_aware_join | ||
|
|
||
| load sql_benchmarks/null_aware_join/init/load.sql | ||
|
|
||
| expect_plan HashJoinExec | ||
|
|
||
| run | ||
| -- Q8: NOT IN correlated by both an equality and a non-equality, 50% NULL on | ||
| -- the outer side. The equality correlation becomes a correlation scope key, so | ||
| -- the candidate pairs come from a hash lookup on `k` (16 groups here) instead | ||
| -- of the full cross product, and only those pairs reach the join filter. Same | ||
| -- NULL fraction as Q06, so the gap between the two is what the scope key buys. | ||
| -- | ||
| -- The `OR` keeps this a mark join: a bare `WHERE ... NOT IN` with an equality | ||
| -- correlation is the shape that does not plan at all before this suite's | ||
| -- motivating fix, and the point here is to measure the scope-key path, not to | ||
| -- pick a shape that only runs on one branch. | ||
| SELECT count(*) | ||
| 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 | ||
| ); | ||
|
|
||
| cleanup sql_benchmarks/null_aware_join/init/cleanup.sql |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| DROP TABLE small_outer; | ||
| DROP TABLE small_inner; | ||
| DROP TABLE large_outer; | ||
| DROP TABLE large_inner; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| -- Synthetic tables for the null-aware (NOT IN) hash join benchmarks. Built from | ||
| -- `range()`, so there is no data generation step. | ||
| -- | ||
| -- Columns, on both sides: | ||
| -- id Int64 NOT NULL -- NOT IN over this never needs null-aware handling | ||
| -- id_n0 Int64 NULL, 0% NULL -- nullable, but no NULL is ever present | ||
| -- id_n1 Int64 NULL, 1% NULL | ||
| -- id_n50 Int64 NULL, 50% NULL | ||
| -- z correlation value for a non-equality correlation (`i.z < o.z`) | ||
| -- k correlation key for an equality correlation (`i.k = o.k`), 16 groups | ||
| -- | ||
| -- `id_n0` is nullable so the planner still builds a null-aware join, but holds no | ||
| -- NULL, which is what makes it the zero-NULL baseline for the correlated queries. | ||
| -- `id` is only ~half covered by the subquery side, so the anti join returns rows | ||
| -- rather than degenerating to an empty or full result. | ||
|
|
||
| -- Small tables: used by the correlated queries (Q04-Q08), whose cost grows with | ||
| -- the product of the two table sizes. | ||
| CREATE TABLE small_outer AS | ||
| SELECT | ||
| value AS id, | ||
| CASE WHEN value < 0 THEN NULL ELSE value END AS id_n0, | ||
| CASE WHEN value % 100 = 0 THEN NULL ELSE value END AS id_n1, | ||
| CASE WHEN value % 2 = 0 THEN NULL ELSE value END AS id_n50, | ||
| value % 1000 AS z, | ||
| value % 16 AS k | ||
| FROM range(0, ${NAJ_ROWS:-10000}); | ||
|
|
||
| CREATE TABLE small_inner AS | ||
| SELECT | ||
| value * 2 AS id, | ||
| CASE WHEN value < 0 THEN NULL ELSE value * 2 END AS id_n0, | ||
| CASE WHEN value % 100 = 0 THEN NULL ELSE value * 2 END AS id_n1, | ||
| CASE WHEN value % 2 = 0 THEN NULL ELSE value * 2 END AS id_n50, | ||
| value % 1000 AS z, | ||
| value % 16 AS k | ||
| FROM range(0, ${NAJ_ROWS:-10000}); | ||
|
|
||
| -- Large tables: used by the uncorrelated queries (Q01-Q03), whose cost is linear | ||
| -- in the table size. | ||
| CREATE TABLE large_outer AS | ||
| SELECT | ||
| value AS id, | ||
| CASE WHEN value < 0 THEN NULL ELSE value END AS id_n0, | ||
| CASE WHEN value % 100 = 0 THEN NULL ELSE value END AS id_n1, | ||
| CASE WHEN value % 2 = 0 THEN NULL ELSE value END AS id_n50 | ||
| FROM range(0, ${NAJ_LARGE_ROWS:-1000000}); | ||
|
|
||
| CREATE TABLE large_inner AS | ||
| SELECT | ||
| value * 2 AS id, | ||
| CASE WHEN value < 0 THEN NULL ELSE value * 2 END AS id_n0, | ||
| CASE WHEN value % 100 = 0 THEN NULL ELSE value * 2 END AS id_n1, | ||
| CASE WHEN value % 2 = 0 THEN NULL ELSE value * 2 END AS id_n50 | ||
| FROM range(0, ${NAJ_LARGE_ROWS:-1000000}); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| description = "Null-aware (NOT IN) hash join benchmarks: uncorrelated, non-equality-correlated, and equality-correlated NOT IN across NULL fractions" | ||
|
|
||
| query_pattern = "q{QUERY_ID_PADDED}.benchmark" | ||
|
|
||
| [[options]] | ||
| name = "rows" | ||
| short = "r" | ||
| env = "NAJ_ROWS" | ||
| default = "10000" | ||
| values = ["10000", "..."] | ||
| help = "Rows per table for the correlated NOT IN queries (Q04-Q08). These evaluate the join filter over candidate build x probe pairs, so their cost grows with the square of this value." | ||
|
|
||
| [[options]] | ||
| name = "large-rows" | ||
| short = "l" | ||
| env = "NAJ_LARGE_ROWS" | ||
| default = "1000000" | ||
| values = ["1000000", "..."] | ||
| help = "Rows per table for the uncorrelated NOT IN queries (Q01-Q03), whose cost is linear in the table size." | ||
|
|
||
| [[examples]] | ||
| command = "cargo run --release --bin benchmark_runner -- null_aware_join" | ||
| description = "Run all null-aware join queries at the default sizes." | ||
|
|
||
| [[examples]] | ||
| command = "cargo run --release --bin benchmark_runner -- null_aware_join --query 6" | ||
| description = "Run only Q06, the worst case for filter evaluation over NULL build rows." | ||
|
|
||
| [[examples]] | ||
| command = "cargo run --release --bin benchmark_runner -- null_aware_join -r 20000" | ||
| description = "Run with 20,000 rows per table for the correlated queries (~4x the filter work of the default)." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expect_planmatches the{:#?}output, andHashJoinExec'sDebugimpl doesn't printnull_aware, so this guard also passes for a plain (non-null-aware) join — Q08 onmainis 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:
Fine as a follow-up