perf: replace PiecewiseMergeJoin's visited bitmap with a suffix watermark (~1.8x faster Left join) - #24579
Open
jayzhan211 wants to merge 2 commits into
Open
perf: replace PiecewiseMergeJoin's visited bitmap with a suffix watermark (~1.8x faster Left join)#24579jayzhan211 wants to merge 2 commits into
jayzhan211 wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #24579 +/- ##
========================================
Coverage 81.37% 81.37%
========================================
Files 1116 1116
Lines 397509 397645 +136
Branches 397509 397645 +136
========================================
+ Hits 323461 323581 +120
- Misses 55110 55121 +11
- Partials 18938 18943 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rationale for this change
PiecewiseMergeJoin(PWMJ) needs to know which buffered (left) rows were matched, so thatLeftandFulljoins can emit the unmatched ones in a final pass. Today it tracks thiswith a bitmap of one bit per buffered row, guarded by a mutex.
That bitmap is more machinery than the operator needs. PWMJ's whole trick is that both
sides are sorted, so when a streamed row finds its first match at buffered index
k,every buffered row from
kto the end matches — and the operator emits that entirerange at once. Marking is therefore never scattered: it is always the suffix
[k, buffered_len).A union of suffixes that all end at the last row is just another suffix:
So the entire bitmap only ever encodes a single number — the smallest
kseen. Keeping abit per row costs, for every matched streamed row, a mutex acquisition plus one
set_bitper emitted row, and the final pass then has to walk the bitmap to build an index array and
run
takeover it.The existence side of this operator (
LeftSemi/LeftAnti) already exploits exactly thisproperty — it stores one
AtomicUsizewatermark and no bitmap. In fact the oldbuild_visited_indices_mapdoc comment spelled the property out, but only for the existencepath. This PR carries the same observation across to the classic joins.
On a
LEFT JOINwith a 1M-row buffered side and a 4k-row streamed side (release build,datafusion-cli):~1.8x faster.
What changes are included in this PR?
BufferedSideDatadropsvisited_indices_bitmap; its existing existence-join fieldexistence_min_markedis generalized tomin_markedand is now maintained by bothstreams. The bitmap allocation, its memory reservation, the
Mutex, and theBooleanBufferBuilder/SharedBitmapBuilder/bit_utilimports all go away.classic_join.rs): a mutex lock plus oneset_bitper emitted row becomes asingle
fetch_min. Becausebuffer_idxonly moves forward within a stream batch, abatch_min_markedfield inBatchProcessStatemeans only the batch's first matchtouches the shared atomic at all.
get_final_indices_from_shared_bitmap+take_record_batchbecomesbuffered_batch.slice(0, min_marked). The unmatched buffered rows are exactly thecomplementary prefix, so the final pass is now zero-copy.
build_visited_indices_map()is removed — every arm other thanFull/Leftnamed a join type thattry_newrejects. A small unused row-countaccumulator in
build_buffered_data'stry_foldgoes too.Net: 136 insertions, 113 deletions across 5 files.
Why this is safe
The suffix property is syntactic, not a consequence of the merge algorithm. The bitmap had
exactly one writer, and both of its call sites passed the same range:
So every write was
[buffer_idx, buffered_len)by construction, and the union of those is[min buffer_idx, buffered_len)— regardless of sortedness, operator direction, NULLplacement, or how partitions interleave. The old and new encodings are therefore
unconditionally equal, not equal-under-an-assumption.
Output ordering is unchanged as well: the old reader returned ascending indices whose bit was
false, and the complement of
[min, len)is[0, min), also ascending.Are these changes tested?
Yes.
The two existing PWMJ fuzz entry points are merged into one differential test against a
NestedLoopJoinoracle,fuzz_pwmj_matches_nested_loop, now covering all six supportedjoin types (
Inner/Left/Right/Full/LeftSemi/LeftAnti) rather than the existencepair alone — 60 seeds x 4 operators, with NULL keys, duplicate keys, 1–3 streamed partitions
executed concurrently, and
batch_size = 3. The previous existence-only test and itscollector are folded in, so this adds coverage while shrinking the file's duplication.
Why an added test was warranted, stated precisely:
pwmj.sltrunspartitions=1throughout, and every classic unit test builds the exec with a single-partition streamed side
at the default batch size. Neither reaches several partitions racing to run the final pass,
nor the mid-scan resume path. I confirmed this by mutation:
pwmj.sltfetch_min(k)→fetch_min(k + 1)min(num_rows)clamp (min_markedisusize::MAXwhen nothing matched)The second row is the gap this closes — the existing suite already catches coarse breakage,
but not the multi-partition / never-matched cases the new encoding introduces.
Also verified unchanged: the 23 PWMJ unit tests,
pwmj.slt, the fulljoins::suite, andclippy
-D warnings. As a further cross-check, old and new release binaries producebyte-identical results for inner / left / right / full / semi / anti /
<=over a 20k x 3kjoin with NULLs on both sides at
target_partitions = 4.Are there any user-facing changes?
No. This is an internal change to how matched buffered rows are recorded — query results,
output ordering, and public APIs are unchanged.
PiecewiseMergeJoinuses slightly lessmemory, since the per-buffered-row bitmap is no longer allocated.