Skip to content

perf: stop re-pairing marked build rows in the scope-key null-aware join - #25679

Open
adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:na-scope-key-lookup
Open

adriangb wants to merge 3 commits into
apache:mainfrom
pydantic:na-scope-key-lookup

Conversation

@adriangb

@adriangb adriangb commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

An equality correlation made a correlated NOT IN slower, not faster. The cost grew with the square of the scope size. This is the reproduction from the issue, with outer_t and inner_t at 100,000 rows each:

-- (a) non-equality correlation only
SELECT count(*) FROM outer_t o
WHERE o.id_n50 NOT IN (SELECT i.id_n0 FROM inner_t i WHERE i.z < o.z);

-- (b) the same, plus an equality correlation on k
SELECT count(*) FROM outer_t o
WHERE o.z > 900
   OR o.id_n50 NOT IN (SELECT i.id_n0 FROM inner_t i WHERE i.k = o.k AND i.z < o.z);

Release datafusion-cli, Apple M4 Pro, median of 10 runs:

Query Before After
(a) 13 ms 13 ms
(b) 415 ms 10 ms

Query (b) is now as fast as query (a). Both queries return the same results as before.

The null_aware_join benchmark suite, default sizes, median of 3 interleaved runs of each side (CARGO_PROFILE_RELEASE_LTO=thin for both sides):

Query Before (ms) After (ms) Change
Q01 3.71 3.70 1.00x
Q02 2.63 2.66 1.01x
Q03 2.27 2.27 1.00x
Q04 0.30 0.31 1.02x
Q05 0.54 0.55 1.02x
Q06 0.54 0.55 1.02x
Q07 0.49 0.49 1.00x
Q08 12.85 0.90 0.07x
Q09 0.41 0.42 1.01x

Only Q08 is a null-aware join with a correlation scope key. The other queries do not reach the changed code, and their changes are noise.

What changes are included in this PR?

A correlated null-aware join marks a build row UNKNOWN when a candidate pair passes the join filter. A marked build 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 found all the build rows in its scope, and the marked build rows were removed only after the lookup made the pairs. A marked build row was thus paired again with every later probe row in its scope.

The change is in UnmarkedPairs::scope_matches in hash_join/not_in.rs:

  1. The lookup drops the matches whose build row is marked, before it makes pairs. The check is one bit per match.
  2. That alone is not sufficient. Each probe row still walks its whole scope, so (b) went only from 1.8 s to 1.0 s of CPU. So the join counts the matches it skipped in a probe batch. When they cost more than a sort of the probe rows, the join sorts the rest of the probe rows by scope hash. Then each hash is looked up once, and its group of probe rows is paired like the cross product of the path without scope keys: the marked build rows are dropped after each chunk of pairs, and the pairing of a group stops when no unmarked build row is left.
  3. A build row belongs to one scope hash only. So the join refreshes the build rows of a group only after a chunk that can contain that group's pairs. It does not take the bitmap lock once per group.

UnmarkedPairs also serves the RightAnti path, which marks probe rows. A probe row is looked up only once, so that path never switches to grouping.

The PR has three commits, for review one at a time:

  1. refactor: no behavior change. The cross product of the path without scope keys and the scope-key lookup share one struct, UnmarkedPairs, which holds the chunk buffers and the refresh logic. The scope-key lookup yields full row indices, and the join checks the scope-key equality per chunk, so NullValueBuildRows drops its copy of the scope values.
  2. test: a unit test that pins the current behavior. The pairs made for one build row grow tenfold when the probe rows grow tenfold.
  3. perf: the fix. The test diff shows that the pairs no longer grow.

Why not the alternative in the issue. The issue also proposes a choice per probe batch between the lookup and the cross product, with the scope equality applied to the cross-product pairs. That choice must be made before any build row is marked, so the estimate is wrong exactly in the case that matters: in Q08 the cross product is 16 times larger than the lookup, but it stops after a few probe rows. The switch in this PR is made during the batch, from the matches the join actually skipped. The cost before the switch is bounded, and shapes that never re-pair a marked build row never pay for a sort.

Why not a sort in every batch. I measured that first. The pairs of the plain lookup are cheap, about 5 ns each, so a sort of every probe batch costs the same as a few pairs per probe row. With a unique scope key, that made the join 40–70% slower in CPU. A fixed threshold on the average scope size did not work either: 8 build rows per scope, with one probe row each, still cost 70% more.

Cost on shapes that do not gain. I measured shapes with a scope key that is unique, or nearly unique, on the build side (2M × 2M rows, 1–50% NULL, no filter or a filter that is never or always true, 1 or 2 probe rows per scope). These never switch to grouping. CPU is 3–15% higher and wall time 0–10% higher. This comes from the bit check at lookup time, which runs in addition to the check before the join filter.

What is the testing strategy for this PR?

  • No result changes. null_aware_anti_join.slt, null_aware_mark_join.slt, subquery.slt, the full sqllogictest suite and the hash_join unit tests pass.
  • I compared eight scope-key queries against DuckDB 1.5.2: anti and mark joins, NULLs on both sides, NULL scope keys, one and two scope keys, residual filters, and 7 to 9 scope groups. I ran them at batch sizes 8192, 7 and 1, with 1 and 4 partitions, and on a larger variant (30k × 25k rows) where each scope holds hundreds of build rows. All results match.
  • New unit test unmarked_pairs_do_not_grow_with_probe_rows in hash_join/not_in.rs. It gives 100 build rows in two scopes to UnmarkedPairs::scope_matches, with 1,000 and then 10,000 probe rows, and marks every pair that it gets. It checks that all build rows are marked, and that the pairs made for one build row do not grow with the number of probe rows, at batch sizes 1, 16 and 8192. With the old behavior (no marked build row skipped at the lookup), a build row gets 500 pairs with 1,000 probe rows and 5,000 with 10,000, and the test fails. I added no sqllogictest, because the change does not change any result, so a new .slt case would pass before and after the change.

Are there any user-facing changes?

No. Correlated NOT IN queries with an equality correlation are faster.

@github-actions github-actions Bot added optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) physical-plan Changes to the physical-plan crate labels Sep 24, 2026
@codecov-commenter

codecov-commenter commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.30303% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.49%. Comparing base (95bb0a0) to head (0bbdfa9).

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/hash_join/stream.rs 91.60% 10 Missing and 11 partials ⚠️
...on/optimizer/src/decorrelate_predicate_subquery.rs 86.07% 3 Missing and 8 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@viirya

viirya commented Oct 20, 2026

Copy link
Copy Markdown
Member

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 mark closure from #19857, MapOffset → ProbeOffset (#26203), and equal_rows_arr now needs the comparator slot (the build scope values are normalized, so equal_rows_arr_with_normalized_left fits). With 8192 × 8192 rows and a constant scope key, a NOT IN (SELECT x FROM inner_t i WHERE i.y = o.b) goes from ~206 ms to ~1 ms, including the variant where one outer row sits in a scope that is never marked. The issue's Q08 at 100k goes from ~367 ms to ~10 ms, and the unique-scope 2M × 2M shapes cost +2–6% wall, in line with your numbers.

Now that #25560 has landed, do you plan to take this out of draft? #26214 makes NOT NULL tuple elements of a multi-column NOT IN into scope keys, which routes more queries into this path, so I'd like to stack it on top of this. If you don't plan to continue with it, we'd be happy to open a PR that builds on your commit.

jayzhan211 added a commit to cning112/datafusion that referenced this pull request Oct 24, 2026
## 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.
@viirya

viirya commented Oct 25, 2026

Copy link
Copy Markdown
Member

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 hash_join/not_in.rs), with you credited as co-author. Maybe sometime next week, if that works for you? Happy to hand it back anytime if you'd rather finish it yourself.

@adriangb
adriangb force-pushed the na-scope-key-lookup branch from 0bbdfa9 to 141ad74 Compare October 25, 2026 05:20
@github-actions github-actions Bot removed optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) labels Oct 25, 2026
@adriangb
adriangb marked this pull request as ready for review October 25, 2026 06:25
@adriangb

Copy link
Copy Markdown
Contributor Author

Sorry for the delay @viirya. Marked as ready now!

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 UnmarkedPairs and 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

Comment on lines +1145 to +1149
self.pair_unmarked(
&mut build_rows,
groups[group[0] as usize].iter().map(|(_, row)| *row),
|b, p| (b, p),
)?;
The null-aware join pairs every build row with each probe row in its
correlation scope, even after the build row is marked UNKNOWN. So the
pairs made for one build row grow with the number of probe rows. The
next commit fixes this, and this test shows the change.
A correlated NOT IN with an equality correlation gets a correlation scope
key. To decide which build rows are UNKNOWN, the join looked up every probe
row in the scope map and removed the build rows that were already marked
only after the lookup had produced the pairs. A marked build row stays
UNKNOWN, but it was still paired with every later probe row in its scope,
so the cost grew with the square of the scope size:

    SELECT count(*) FROM outer_t o
    WHERE o.z > 900
       OR o.id_n50 NOT IN (
            SELECT i.id_n0 FROM inner_t i WHERE i.k = o.k AND i.z < o.z);

took about 415 ms with 100k rows in each table, against about 13 ms
for a NOT IN with only the `i.z < o.z` correlation (release
`datafusion-cli`, median of 10 runs).

Now the lookup drops the marked build rows before it makes pairs. That
alone still walks each scope once per probe row. So when the skipped
matches of a probe batch cost more than a sort, the rest of the probe rows
are grouped by scope hash: each hash is looked up once, and the group is
paired like the cross product of the path without scope keys, which drops
the marked build rows after each chunk and stops when none is left. The
switch keeps the plain lookup for scopes that hold about one build row,
where a sort would only add cost. The query above now takes about 10 ms.

The pairs made for one build row no longer grow with the number of probe
rows, so the unit test now checks that.
@adriangb
adriangb force-pushed the na-scope-key-lookup branch from 504e46c to 440b285 Compare October 25, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Null-aware join: an equality correlation makes a correlated NOT IN slower, not faster

4 participants