Repository navigation
perf: stop re-pairing marked build rows in the scope-key null-aware join - #25679
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25679 +/- ##
========================================
Coverage 82.48% 82.49%
========================================
Files 1140 1140
Lines 437614 437727 +113
Branches 437614 437727 +113
========================================
+ Hits 360986 361092 +106
- Misses 54834 54849 +15
+ Partials 21794 21786 -8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
9ff4d07 to
0bbdfa9
Compare
|
Thanks for this — the grouping idea fixes the pathology nicely. I rebased the last commit onto current main to check it against the repro sunchao raised on #26214 (#26214 (comment)). Conflicts are small: the multi-column Now that #25560 has landed, do you plan to take this out of draft? #26214 makes NOT NULL tuple elements of a multi-column |
## Which issue does this PR close? - None. ## Rationale for this change `HashJoinExec` supports SQL `NOT IN` through `null_aware = true`, in three forms: `LeftAnti`, `LeftMark` and `RightAnti`, each correlated or not. The rules for which rows such a join emits are spread out today: - `exec.rs`: `NullAwareMode`, four `JoinLeftData` fields and one `JoinBuildData` field that only some forms use, and the correlated per-row state built inside the build function. - `stream.rs`: about 700 lines inside the regular join stream. These are the batch-skip decision, the correlated marking pass and its helpers, the RightAnti NULL and UNKNOWN drops, and two final-stage functions. - `utils.rs`: a second copy of the final rule, used for the LeftMark column. The two copies of the final rule already drifted once: LeftMark checked logical NULLs since apache#25693, LeftAnti physical ones until apache#26578. This code changes often. Since July it gained four features (apache#23957, apache#21585, apache#19857, apache#26213) and seven correctness fixes (apache#23104, apache#23173, apache#25076, apache#25339, apache#25693, apache#26586, apache#26578), and apache#26214, apache#25679 and apache#26088 propose more. Each such change has to find the `NOT IN` rules in three files, usually about six functions, interleaved with the regular join. A change to the regular probe loop has to read past them too. This PR moves the rules into `hash_join/not_in.rs`, with one copy of the final rule, so a `NOT IN` change touches one module and a regular-join change doesn't need to read it. The join reaches it at four points: 1. `NotIn::try_new` decides when the plan is built or executed whether `null_aware` is legal for the join, and which form it is: `BuildRows { scope, output: Rows | Mark }` for LeftAnti and LeftMark, or `ProbeRows { scope }` for RightAnti, where `scope` is `Uncorrelated` or `Correlated`. 2. `NotInBuild::try_new` runs once per build. It has one variant per form, holding only that form's state. `JoinLeftData` keeps a single `Option<NotInBuild>`. 3. `NotInProbe::before_lookup` / `after_lookup` run around each probe batch's lookup, and `after_empty_build` replaces `after_lookup` when the build map is empty. They skip the batch, record what the probe saw, mark build rows or this batch's probe rows UNKNOWN, and drop the probe rows a RightAnti must not emit; for other joins they do nothing. 4. `NotInBuild::final_chunk` runs on the build rows emitted at the end. It applies one rule for an unmatched row's mark, used both by LeftAnti (keep the rows whose mark is FALSE) and by LeftMark (the mark column). ## What changes are included in this PR? Six commits, each compiling and passing clippy on its own: 1. **Move the correlated marking helpers into `not_in.rs`.** The code is unchanged, apart from one signature that now takes the build data and probe batch instead of the stream's private state structs. This commit also adds a test for correlated marking when every build value key is NULL. The full-key map is empty then, and the marking must still run before the empty-map shortcut; nothing covered this before. 2. **`NotIn` replaces `NullAwareMode`.** The legality checks, messages and order are the same. 3. **`NotInBuild` holds the per-form build state, with one final rule.** It replaces the `JoinLeftData` / `JoinBuildData` fields, the two final-stage functions in `stream.rs`, and `build_null_aware_left_mark_column` in `utils.rs`. 4. **The `NotInProbe` hooks replace the stream's `NOT IN` branches and fields.** The correlated RightAnti's per-batch UNKNOWN probe rows move from `ProcessProbeBatchState` into `NotInProbe`, which recomputes them on each batch's first lookup. 5. **Review follow-ups.** `HashJoinExec::not_in()` and the `not_in` parameters replace `null_aware_mode()` and `null_aware: Option<NotIn>`, the helpers used only inside `not_in.rs` become private, and `final_chunk` asserts that a probe side with a NULL key is not empty. 6. **Test the correlated RightAnti empty-build path.** `test_null_aware_correlated_all_null_build_values` also runs RightAnti, which must drop the UNKNOWN probe rows when the build map is empty (`NotInProbe::after_empty_build`). Types: `NullAwareMode` goes, and `NotIn`, `NotInScope`, `NotInOutput`, `NotInBuild` and `NotInProbe` come in. Gone: the `NOT IN` fields of `JoinLeftData`, `JoinBuildData`, `HashJoinStream` and `ProcessProbeBatchState` and their constructor parameters, 4 stream functions (`null_aware_skip_probe_batch`, `drop_null_probe_keys`, `null_aware_left_anti_final_indices`, `null_aware_left_mark_column`), and the second copy of the final rule. What does not change: - the planner rules, including the `JoinSelection` swap to RightAnti; - the dynamic-filter gate for null-aware joins; - the prefilter and prepared-build exclusions; - the cross-partition `ProbeCompletion`. ### Behavior notes No query results change. Two internal orders do: - **Reservation order.** For an uncorrelated LeftAnti or LeftMark join whose build key has newly materialized logical NULLs (a dictionary, REE or Null key), the NULL buffer is now charged to the memory reservation right after the visited bitmap. On main it was charged after the membership step's optional pruning bitmap. Under a tight memory limit, a different allocation may be the one that fails. The pruning bitmap only exists with a dynamic filter, which a null-aware join with a nullable build key doesn't get. - **The form is computed twice per execution.** The stream reads it from the build (`JoinLeftData::not_in`); `execute` derives it again (`HashJoinExec::not_in()`) for the dynamic filter. Main derived it three times (build, stream, accumulator). The two can only disagree if an executed plan is rebuilt and run again without `reset_state`, which is already unsupported. Main panicked in that case; this PR uses the build's form. ## Performance Benchmark bot (GKE `c4a-highmem-16`, aarch64) on `a9d4896d7e` against its merge base `702803d022`; the later commit only adds a test: - `null_aware_join`: geomean 0.989. Q01 and Q03 are 4% faster, Q02, Q05 and Q06 about 2% faster, Q08 2% slower (20.4 → 20.8 ms), the rest are within 1%. - `hj`: total 6870 → 6885 ms (+0.2%). Q22 and Q24 are unchanged. The queries the bot flags (Q11, Q14, Q15, Q17 slower; Q16, Q18 faster) move within their run-to-run spread. - `tpch10`: all 22 queries unchanged, total 7078 → 7049 ms. Earlier runs on an M-series Mac showed this refactor slowing `hj` Q24 by up to 9% through different LLVM inlining of the probe loop. That doesn't reproduce on the bot, so the PR leaves inlining to the compiler. Not measured on x86_64. ## What is the testing strategy for this PR? - New test: `test_null_aware_correlated_all_null_build_values`, covering LeftAnti, LeftMark and RightAnti with the probe in two batches, at every batch-size config. - Existing tests: - physical-plan unit tests; - the null-aware join fuzz test (`null_aware_join_fuzz`, with `extended_tests`), which covers uncorrelated and correlated RightAnti plans after the `JoinSelection` swap; - the `null_aware_*` and `subquery*` sqllogictest files, with no changes to expected results or `EXPLAIN` output. ## Are there any user-facing changes? No.
|
Following up on my earlier comment: #26214 is on hold until the scope-key marking stops re-pairing already-marked rows, which is what this PR does. Would you have a chance to pick this up again soon? If you're busy with other things, I'd be glad to help move it forward: I could open a PR that builds on your commit and rebases it onto current main (the null-aware join code has since moved to |
0bbdfa9 to
141ad74
Compare
|
Sorry for the delay @viirya. Marked as ready now! |
dc6edcc to
504e46c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Regression coverage does not validate grouped pairing with remaining unmarked rows or detect loss of the hash-chain traversal optimization.
1 open issue
What changed in this PR
This PR addresses #25438 by reducing repeated work in DataFusion’s scope-key null-aware joins, improving correlated NOT IN performance without intended result changes.
Changes:
- Skip marked rows during scope lookup and adaptively group remaining probe rows by hash.
- Share chunked pairing logic through
UnmarkedPairsand remove retained copies of NULL-build scope values. - Add a regression test for emitted-pair growth.
| File | Description |
|---|---|
datafusion/physical-plan/src/joins/hash_join/not_in.rs |
Optimizes scope matching, consolidates pairing logic, reduces retained state, and adds regression coverage. |
Review effort: Balanced
| self.pair_unmarked( | ||
| &mut build_rows, | ||
| groups[group[0] as usize].iter().map(|(_, row)| *row), | ||
| |b, p| (b, p), | ||
| )?; |
504e46c to
440b285
Compare
|
Thanks for picking this back up! I re-ran the repros on the current head (440b285). The build-side path is fixed: with 8192 × 8192 rows and a constant scope key (outer_t: a = NULL, b = 1; inner_t: x = 2, y = 1), the LeftMark SELECT count(r) FROM (SELECT a NOT IN (SELECT x FROM inner_t i WHERE i.y = o.b) AS r FROM outer_t o);goes from ~210 ms to ~1 ms.
SELECT count(*) FROM outer_t o WHERE a NOT IN (SELECT x FROM inner_t i WHERE i.y = o.b);plans as a null-aware Would it work to group the probe rows by scope hash for This matters for #26214: after #26213, its multi-column repro Fully marked buckets are walked again in every probe batch. Nothing carries over between probe batches, so each batch walks the whole marked chain at least once per group. The LeftMark query above at N × N rows with a constant key takes ~260 ms at 1M, ~960 ms at 2M and ~3.7 s at 4M. The no-scope cross product grows the same way ( Coverage of the grouped path. +1 to Copilot's point. Grouping starts only after more than 8 skipped matches per probe row, and only |
Prepares the fix for re-paired marked build rows in the scope-key null-aware join. No behavior changes. The cross product of the path without scope keys and the scope-key lookup now share UnmarkedPairs, which holds the chunk buffers and the refresh of the marked rows. The two lookup index buffers travel together as LookupBuffers. The scope-key lookup now yields full build and probe row indices, and the join checks the scope-key equality on each chunk of pairs. So NullValueBuildRows no longer keeps a copy of the scope values. Probe rows whose scope keys hold a NULL are left out before the lookup, because they are in no scope.
A unit test compares the marks of UnmarkedPairs with those of every pair, on random inputs: hash collisions between scopes, rows marked before the pairing or during it by other partitions, filters that pass none to all of the pairs, marks on build or probe rows, and batch sizes from 1 to 8192. Two tests pin the matches that the scope map lookups walk. A lookup per probe row walks every build row of its scope for every probe row, even after the rows are marked, so the walk grows with the product of the rows. The next commit changes this, and the test diff shows it. null_aware_scope_groups.slt compares LeftMark and RightAnti NOT IN queries with a reference over scopes of many rows and a residual filter that leaves some rows unmarked, for batch sizes 1, 7 and 8192 and 1 and 4 partitions.
A correlated NOT IN with an equality correlation gets a correlation scope
key. To decide which rows are UNKNOWN, the join looked up every probe row
in the scope map, and paired it with every row of its scope. A marked row
stays UNKNOWN, but it was still paired again, so the cost grew with the
square of the scope size:
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);
took about 538 ms with 100k rows in each table, against about 7 ms for a
NOT IN with only the `i.z < o.z` correlation (release `datafusion-cli`,
median of 10 runs). It now takes about 7 ms.
The lookup drops the matches whose build row is marked before it makes
pairs. That alone still walks each scope once per probe row, and a
RightAnti, which marks probe rows, still paired each probe row with its
whole scope. So the join counts the matches that the lookup per probe row
wastes. When they cost more than a sort, it estimates from a sample of
the rest of the probe rows whether groups of equal hashes save work:
- with marks on build rows, when a group holds more than one probe row,
which then share one walk of the hash chain;
- with marks on probe rows, when the pairs of a group fill more than a
chunk, so that the later chunks can skip the marked probe rows.
If they do, the join sorts the rest of the probe rows by hash, looks up
each hash once, and pairs each group like a cross product that drops the
rows as they are marked. A group of probe rows stops its walk of the hash
chain when all of them are marked. Otherwise the lookup per probe row
goes on, so scopes of a few rows never pay for a sort.
With marks on build rows, a check of the marks that drops nothing makes
the next checks less frequent, so a filter that marks nothing costs
little. Every pair now comes from a pairing that drops the marked rows,
so the marking closure no longer checks them again.
The unit tests pin the cost decisions and bound the matches walked, and
the test diff shows that the walk no longer grows with the product of
the rows.
440b285 to
ac9173e
Compare
viirya
left a comment
There was a problem hiding this comment.
Thanks for the update, this covers everything I raised. On ac9173e, with 8192 × 8192 rows and a constant scope key, the RightAnti form a NOT IN (SELECT x FROM inner_t i WHERE i.y = o.b) goes from ~295 ms on main to ~1 ms. With #26214 applied on top, its multi-column repros ((a, b) NOT IN (SELECT x, y FROM inner_t) with b/y NOT NULL, also planned as RightAnti) go from ~296 ms to ~1 ms as well. The medium-bucket shape that got slower last time is back to main (64 scopes: 3 ms → 3 ms; unique scope: 31 ms → 31 ms), and the RightAnti walk now grows linearly with the rows (13 / 25 / 51 ms at 1M / 2M / 4M). Agreed that the per-batch re-walk of fully marked scopes is fine as a follow-up, since the path without scope keys has the same cost.
The new slt and the brute-force test against every pair cover the grouped path well, including rows left unmarked by the filter and marks from other partitions.
Two small inline comments: the license header check failing on the new slt, and two doc comments that still say marked pairs never reach the join filter.
| /// A row stays UNKNOWN once it is marked, so pairs that decide a row already | ||
| /// known to be UNKNOWN are skipped, and the join filter is not evaluated for | ||
| /// them. Without scope keys this also ends the pairing as soon as every row |
There was a problem hiding this comment.
nit: since mark no longer calls retain_unmarked, this (and the UnmarkedPairs doc at L991–993, "drops the pairs that decide a marked row before it sends them") is now a bit stronger than what happens. A pair of a marked build row can reach the join filter when lookup_unmarked skips its check, and the per-probe-row lookup with marks on probe rows doesn't check at all. Both are fine since a pair only sets a mark, as the comment in lookup_unmarked says. Maybe something like "...so the pairing drops most pairs that decide a row already known to be UNKNOWN before the join filter is evaluated (see UnmarkedPairs::lookup_unmarked for the checks it skips)"?
Co-authored-by: Liang-Chi Hsieh <viirya@gmail.com>
A skipped check of the build row marks, and the lookup per probe row with marks on probe rows, let some pairs of a marked row reach the join filter. Such a pair only sets a mark that is already set.

Which issue does this PR close?
Rationale for this change
An equality correlation made a correlated
NOT INslower, not faster. The cost grew with the square of the scope size. This is the reproduction from the issue, on thenull_aware_joinbenchmark tables at 100,000 rows each:Release
datafusion-cli, median of 10 runs:mainQuery (b) is now as fast as query (a). Both queries return the same results as before.
What changes are included in this PR?
A correlated null-aware join marks a row UNKNOWN when a candidate pair passes the join filter.
LeftAntiandLeftMarkmark build rows, andRightAntimarks probe rows. A marked row stays marked. With a scope key, the candidate pairs come from a hash lookup on the scope key. Before this PR, each probe row was paired with every row of its scope, marked or not.The change is in
UnmarkedPairs::scope_matchesinhash_join/not_in.rs:RightAntiprobe row is still paired with its whole scope. So the join counts the matches that the lookup per probe row wastes. When they cost more than a sort, it estimates from a sample of the remaining probe rows whether groups of equal hashes save work:The PR has three commits, for review one at a time:
refactor: no behavior change. The cross product of the path without scope keys and the scope-key lookup share one struct,UnmarkedPairs. The scope-key lookup yields full row indices, and the join checks the scope-key equality per chunk. SoNullValueBuildRowsdrops its copy of the scope values.test: a randomized differential test, two tests that pin how many hash-chain matches the lookups walk today, andnull_aware_scope_groups.slt.perf: the change. The test diff shows that the walk no longer grows with the product of the rows.Cost on other shapes. Release
datafusion-cli, median of 3 interleaved runs, in ms,main → this PR. The outer side is all NULL, except the unique-key shape (50% NULL). "None" has no residual filter, "never" a filter that no pair passes, "always" one that every pair passes. One partition unless noted. The 64-row shape comes from a separate run, because its first run was noisy.RightAntinoneRightAntineverRightAntialwaysLeftMarknoneLeftMarkneverLeftMarkalwaysNo shape is more than 5% slower, and the remaining gaps are inside the run-to-run noise. Shapes where rows are marked are up to several hundred times faster.
Not in this PR. Each probe batch walks a fully marked scope again at least once. The path without scope keys has the same cost. Keeping each scope's unmarked rows across batches is a possible follow-up.
What is the testing strategy for this PR?
null_aware_*.sltfiles,subquery.slt, the full sqllogictest suite and thehash_joinunit tests pass.unmarked_pairs_match_brute_forcerunsUnmarkedPairson 3,000 random inputs and compares the marks with those of every pair. The inputs have hash collisions between scopes, rows marked before the pairing or during it by other partitions, filters that pass none to all of the pairs, marks on build or probe rows, and batch sizes from 1 to 8192. More than 100 inputs of each mark kind reach the grouped pairing.scope_lookups_stay_linear_in_rowsandmarked_probe_groups_stop_walkingbound the hash-chain matches that the lookups walk. Emitted pairs alone do not show a repeated walk.estimate_groups,groups_save_work,next_unchecked_lookups) are small pure functions with exact unit tests.small_scopes_are_not_groupedandlookups_skip_checks_while_nothing_is_markedcheck them through the pairing.null_aware_scope_groups.sltcomparesLeftMarkandRightAntiNOT INqueries with a reference built from an outer join and an aggregate. The scopes are large enough for the grouped pairing, and a residual filter leaves some rows unmarked. It runs withconfigMatrixfor batch sizes 1, 7 and 8192 and 1 and 4 partitions. With an instrumented build, I confirmed that both mark kinds reach the grouped pairing.cargo-mutantson the changed lines ofnot_in.rscatches every mutant except one, which moves the threshold for considering groups by one wasted match.Are there any user-facing changes?
No. Correlated
NOT INqueries with an equality correlation are faster.