Skip to content

perf: buffer the NestedLoopJoin build side as coalesced chunks instead of one concat_batches allocation - #24820

Open
ranflarion wants to merge 1 commit into
apache:mainfrom
ranflarion:nlj-chunked-build
Open

perf: buffer the NestedLoopJoin build side as coalesced chunks instead of one concat_batches allocation#24820
ranflarion wants to merge 1 commit into
apache:mainfrom
ranflarion:nlj-chunked-build

Conversation

@ranflarion

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

NestedLoopJoinExec buffered the whole build side into one batch via concat_batches, which doubles peak memory while the copy runs (inputs and output coexist), and the concat output is never reserved in the memory pool, so the doubled peak is invisible to it. On an ~880 MB build side (repro in #24819, runnable with stock datafusion-cli), main peaks at 1738 MB RSS; with --memory-limit 1g it completes while peaking at 1739 MB, 1.7x its own limit. A single allocation also caps any one string column at i32::MAX bytes (the overflow reported in #23032) and can only be spilled or released wholesale.

This PR keeps the build side as target-batch-size chunks instead. Same repro after the change: 899 MB peak (1.02x the build side), 900 MB under -m 1g. Wall time on the repro improves ~20% since the concat copy is gone.

What changes are included in this PR?

  • JoinLeftData holds Vec<RecordBatch> chunks with prefix-sum row_offsets, a binary-search locate(global_row) -> (chunk, local_row), and the build schema (so an empty build side keeps its shape). Zero-row chunks are dropped at construction.
  • collect_left_input feeds arrow's BatchCoalescer with with_biggest_coalesce_batch_size(target/2): small batches are compacted to target size, batches at or above half the target pass through zero-copy. Reservation still charges each input batch's get_array_memory_size as before.
  • The build-side spill path writes through the same coalescer, so the spill file holds uniformly sized chunks and the memory-limited replay uses the read-back batches as the chunk list directly — the per-pass concat_batches in the memory-limited rebuild is gone too.
  • Probe and unmatched-left emission never cross a chunk boundary: ranges are clamped at chunk ends, take/slice use chunk-local indices, and the visited-left bitmap keeps global row numbers, so bitmap semantics (including the multi-partition rules from fix: refuse memory-limited NestedLoopJoin fallback for left-emission joins with a multi-partition probe side #24675 and the deferred emission from fix: emit deferred unmatched rows when memory-limited NestedLoopJoin exhausts its left side #24746) are unchanged. The output BatchCoalescer re-coalesces the occasionally smaller batch emitted at a chunk tail.

One behavior note: a sliced batch at or above half the target size is now retained as-is, keeping its parent allocation alive, where the old concat incidentally un-pinned it by copying. The reservation charges the full parent buffers, so the pool over-counts rather than under-counts in that case; slice-aware accounting (dedup by allocation) is a planned follow-up.

Are these changes tested?

Existing coverage: the full NLJ suite including the memory-limited matrix and the one-shot re-execution test, physical-plan lib tests, the join fuzz suite (--features extended_tests), the memory_limit integration tests, and the join sqllogictests all pass. New unit tests: chunks retain the input buffers by pointer identity (the zero-copy bypass), and zero-row chunks are dropped with locate() boundary checks.

Probe-throughput parity, medians over interleaved runs of ~1.6e10 pair evaluations, release builds, same machine:

build side arrives as main this PR
250,000 batches of 8 rows 7.358 s 7.204 s
8192-row batches 7.075 s 7.039 s

Are there any user-facing changes?

No. Plans, results, and metrics are unchanged; only the build side's in-memory layout and its peak memory differ.

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

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.56477% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.58%. Comparing base (5e168c9) to head (0e2df27).

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/nested_loop_join.rs 87.56% 11 Missing and 13 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #24820      +/-   ##
==========================================
- Coverage   81.58%   81.58%   -0.01%     
==========================================
  Files        1123     1123              
  Lines      406610   406743     +133     
  Branches   406610   406743     +133     
==========================================
+ Hits       331720   331826     +106     
- Misses      55452    55468      +16     
- Partials    19438    19449      +11     

☔ 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.

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.

NestedLoopJoin buffers the build side into a single concat_batches allocation: 2x transient peak, invisible to the memory pool

2 participants