Repository navigation
fix: handle dictionary nulls in null-aware left mark joins - #25693
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #25693 +/- ##
=======================================
Coverage 82.73% 82.73%
=======================================
Files 1147 1147
Lines 449389 449437 +48
Branches 449389 449437 +48
=======================================
+ Hits 371788 371834 +46
- Misses 54943 54947 +4
+ Partials 22658 22656 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@sunchao Can you take a look? Thanks! |
sunchao
left a comment
There was a problem hiding this comment.
Thanks, @viirya. The logical-null changes fix both reported wrong-result paths. I ran all 100 matching null-aware join tests on d3ccef0; they passed. Applying only the new tests to parent 2306a4b produced the expected 10 failures, with the 20 pre-existing mark cases passing. Current-head CI has 40 successful and 3 skipped checks.
I found one introduced performance issue in final mark emission, detailed inline. Please bound the logical-null computation to the current output chunk or compute it once for the join before approval.
| probe_side_has_null: bool, | ||
| probe_side_non_empty: bool, | ||
| ) -> ArrayRef { | ||
| let build_key_nulls = build_key_column.logical_nulls(); |
There was a problem hiding this comment.
[P2] Avoid rescanning the full build dictionary for every output batch
emit_unmatched_build_rows calls this helper once per batch_size chunk, but build_key_column is the entire concatenated build column. In Arrow 60, DictionaryArray::logical_nulls() allocates an N-bit bitmap and walks all N keys when the dictionary values contain NULL. This makes final emission do N * ceil(N / batch_size) key inspections. It also runs for correlated joins even though null_indices_bitmap supplies the answer and the new buffer is unused.
I reproduced this with a null-aware CollectLeft LeftMark: dictionary<Int32, Int32> build values [1, NULL], alternating keys [0, 1, ...], an empty probe, and batch size 8192. Both revisions return exactly N non-null FALSE marks. With separate base/head target directories and the locked Arrow 60 dependencies, median times over three unoptimized operator runs were 0.185s vs 1.165s for 524,288 rows, and 0.369s vs 4.274s for 1,048,576 rows. Reversing run order retained the smaller-case slowdown; the equivalent plain Int32 control stayed comparable. These are diagnostic debug-build timings, not release query benchmarks.
Could we compute logical nulls only for the current contiguous LeftMark chunk (adjusting the index), or retain the bitmap once for final emission, and skip this work when the correlated bitmap is present? That preserves the fix without repeatedly scanning the full build side.
There was a problem hiding this comment.
Thanks, this is valid. I reproduced the repeated full-dictionary scan with 524,288 rows and batch size 8192. The diagnostic runtime dropped from about 693ms to 141ms after limiting logical_nulls() to the current contiguous output chunk. Correlated joins now skip this computation and continue using their precomputed bitmap. All 104 null-aware tests and the complete datafusion-physical-plan all-features suite pass.
There was a problem hiding this comment.
Thanks, @viirya. I confirmed that 4d50daa fixes the original flat-dictionary case: the same 524,288-row diagnostic improved from a median 1.152s on d3ccef0 to 0.209s on the new head.
One part of this P2 remains for nested dictionary build keys at the new slice/logical_nulls call. Slicing a DictionaryArray only slices its keys and retains all its values. When those values are themselves a dictionary, Arrow 60 recursively materializes the entire inner dictionary's logical-null bitmap on every output chunk. The outer slice therefore does not bound that inner scan.
I verified a nonempty nested-dictionary join executes and produces the expected TRUE/NULL/FALSE marks. To isolate final emission, I then used an empty probe and Dictionary<Int32, Dictionary<Int32, Int32>> build keys: outer keys 0..N, N inner keys all 0, leaf values [1, NULL, 4], batch size 8192. Both pre-PR base 2306a4b and current head produce exactly N non-null FALSE marks. At 524,288 rows, medians were 0.234s on base versus 0.954s on head; at 1,048,576 rows, 0.471s versus 3.307s. These are three-run unoptimized native-operator diagnostics with identical compiler/locked dependencies and separate target directories, not release SQL benchmarks.
Could we compute logical nulls once on entry to final emission and slice that bitmap for each chunk, accounting for any retained allocation? That avoids repeatedly traversing nested dictionary values as well as the flat keys.
There was a problem hiding this comment.
Thanks, confirmed. Slicing the outer dictionary retains its full child dictionaries, so chunk-local logical_nulls still rescanned nested values. Fixed in 3bc2308 by computing build-key logical nulls once during build collection, retaining only a non-empty bitmap, accounting for newly materialized buffer memory, and indexing the cached bitmap by the original build row during final emission. The 524,288-row nested-dictionary diagnostic dropped from about 561ms to 177ms locally. I also added nested-dictionary correctness coverage across five batch sizes; all 109 null-aware tests and the complete 2,409-test datafusion-physical-plan all-features suite pass.
sunchao
left a comment
There was a problem hiding this comment.
Thanks, @viirya. Re-reviewed 4d50daa with independent source passes and fresh runtime checks. The flat-dictionary build scan from my earlier review is fixed, and the chunk-local null indexing and correlated bypass look correct. All 100 matching null-aware tests pass; current-head CI has 40 successful and 3 skipped checks.
Two performance issues remain: the nested-dictionary part of final emission (updated in the existing thread), and repeated whole-probe logical-null scans on lookup resumption (inline below). Both were reproduced against the pre-PR base with identical result assertions. Please address these before approval. Timings in the comments are scoped to unoptimized native-operator diagnostics.
| } else { | ||
| probe_key_column.null_count() > 0 | ||
| }; | ||
| let probe_has_null = probe_key_column.logical_null_count() > 0; |
There was a problem hiding this comment.
[P2] Record probe logical nulls once per input batch
process_probe_batch calls this on every resumed lookup chunk, not just when a probe batch is first fetched. With dictionary values containing a NULL, Arrow 60's logical_null_count() walks every key in the whole probe batch, even if that NULL dictionary entry is unused. This change therefore adds another full probe-key scan for every lookup chunk.
I reproduced the slowdown with one build value 1 and one native probe batch of 524,288 dictionary keys all 0, dictionary values [1, NULL], and join batch size 8192. Both pre-PR base 2306a4b and head 4d50daa return exactly one TRUE mark. Median unoptimized operator time over three runs increased from 1.610s to 2.348s; the plain Int32 control stayed about 0.057s. Compiler, locked Arrow 60 dependencies, and fixture were identical, with separate target directories. This oversized-batch case is supported through native/custom execution plans; normal DataSourceExec scans split batches, so these are not ordinary SQL performance claims.
Please compute and record the probe summary only on the first chunk (state.offset == (0, None)), as the adjacent correlated bookkeeping already does. Keep the anti-join's shared NULL-hint check outside that guard so another partition can still trigger early exit between chunks.
There was a problem hiding this comment.
Thanks, confirmed. Fixed in 3bc2308 by computing and recording the probe logical-null summary only when state.offset is the initial (0, None). The shared NULL-hint check for LeftAnti remains outside that guard, so another partition can still stop resumed lookup early. The 524,288-row oversized-probe diagnostic dropped from about 1.29s to 910ms with identical result assertions. All 109 null-aware tests and the complete datafusion-physical-plan all-features suite pass.
sunchao
left a comment
There was a problem hiding this comment.
Thanks, @viirya. Re-reviewed 3bc2308 with independent source passes and fresh execution checks. Both performance findings from my previous review are addressed: build logical nulls are computed once with retained-buffer accounting, and probe summaries are recorded only on the first lookup chunk while the shared anti-join NULL hint remains checked on resumed chunks. I found no new actionable issue.
Validation on this head:
- All 2,340 datafusion-physical-plan library tests passed with locked dependencies.
- Re-ran the flat/nested dictionary and oversized-probe reproducers, including exact mark assertions and plain Int32 controls. The mixed nested-dictionary case passes here and fails on the pre-PR base with the expected FALSE-versus-NULL mismatch.
- The empty-probe nested-dictionary diagnostic now takes median 0.264s at 524,288 rows and 0.516s at 1,048,576 rows, without the previous repeated-child-scan growth. The 524,288-row oversized dictionary probe takes 1.625s on both this head and pre-PR base 2306a4b. These are three-run unoptimized native-operator diagnostics using the same Rust 1.98.1 compiler, locked Arrow 60 dependencies, and separate target directories, not release SQL benchmarks.
LGTM for the reviewed code. GitHub still reports merge conflicts, and this head has only two Process checks (one successful, one cancelled), not a substantive test-CI result. Please rebase and obtain current-head CI before merging.
3bc2308 to
b764107
Compare
|
Thank you @sunchao ! |
) ## Which issue does this PR close? - Closes apache#25691. ## Rationale for this change Null-aware `LeftMark` joins use physical null checks for dictionary keys. They miss valid dictionary keys that point to `NULL` dictionary values and return `FALSE` marks where SQL three-valued logic requires `NULL`. ## What changes are included in this PR? Null-aware `LeftMark` joins now use Arrow's logical-null APIs on both probe and build keys. The build-side logical-null bitmap is computed once during build collection, retained with memory accounting, and reused by final output chunks. Probe-side null summaries are computed once per input batch even when hash lookup resumes across several chunks. Correlated joins continue using their precomputed per-row null bitmap. ## What is the testing strategy for this PR? The diff adds three `HashJoinExec` test functions covering logical dictionary nulls on the build and probe sides, including nested dictionary build keys. `#[apply(hash_join_exec_configs)]` expands each function across five batch sizes, so Cargo reports 15 new test instances. The probe-side function also checks both probe-partition completion orders inside each instance. Ablation testing confirmed that the original build- and probe-side functions fail at every batch size without the correctness fix. With the fix, all 109 null-aware tests and the complete 2,409-test `datafusion-physical-plan` all-features suite pass. For 524,288-row debug diagnostics, bounding the original flat-dictionary scan reduced runtime from about 693ms to 141ms. The follow-up nested-dictionary diagnostic dropped from about 561ms to 177ms, and the oversized resumed-probe diagnostic dropped from about 1.29s to 910ms. ## Are there any user-facing changes? Null-aware mark joins over dictionary-encoded keys now return the correct nullable marks. There are no public API changes.
…ins (apache#26578) ## Which issue does this PR close? - Closes apache#26568. ## Rationale for this change `x NOT IN (subquery)` keeps a build row whose key is a dictionary entry pointing at a NULL value. `NULL NOT IN (5, 6)` is UNKNOWN, so the row should be dropped, as it is when the key is a physical NULL. ## What changes are included in this PR? The final step of the uncorrelated null-aware `LeftAnti` join now checks the logical nulls of the build key instead of `is_null`. They are computed once with the build side, reusing what apache#25693 added for `LeftMark`, so the field and its getter are renamed to `null_aware_build_key_nulls`. ## What is the testing strategy for this PR? New `test_null_aware_left_anti_build_logical_null` with the case from the issue. It fails without the fix. ## Are there any user-facing changes? No. Co-authored-by: Alejandro Borrego <210245044+abp002@users.noreply.github.com> Co-authored-by: Jay Zhan <jayzhan211@gmail.com>
## 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.
Which issue does this PR close?
Rationale for this change
Null-aware
LeftMarkjoins use physical null checks for dictionary keys. They miss valid dictionary keys that point toNULLdictionary values and returnFALSEmarks where SQL three-valued logic requiresNULL.What changes are included in this PR?
Null-aware
LeftMarkjoins now use Arrow's logical-null APIs on both probe and build keys. The build-side logical-null bitmap is computed once during build collection, retained with memory accounting, and reused by final output chunks. Probe-side null summaries are computed once per input batch even when hash lookup resumes across several chunks. Correlated joins continue using their precomputed per-row null bitmap.What is the testing strategy for this PR?
The diff adds three
HashJoinExectest functions covering logical dictionary nulls on the build and probe sides, including nested dictionary build keys.#[apply(hash_join_exec_configs)]expands each function across five batch sizes, so Cargo reports 15 new test instances. The probe-side function also checks both probe-partition completion orders inside each instance.Ablation testing confirmed that the original build- and probe-side functions fail at every batch size without the correctness fix. With the fix, all 109 null-aware tests and the complete 2,409-test
datafusion-physical-planall-features suite pass.For 524,288-row debug diagnostics, bounding the original flat-dictionary scan reduced runtime from about 693ms to 141ms. The follow-up nested-dictionary diagnostic dropped from about 561ms to 177ms, and the oversized resumed-probe diagnostic dropped from about 1.29s to 910ms.
Are there any user-facing changes?
Null-aware mark joins over dictionary-encoded keys now return the correct nullable marks. There are no public API changes.