feat: null aware RightAnti hash join execution + planning support - #23957
feat: null aware RightAnti hash join execution + planning support#23957saadtajwar wants to merge 22 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #23957 +/- ##
========================================
Coverage 80.98% 80.99%
========================================
Files 1106 1106
Lines 383495 383621 +126
Branches 383495 383621 +126
========================================
+ Hits 310590 310722 +132
+ Misses 54572 54561 -11
- Partials 18333 18338 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
cc @andygrove - looking forward to your feedback! I'll have the second PR with the planner support up shortly :) |
| if !matches!( | ||
| (join_type, partition_mode), | ||
| (JoinType::LeftAnti, _) | ||
| | (JoinType::RightAnti, PartitionMode::CollectLeft) // `PartitionMode::CollectLeft` is safe because `RightAnti` is probe-driven |
There was a problem hiding this comment.
Do we need the partition_mode check for CollectLeft here? Or should we just count on it being enforced/never chosen as anything but CollectLeft beforehand? I suppose it's possible to explicitly create a plan with a different partition mode if you bypass the SQL -> plan frontend and create directly? Or do we make the assumption that users will only create logical plans and not physical? 👀
|
cc @andygrove - just to update, this PR should now fully close out #23931 ! Just this one PR now with all changes instead of two :) |
…htanti-hash-join # Conflicts: # datafusion/sqllogictest/test_files/dynamic_filter_pushdown_config.slt
kumarUjjawal
left a comment
There was a problem hiding this comment.
Thank you for working on this @saadtajwar
I have left some comments. Do we have benchmark results for this?
| bounds = None; | ||
| } | ||
|
|
||
| let build_has_null = !left_values.is_empty() && left_values[0].null_count() > 0; |
There was a problem hiding this comment.
This can:
- Miss a NULL in the subquery and incorrectly return outer rows.
- Return a logically NULL outer key when the subquery is non-empty.
we should use logical NULL masks on both sides.
There was a problem hiding this comment.
Ah good catch - fixed to use those logical null masks!
| .store(true, Ordering::Relaxed); | ||
| } | ||
| match self.join_type { | ||
| JoinType::RightAnti => { |
There was a problem hiding this comment.
filtered null-aware joins should not beswapped or filtered null-aware RightAnti should be rejected.
There was a problem hiding this comment.
Thanks, done! Added that condition to can_swap_hash_join
| self.right_side_ordered, | ||
| )?; | ||
|
|
||
| // If null-aware RightAnti join, we don't want to emit NULL probe keys |
There was a problem hiding this comment.
always allocates a Vec and copies every unmatched index, even when the probe batch has no NULLs. This affects low-hit NOT IN queries that return many rows—the important target workload. Reuse the logical validity mask and skip the copy when it contains no NULLs.
There was a problem hiding this comment.
Ah I see, that makes sense, thanks! Pushed a change to address, please let me know your thoughts!
|
@kumarUjjawal thanks for the PR comments! Pushed some commits to address those, please let me know your thoughts! I couldn't find any existing benchmarks that exercises this path, so I just ran the below on my local machine! Please let me know your thoughts - thanks again for taking the time to review! -- large outer (nullable key)
CREATE TABLE big_outer AS
SELECT
CASE WHEN value % 17 = 0 THEN CAST(NULL AS BIGINT) ELSE value END AS k,
value AS payload
FROM range(5000000);
-- small subquery side (nullable key)
CREATE TABLE small_dim AS
SELECT
CASE WHEN value % 19 = 0 THEN CAST(NULL AS BIGINT) ELSE value END AS k
FROM range(20000);
SET datafusion.optimizer.join_reordering = true;
SET datafusion.optimizer.prefer_hash_join = true;
EXPLAIN
SELECT count(*)
FROM big_outer o
WHERE o.k NOT IN (SELECT k FROM small_dim);Query under test: SELECT count(*)
FROM big_outer o
WHERE o.k NOT IN (SELECT k FROM small_dim);Runner flags (same on both sides): --iterations 3 --partitions 2 --batch-size 4096 --memory-limit 2GPlans
Results
So for this shape the PR cuts peak join memory by ~250x (build moves from the outer table to the subquery) and is ~1.7x faster wall-clock on this machine |
kumarUjjawal
left a comment
There was a problem hiding this comment.
I have left few comments please let me know what you think.
| // Check if probe side (RIGHT) contains NULL | ||
| // Since null_aware validation ensures single column join, we only check the first column | ||
| let probe_key_column = &state.values[0]; | ||
| if probe_key_column.logical_null_count() > 0 { |
There was a problem hiding this comment.
This changes filtered LeftAnti results. A dictionary/run-end logical NULL is recorded globally before the JoinFilterm runs. For example, outer (1, A), subquery (NULL, B), with outer.group = inner.group: the NULL row is rejected, so theouter row should survive, but this suppresses it. Please revert this LeftAnti change for this PR, limit it to unfiltered joins, or make NULL handling filter-aware.
There was a problem hiding this comment.
Ah I see - limited to unfiltered, thank you for the catch
| bounds = None; | ||
| } | ||
|
|
||
| let build_has_null = |
There was a problem hiding this comment.
logical_null_count() now runs for every hash join, but build_side_has_null is only used by null-aware RightAnti. For dictionary/run-end keys this can add an unnecessary O(build rows) pass to unrelated joins. Can we compute it only for null-aware RightAnti?
There was a problem hiding this comment.
Yes! Sounds good, done
There was a problem hiding this comment.
The optimizer no longer swaps filtered joins, which fixes the SQL planner path. However, public HashJoinExec::try_new and protobuf decoding still accept filtered null-aware RightAnti, where the build-NULL check runs before the filter. I suggest rejecting that combination?
There was a problem hiding this comment.
Ah thank you, done
|
@kumarUjjawal thank you again for taking the time to review here! Addressed your comments in the latest commit - please let me know what you think! |
|
@saadtajwar can you please look at the failing ci |
Apologies - this should be fixed now. Really appreciate you reviewing as always! |
kumarUjjawal
left a comment
There was a problem hiding this comment.
Thank you @saadtajwar
LGTM!
Which issue does this PR close?
Rationale for this change (copied from issue):
DataFusion plans NOT IN (subquery) as a null-aware anti join, but HashJoinExec only supports null_aware = true for LeftAnti with a single join key (validated in datafusion-physical-plan/src/joins/hash_join/exec.rs). Since HashJoinExec always builds on the left input, the build side of a null-aware anti join is the outer table, not the subquery.
This has two costs:
Memory scales with the wrong side. For SELECT ... FROM big_fact WHERE key NOT IN (SELECT k FROM small_dim), the hash table is built over the entire fact table. Memory is O(outer) when it could be O(subquery).
The operator cannot be distributed. The null-aware logic coordinates three pieces of global state across probe partitions through in-process shared memory: probe_side_has_null: AtomicBool, probe_side_non_empty: AtomicBool, and the visited-build-row bitmap, with the last probe partition to finish emitting the unmatched build rows (hash_join/stream.rs). This is correct and cheap in one process, but engines that split probe partitions across processes get independent copies of all three and produce duplicated or incorrect rows. Ballista hit exactly this (apache/datafusion-ballista#2187) and currently has to force the join into a single task (apache/datafusion-ballista#2188), losing all parallelism.
What changes are included in this PR?
This PR adds physical execution support for null-aware
RightAntihash joins using theCollectLeftpartition mode.The build side now records whether it contains a NULL join key, and the join outputs no rows when the build side contains NULL, filters probe rows with NULL keys, and outputs all probe rows when the build side is empty.
For planner support,
JoinSelectionwas updated to swap a null-awareLeftAntijoin to a null-awareRightAntijoin when statistics show that the right side is smaller with the swapped join always using theCollectLeftpartition modeAre these changes tested?
Yes
Are there any user-facing changes?
NOT INqueries may now use a null-awareRightAntiphysical plan when the subquery is smaller. Query results remain unchanged, but these queries can use less memory and keep the outer table partitioned.