feat(pwmj): support LeftSemi/LeftAnti existence joins via classic scan - #23870
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #23870 +/- ##
========================================
Coverage 81.17% 81.18%
========================================
Files 1109 1110 +1
Lines 388117 388566 +449
Branches 388117 388566 +449
========================================
+ Hits 315071 315471 +400
- Misses 54504 54513 +9
- Partials 18542 18582 +40 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Oh nice! @coderfender FYI |
|
@kumarUjjawal Can you review this PR? Sorry for tagging you everywhere |
No worries. I'm happy to help. Should we split the benchmark in a new pr so we can assess easily? |
|
benchmark PR: #24160 |
|
Thank you for this really nice PR, I got some suggestions:
This high-level approach LGTM, I might not be able to do detailed follow-up review timely, but it should be good to go if others can do the review. |
Thanks you @2010YOUY01 I will move this forward from here. |
viirya
left a comment
There was a problem hiding this comment.
Took a focused correctness pass over the LeftSemi/LeftAnti path — not a line-by-line/differential review, but I traced the four spots most likely to hide the anti-vs-semi asymmetry class of bug (cf. #24002), and empirically checked one of them. Sharing what I verified and two small suggestions. The high-level approach looks sound to me.
What I checked (all held up):
break 'stream_rowsafter the first match — the correctness of stopping the batch scan on the first match rests entirely on the sort invariant (first stream row yields the maximal matching buffered suffix, so later rows can only re-mark a subset). I built an adversarial single-batch, multi-row case (left.b1 > right.b1, buffered[2,4,6,8], streamed[1,3,5,7]) and confirmed it still emits all four expected rows. Correct — but the reasoning is subtle and load-bearing.- Empty / all-null streamed side for LeftAnti — the "emit all buffered rows" case (the failure mode from #24002, where filtering the probe side of an anti join wrongly creates output).
join_left_anti_empty_rightandjoin_left_anti_all_right_nullscover this and assert the full buffered set. Good. - Multi-partition final pass —
fetch_sub(1, SeqCst) == 1correctly gates the final emit to the last partition, matching the existing classic Left/Full coordination. - NULL join keys — never marked, so correctly excluded from Semi / included in Anti.
Two non-blocking suggestions:
- The
break 'stream_rowscomment explains what it does but not that its correctness depends on the streamed side being sorted in the same direction as the buffered side. Since a future change to the sort logic could silently break this, it'd help to state that invariant explicitly at thebreak. required_input_orderingdoesunimplemented!()for right-existence joins (exec.rs). That's a runtime panic guarded only by the planner gate (physical_planner.rs) not routing those types here — the two are far apart. When someone implements the RightSemi/RightAnti follow-up, the natural first step (opening the planner gate) would panic the optimizer if they forget this spot. Considernot_impl_err!here instead, so it degrades to an error rather than a panic.
This dovetails with @2010YOUY01's point about classic_join.rs now hosting semi/anti — if you do split the existence path into its own stream, suggestion (1)'s invariant and the naming confusion get resolved together.
For the RightSemi/RightAnti follow-up: the swap approach turns RightAnti into LeftAnti, which relocates the NULL-key handling into a new sort/operator-flip context that the current (all Left*) NULL tests don't exercise — worth dedicated null coverage there.
Scope caveat: this is a targeted correctness pass on the four points above, not a line-by-line audit or a differential (vs NestedLoopJoin) fuzz check — so treat it as "these specific high-risk areas look correct," not a full sign-off.
|
Thanks @2010YOUY01 @viirya @kumarUjjawal for review. I have refactored code and split existence join in separate stream. This also enabled few more optimisations. PTAL. |
|
Thanks @SubhamSinghal this PR is on my list today! |
comphead
left a comment
There was a problem hiding this comment.
Thanks @SubhamSinghal here first pass AI review,
And if we planning to enable existence joins, we would need becnhes and fuzz tests to prevent regressions.
P1 — Classic Left/Full PiecewiseMergeJoin can silently drop unmatched rows pre-PR; the fix ships without a regression test
Where: datafusion/physical-plan/src/joins/piecewise_merge_join/exec.rs — diff hunk around line 108 (streamed_partitions = self.streamed.output_partitioning().partition_count()), then passed to build_buffered_data in place of self.num_partitions.
Description: Pre-PR, remaining_partitions was seeded from PiecewiseMergeJoinExec::num_partitions, which the physical planner captures from session_state.config().target_partitions() at datafusion/core/src/physical_planner.rs:1791. The classic stream at classic_join.rs:223 uses
remaining_partitions.fetch_sub(1, SeqCst) == 1 as the sole trigger for the ProcessUnmatched final pass — the only path that emits unmatched buffered rows for JoinType::Left/JoinType::Full. The streamed side has Distribution::UnspecifiedDistribution (exec.rs:490-493), so EnforceDistribution only
inserts a round-robin when enable_round_robin && roundrobin_beneficial && n_rows > batch_size && current < target. Whenever that fails (enable_round_robin_repartition = false, or a source whose exact stats are <= batch_size), the streamed side has fewer partitions than target_partitions, fetch_sub(1)
never returns 1, and unmatched left/full rows are silently dropped.
Reason: Wrong result on a common configuration.
Evidence — concrete repro: target_partitions = 4, enable_round_robin_repartition = false, tables L(x) = {1, 10}, R(y) = {5}, both single-partition sources. Query: SELECT * FROM L LEFT JOIN R ON L.x < R.y. Pre-PR PWMJ: counter starts at 4, only one execute(0) fires, ProcessUnmatched never runs,
L.x = 10 is silently dropped. Same for FULL JOIN.
Scope note: The PR fixes this in exec.rs but adds no test covering the classic Left/Full path — pwmj.slt covers only join_type=Inner for the classic side (grep confirms), and the unit tests hard-code num_partitions=1. A regression that reintroduces the mismatch would slip through today's tests.
P1 — Extreme-key extraction uses sort_to_indices(..., Some(1)) (O(N log N) full sort) instead of the O(N) MinAccumulator/MaxAccumulator pattern DataFusion already uses
Where: datafusion/physical-plan/src/joins/piecewise_merge_join/existence_join.rs:430-438.
Description: The call passes Some(1) as a limit, but with nulls_first: false arrow-rs's sort_impl sets v_limit = valids.len() (arrow-ord-59.1.0/src/sort.rs:637-643) and dispatches to sort_unstable_by at limit == len, calling stdlib array.sort_unstable_by(cmp) — a full O(N log N) unstable
sort. The PR's own inline comment acknowledges this. The identical pattern (find batch extreme for join bounds) lives in hash_join's CollectLeftAccumulator at datafusion/physical-plan/src/joins/hash_join/exec.rs:1999-2068, which uses MinAccumulator/MaxAccumulator. Arrow also exposes typed
arrow::compute::min::<T> / max::<T> and min_string / max_string (arrow-arith-59.1.0/src/aggregate.rs).
Reason: Per-batch cost is O(N log N) plus an index-vector allocation and a take, where an O(N) linear scan with no allocation is idiomatic in-tree.
Evidence — concrete repro: Streamed side of 8192-row batches with no early termination via the watermark (highly selective join where the extreme never crosses the current watermark). Every batch pays ~106k comparisons + one Vec<(u32, K)> allocation to keep one row. MinAccumulator::update_batch on the
same batch is ~8192 comparisons on the packed slice with zero auxiliary allocation. This is the hot path — the whole point of the PR.
P2 — No benchmark despite "magnitudes faster" performance claim
Where: datafusion/physical-plan/src/joins/piecewise_merge_join/exec.rs:165-175 (doc claim); whole PR (missing bench).
Description: grep -n -i 'bench' /tmp/23870.txt returns zero matches. datafusion/core/benches/ has no PWMJ or range-join bench, and none is added. The only PWMJ-related bench-adjacent code is the pre-existing enable_piecewise_merge_join flag in benchmarks/src/tpch/run.rs and
benchmarks/src/tpcds/run.rs, which does not target LeftSemi/LeftAnti inequality joins as a first-class case.
Reason: Per the pr-review skill's benchmark rubric, performance-motivated changes must have measured evidence. Reviewers cannot verify or regression-guard the "magnitudes faster" claim without a criterion harness or a targeted TPC comparison. Future refactors of ExistencePWMJStream (e.g., the sort-based
extreme-picking or the fetch_min contention pattern) can silently regress this without CI signal.
P2 — No fuzz coverage for PWMJ LeftSemi/LeftAnti in join_fuzz.rs
Where: datafusion/core/tests/fuzz_cases/join_fuzz.rs (unchanged by this PR).
Description: join_fuzz.rs (1349 lines) already parametrizes LeftSemi/LeftAnti (lines 208-311), and JoinFuzzTestCase at line 668 compares SortMergeJoinExec, HashJoinExec, and NestedLoopJoinExec on randomized inputs. Adding a piecewise_merge_join() builder that consumes a range-predicate filter
is a direct extension of the same pattern (compare PiecewiseMergeJoinExec vs NestedLoopJoinExec output). No such extension is in the diff. The AtomicUsize watermark on BufferedSideData::existence_min_marked updated via fetch_min, plus the "last streamed partition to finish emits" contract, is exactly
the concurrency logic that static SQL tests cannot stress.
Reason: Per the pr-review skill, complex join operators require fuzz coverage. The algorithm relies on a cross-partition emit contract that varying partition counts and batch boundaries would exercise.
Evidence — concrete repro: target_partitions = 4, enable_round_robin_repartition = false, tables L(x) = {1, 10}, R(y) = {5}, both single-partition sources. Query: SELECT * FROM L LEFT JOIN R ON L.x < R.y. Pre-PR PWMJ: counter starts at 4, only one execute(0) fires, ProcessUnmatched never runs,
L.x = 10 is silently dropped. Same for FULL JOIN.
Scope note: The PR fixes this in exec.rs but adds no test covering the classic Left/Full path — pwmj.slt covers only join_type=Inner for the classic side (grep confirms), and the unit tests hard-code num_partitions=1. A regression that reintroduces the mismatch would slip through today's tests.
|
|
Thanks @SubhamSinghal I'll check it today |
comphead
left a comment
There was a problem hiding this comment.
Thnaks @SubhamSinghal we still gated by enable_piecewise_merge_join so the change is beta for users.
@viirya do you have anything to add?
lets have this PR open until next week to let other people a chance to be familiar with it
viirya
left a comment
There was a problem hiding this comment.
Came back for a closer look now that the existence path is its own ExistencePWMJStream — the split reads much cleaner, and the min/max-extreme + binary-search approach is a nice improvement over the earlier scan. LGTM.
I traced the new algorithm and then verified it with a differential fuzz against NestedLoopJoin (same SQL, enable_piecewise_merge_join on vs off).
Algorithm (traced, all correct):
- Extreme-key choice matches the operator:
</<=→ descending →max_batch,>/>=→ ascending →min_batch. Forl < ra buffered row matches iffl < max(r), so collapsing the whole streamed side to its extreme is sound. - The
compare == Lesstest in the binary search is correct precisely becauseJoinKeyComparatorbuilds the comparator with the operator'sSortOptions, soLessunder a descending comparator means the streamed key sorts ahead of the buffered one — i.e. the value-level match condition.match_on_equalhandles the<=/>=inclusive boundary. - NULLs: the extreme key ignores nulls; buffered nulls sit at the front (
nulls_first) and the scan starts past them, so they're correctly excluded fromLeftSemi/ included inLeftAnti. - Cross-batch / cross-partition marking via the atomic
existence_min_marked+fetch_min— the "marked set is always the contiguous suffix[min_marked, len)" invariant holds because each batch's extreme reaches the smallest matching index.
Differential fuzz (LeftSemi/LeftAnti vs NestedLoopJoin): 300 random datasets × {<,<=,>,>=} × {EXISTS, NOT EXISTS}, then a hardened pass with 4 partitions, up to 400 rows/side, 0–100% NULL density, and a tight value domain (many ties). 4800 checks, 0 mismatches. The existence path matches NestedLoopJoin exactly.
Which issue does this close?
Part of #17427 (Make
PiecewiseMergeJoinwork in DataFusion). AddsLeftSemi/LeftAntisupport, one of the epic's checklist items. Supersedes the stale #18392, taking the alternative approach that @2010YOUY01 suggested there (reuse the classic join path for generality) rather than a dedicated existence stream.Rationale for this change
An inequality-correlated
EXISTS/NOT EXISTS(e.g.WHERE EXISTS (SELECT 1 FROM r WHERE l.x < r.y)) has no equi-key, so it decorrelates to aLeftSemi/LeftAntijoin with a single range predicate. TodayPiecewiseMergeJoinExecrejects existence joins (not_impl_err!) and these queries fall back toNestedLoopJoinExec, which is O(n*m).Microbenchmark (20K × 20K rows, single inequality,
enable_piecewise_merge_joinon vs off), added in this PR as
piecewise_merge_join_semi_anti:What changes are included in this PR?
Existence joins (
LeftSemi/LeftAnti) forPiecewiseMergeJoin:LeftSemi/LeftAntiwith a single range predicate toPiecewiseMergeJoinExecin the physical planner (still gated behindenable_piecewise_merge_join, defaultfalse).LeftSemi= marked rows,LeftAnti= unmarked; NULL join keys are never marked, so they are correctly excluded from Semi and included in Anti). Only left-side columns are produced.RightSemi/RightAnti/Markremain unsupported (they require swapping the inputs); they are still rejected intry_newand excluded in the planner. Left as a follow-up.Are these changes tested?
Yes.
classic_join.rscoveringLeftSemi/LeftAntiacross<,<=,>,>=; NULL join keys; all-null streamed side; empty inputs;Date32keys; multi-batch and multi-partition streamed inputs; and the low-water-mark skip branch.pwmj.sltforEXISTS/NOT EXISTS(including NULLs) withEXPLAINassertions confirming the plan usesPiecewiseMergeJoin.Are there any user-facing changes?
No behaviour change by default:
enable_piecewise_merge_joinremainsfalse. When enabled, single-range-predicateLeftSemi/LeftAntijoins are planned asPiecewiseMergeJoininstead ofNestedLoopJoin. No API changes.