Skip to content

perf: read NOT NULL tuple elements of a multi-column NOT IN as scope keys - #26214

Open
viirya wants to merge 3 commits into
apache:mainfrom
viirya:multi-column-not-in-scope-keys
Open

viirya wants to merge 3 commits into
apache:mainfrom
viirya:multi-column-not-in-scope-keys

Conversation

@viirya

@viirya viirya commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

A multi-column (a, b) NOT IN (SELECT x, y ...) plans as a null-aware hash join that decides UNKNOWN per build row. Without correlation keys, every row with a NULL in any tuple element is paired with every row on the other side, so the NULL path costs O(rows x NULL-valued rows).

A tuple element that cannot be NULL in scope on either side never makes the tuple comparison UNKNOWN: against a definite value it is either equal, which leaves the other elements to decide, or a definite mismatch, which makes the comparison FALSE. It only selects which rows a NULL in another element is compared with, which is exactly what a correlation scope key does. For example, with b and y NOT NULL, a NULL a only needs to be compared with the subquery rows whose y equals its b.

What changes are included in this PR?

  • Decorrelation (build_join): for a null-aware tuple, the elements are partitioned by may_be_null_in_scope, keeping their order within each group. The elements that can be NULL come first and are the NOT IN value keys (null_aware_value_keys counts only them); the others follow as scope keys, before any correlation keys. The "value keys first" layout is unchanged, so the hash join needs no change: it already narrows the NULL candidate pairs through its scope hash map.
    • All elements NOT NULL: the join is not null-aware, as before.
    • Exactly one nullable element: V = 1, a scalar NOT IN on that element correlated by the others.
    • LeftMark (projection / OR) is reordered the same way and stays three-valued exact.
    • A NOT NULL element that is not a hashable equi-join key (e.g. RunEndEncoded) now stays in the join filter like any correlation instead of being rejected. A nullable one is still rejected.
  • Docs: Join::null_aware_value_keys, HashJoinExec::null_aware_value_keys and the 56.0.0 upgrade guide describe the new key layout.
  • The Q11 / Q12 comments of the null_aware_join benchmark now describe the scope-key path: their z column (value % 1000) is NOT NULL.

What is the testing strategy for this PR?

  • null_aware_anti_join.slt: EXPLAIN plans for the reordering, with a correlation, with two nullable elements and one NOT NULL element, a constant element, NOT NULL on the outer side only (not reordered), and a mark join.
  • null_aware_multi_column.slt (batch size matrix): results with NOT NULL elements for LeftAnti, LeftMark and OR, equivalence with the scalar NOT IN shape, correlated subqueries, an empty subquery, a constant element and a non-hashable NOT NULL element.
  • null_aware_join_fuzz.rs: NOT NULL columns on both sides and seven mixed shapes, compared with the NOT EXISTS ... IS NOT FALSE reference. Placing the NOT NULL elements first fails this fuzz on its first seed.
  • roundtrip_logical_plan.rs: the existing multi-column case had only NOT NULL VALUES columns for b/y, so it now expects one value key; a variant with a nullable b keeps covering two value keys.

Benchmark null_aware_join against main (mean of 3 alternating runs):

NAJ_ROWS Q10 (no NULL) Q11 (1% NULL in subquery) Q12 (1% NULL in outer)
10000 0.34 ms -> 0.34 ms 3.55 ms -> 0.35 ms 0.72 ms -> 0.36 ms
20000 0.42 ms -> 0.47 ms 7.85 ms -> 0.49 ms 0.77 ms -> 0.51 ms

Q01 to Q09 are unchanged. Q10 (20000 rows, one outlier run dropped) is slightly slower: with a scope key, the build side eagerly builds a scope hash map over all build rows, which is only probed for NULL-valued probe rows. This is the existing behavior of correlated scalar NOT IN; building that map lazily could be a follow-up.

Are there any user-facing changes?

Faster multi-column NOT IN when some tuple elements are NOT NULL. Plans show fewer NOT IN value keys (null_aware(value_keys=N)) for such tuples. No API change.

viirya and others added 2 commits October 11, 2026 10:12
…keys

A tuple element that cannot be NULL in scope on either side never makes a
multi-column `NOT IN` comparison UNKNOWN, so it only selects which subquery
rows a NULL in another element is compared with. Decorrelation now moves
such elements after the nullable ones, so `null_aware_value_keys` counts
only the nullable elements and the others become correlation scope keys.
The null-aware hash join then pairs NULL-valued rows with the rows in scope
through a hash lookup instead of a cross product.

Closes apache#26088.

Co-authored-by: Isaac <no-reply@databricks.com>
…nd trip

Co-authored-by: Isaac <no-reply@databricks.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) proto Related to proto crate physical-plan Changes to the physical-plan crate labels Oct 11, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.80%. Comparing base (454ca08) to head (06c0885).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...on/optimizer/src/decorrelate_predicate_subquery.rs 95.83% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26214      +/-   ##
==========================================
- Coverage   82.80%   82.80%   -0.01%     
==========================================
  Files        1149     1149              
  Lines      452213   452226      +13     
  Branches   452213   452226      +13     
==========================================
  Hits       374473   374473              
- Misses      54945    54953       +8     
- Partials    22795    22800       +5     

☔ 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 requested review from comphead and sunchao October 12, 2026 02:06
@viirya

viirya commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

@sunchao @comphead Can you take a look?

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Found one performance regression in the scope-key promotion; details inline. The local reproduction checks candidate enumeration using extracted executor helpers with minimal storage shims, rather than end-to-end query timings.

Comment on lines +953 to +958
let null_aware_value_keys = if null_aware && in_values.len() > 1 {
let (nullable, non_nullable): (Vec<_>, Vec<_>) = in_values
.into_iter()
.zip(elements_may_be_null)
.partition(|(_, may_be_null)| *may_be_null);
let null_aware_value_keys = nullable.len();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Preserve early termination when promoting low-cardinality scope keys

This promotion can substantially increase work when the new scope key has few distinct values. For example, consider materialized tables with these schemas and contents:

-- outer_t(a INT, b INT NOT NULL): 8,192 rows of (NULL, 1)
-- inner_t(x INT, y INT NOT NULL): 8,192 rows of (2, 1)
SELECT *
FROM outer_t
WHERE (a, b) NOT IN (SELECT x, y FROM inner_t);

Previously, both elements were value keys, so for_each_unmarked_cross_product stopped once all outer rows were marked UNKNOWN. With b = y promoted to a scope key, for_each_scope_match enumerates every matching pair; retain_unmarked discards already-resolved rows only after the lookup has produced them. Both plans return the same result, but the new path keeps traversing the constant-key bucket.

A focused executable harness using the actual traversal/iteration helper bodies with minimal storage shims confirmed 8,192 candidate pairs before versus 67,108,864 afterward, with batch size 8,192 and both paths marking exactly 8,192 rows. These are helper-level work counts, not end-to-end query timings. The scalar early-exit path does not apply because the join still has two keys, and NULL marking runs before the empty full-key-map shortcut.

Could we preserve early termination or prune resolved scope entries before applying this transformation unconditionally, and add a constant/low-cardinality scope benchmark?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, confirmed. On the example from your comment, the scope-key path takes ~300 ms versus ~1 ms when b = y stays a value key. With #26213 merged it now plans as RightAnti and regresses the same way, and LeftMark too (218 ms vs 1 ms).

The root cause is not specific to this PR: the scope-key marking walks every pair in a bucket and drops already-marked rows only afterwards, so a correlated scalar NOT IN with a low-cardinality equality correlation already hits it on main (a NOT IN (SELECT x FROM inner_t i WHERE i.y = o.b) takes ~300 ms on the same data). #25679 works on this by skipping marked rows in the scope lookup and grouping probe rows by scope hash.

Since this PR only makes that path more common, I'll keep it on hold until a fix like #25679 lands, and then add a constant/low-cardinality scope-key query to the null_aware_join benchmark here.

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.
…-scope-keys

# Conflicts:
#	benchmarks/sql_benchmarks/null_aware_join/benchmarks/q12.benchmark
#	datafusion/core/tests/fuzz_cases/null_aware_join_fuzz.rs
#	datafusion/physical-plan/src/joins/hash_join/exec.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Core DataFusion crate documentation Improvements or additions to documentation logical-expr Logical plan and expressions optimizer Optimizer rules physical-plan Changes to the physical-plan crate proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Narrow multi-column NOT IN UNKNOWN checks by treating NOT NULL tuple elements as scope keys

3 participants