Skip to content

perf: emit unmatched build rows in batch_size chunks in HashJoinExec - #25028

Open
jayzhan211 wants to merge 1 commit into
apache:mainfrom
jayzhan211:hash-join-chunked-unmatched
Open

perf: emit unmatched build rows in batch_size chunks in HashJoinExec#25028
jayzhan211 wants to merge 1 commit into
apache:mainfrom
jayzhan211:hash-join-chunked-unmatched

Conversation

@jayzhan211

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

For join types that emit build-side rows after the probe side is exhausted (Left, Full, LeftAnti, LeftSemi, LeftMark), HashJoinExec computed the final indices over the whole build side and materialized them as one RecordBatch before handing it to the output coalescer.

That batch is not bounded by batch_size, and it is not covered by the memory reservation. Worse, LimitedBatchCoalescer configures arrow's BatchCoalescer with biggest_coalesce_batch_size = batch_size / 2, which passes any larger batch through untouched. So a LEFT ANTI join over a 10M-row build side with few matches emitted a single ~10M-row batch downstream, regardless of datafusion.execution.batch_size.

NestedLoopJoinExec already emits its unmatched build rows in batch_size chunks; this PR brings HashJoinExec in line.

What changes are included in this PR?

  • HashJoinStream gets a new state, EmitUnmatchedBuildRows, entered from ExhaustedProbeSide by the last probe partition. It holds a BooleanBuffer snapshot of the visited bitmap (taken once, after every partition reported completion, so the lock is not held while emitting) and a cursor.
  • next_final_indices_chunk scans the snapshot from the cursor and returns at most batch_size final indices per call (LeftMark emits every row, so its chunks are plain ranges). Null-aware LeftAnti/LeftMark post-processing and fetch handling are unchanged and now run per chunk.
  • input_batches is still bumped once for the final phase and input_rows once per chunk, so metric values are identical to before.
  • get_final_indices_from_bit_map / get_final_indices_from_shared_bitmap in joins/utils.rs had no other callers and are removed.
  • New benchmark cases in hash_join_semi_anti.rs with a 1M-row build side and a 100K-row probe side (left_semi_build1m_h10, left_anti_build1m_h10, left_build1m_h10).

Benchmark (this branch vs. main, Apple Silicon, cargo bench --bench hash_join_semi_anti -- build1m):

case main this PR change
left_semi_build1m_h10 2.05 ms 1.97 ms -4%
left_anti_build1m_h10 6.21 ms 4.75 ms -24%
left_build1m_h10 7.37 ms 6.79 ms -8%

Peak RSS of the left_anti_build1m_h10 bench binary: ~369 MB on main vs ~160 MB on this branch (/usr/bin/time -l, 1M build rows, 900K unmatched).

Are these changes tested?

  • New join_emits_final_build_rows_in_batch_size_chunks test (Left/Full/LeftAnti/LeftSemi/LeftMark × batch sizes 1/7/8192 × perfect-hash-join on/off) asserts the output rows and that every output batch respects batch_size. On main 14 of its 30 cases fail the batch-size assertion.
  • New join_fetch_stops_final_build_rows_mid_chunk test checks a fetch that is reached in the middle of the final rows.
  • Existing hash join unit tests (493), join sqllogictests (joins, join_limit_pushdown, join_disable_repartition_joins, subquery), and the core join fuzz tests pass.

Are there any user-facing changes?

No result changes. Output batches of the affected join types are now bounded by batch_size instead of arriving as one batch holding every unmatched build row.

For join types that emit build-side rows after the probe side is
exhausted (Left, Full, LeftAnti, LeftSemi, LeftMark), HashJoinExec
materialized every final build row as one RecordBatch. That batch is
unbounded by batch_size and not covered by the memory reservation, and
LimitedBatchCoalescer passes batches larger than batch_size / 2 through
untouched, so a selective anti join over a large build side emitted a
single build-side-sized batch downstream.

Add an EmitUnmatchedBuildRows stream state that snapshots the visited
bitmap once (after every probe partition reported completion) and emits
at most batch_size final rows per poll, matching NestedLoopJoinExec.
Remove the now-unused get_final_indices_from_bit_map helpers.

Add tests for chunked emission across join types and batch sizes and for
a fetch reached mid-chunk, plus benchmark cases with a 1M-row build side.
let emit_visited = join_type == JoinType::LeftSemi;
let mut build_indices = Vec::with_capacity(batch_size.min(num_rows - start));
let mut idx = start;
while idx < num_rows && build_indices.len() < batch_size {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Unmatched build-side rows are now emitted in batch_size chunks instead of a single batch over the whole build side.

@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Sep 7, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.86777% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.71%. Comparing base (38de903) to head (93eedb2).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/hash_join/stream.rs 92.75% 1 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25028      +/-   ##
==========================================
- Coverage   81.71%   81.71%   -0.01%     
==========================================
  Files        1127     1127              
  Lines      416072   416150      +78     
  Branches   416072   416150      +78     
==========================================
+ Hits       339997   340058      +61     
- Misses      56090    56102      +12     
- Partials    19985    19990       +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.

@kosiew kosiew left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@jayzhan211, thanks for working on this. This looks good to me.

I like the approach of introducing a dedicated final-build-row emission state and snapshotting the visited bitmap once probing is complete. Emitting the remaining build-side rows in batch-size chunks avoids materializing the entire final result at once and keeps the output bounded by the configured batch size.

The chunking and fetch-limit tests cover the new behavior well, and the build-side-output benchmarks are a useful addition. The cleanup of the now-unused index helpers also makes sense with the new incremental approach.

I don't see any blocking issues or additional changes needed from my side. Thanks!

@jayzhan211
jayzhan211 added this pull request to the merge queue Sep 8, 2026
@jayzhan211

Copy link
Copy Markdown
Contributor Author

Thanks @kosiew 🚀

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to no response for status checks Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants