Skip to content

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

Open
ranflarion wants to merge 5 commits into
apache:mainfrom
ranflarion:nlj-chunked-build
Open

ranflarion wants to merge 5 commits 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

codecov-commenter commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.25100% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.91%. Comparing base (c5257f0) to head (a886459).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...fusion/physical-plan/src/joins/nested_loop_join.rs 87.25% 13 Missing and 19 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #24820    +/-   ##
========================================
  Coverage   81.90%   81.91%            
========================================
  Files        1133     1134     +1     
  Lines      424993   425504   +511     
  Branches   424993   425504   +511     
========================================
+ Hits       348083   348541   +458     
- Misses      56278    56306    +28     
- Partials    20632    20657    +25     

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

@ranflarion,

Thanks for working on this. The chunked build-side representation looks like a good way to avoid the large concat_batches allocation while preserving the existing join semantics. I walked through the global-to-local row translation in probing, bitmap updates, unmatched-left emission, and spill/replay, and I did not find any blocking issues.

I have one non-blocking testing suggestion below. The implementation looks good to me otherwise.

@@ -3440,6 +3534,102 @@ pub(crate) mod tests {
Arc::new(TestMemoryExec::update_cache(&source))

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.

Could we add an execution-level regression test where the left child emits multiple sub-target batches, so JoinLeftData actually contains more than one coalesced chunk? It would be useful to exercise both a match and an unmatched-left row across a chunk boundary.

The new tests do a good job covering buffer reuse and locate(), but the existing join fixtures appear to provide a single build-side RecordBatch. With the smaller batch-size variants, that batch takes the large-batch bypass, so those tests do not exercise the new chunk transition paths during probing and final unmatched-left emission.

I am marking this as non-blocking since the global/local index translation looks consistent on inspection, but an end-to-end test would give us good regression coverage for the main correctness-sensitive part of this change.

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.

Added join_across_build_chunk_boundaries, which runs every join type over a 36-row build side delivered two ways, as 1-row batches that the load coalesces into chunks of exactly batch_size (4 or 12), and as 7-row batches that take the bypass so chunk edges fall off the output batch size and the probe and emission ranges have to be clamped. Rows 11|12 match on both sides of a boundary in the 4- and 12-row layouts, 27|28 in the 4- and 7-row layouts, every other boundary has unmatched rows on both sides, and the right side arrives as 1-row batches so batch size 12 exercises the range probe path and 4 the single-row one. Each case asserts the chunk count through collect_left_input, then checks the output against the same rows delivered as one batch, which is a single chunk. I mutation-tested it: swapping the local index for the global one in the range probe fails 20 of 40 cases, dropping the chunk-end clamp in unmatched-left emission fails 10, and that second one only shows up with the 7-row delivery, since exactly-batch_size chunks never need the clamp, which is why both deliveries are in the matrix.

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.

Added join_across_build_chunk_boundaries, which runs every join type over a 36-row build side delivered two ways, as 1-row batches that the load coalesces into chunks of exactly batch_size (4 or 12), and as 7-row batches that take the bypass so chunk edges fall off the output batch size and the probe and emission ranges have to be clamped.

@2010YOUY01

Copy link
Copy Markdown
Contributor

run benchmark nlj

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5595356938-2231-mjgzk 6.12.94+ #1 SMP Fri Jul 17 09:42:57 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing nlj-chunked-build (a5bbd22) to 5e168c9 (merge-base) diff

Run configuration
run benchmark nlj

Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing nlj-chunked-build (a5bbd22) to 5e168c9 (merge-base) diff

Run configuration
run benchmark nlj
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

Comparing HEAD and nlj-chunked-build
--------------------
Benchmark nlj.json
--------------------
┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Query     ┃       HEAD ┃ nlj-chunked-build ┃    Change ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ QQuery 1  │  255.38 ms │         255.64 ms │ no change │
│ QQuery 2  │  319.03 ms │         318.80 ms │ no change │
│ QQuery 3  │  413.08 ms │         414.14 ms │ no change │
│ QQuery 4  │ 1037.39 ms │        1041.33 ms │ no change │
│ QQuery 5  │  643.17 ms │         643.88 ms │ no change │
│ QQuery 6  │ 5309.33 ms │        5324.00 ms │ no change │
│ QQuery 7  │  646.26 ms │         646.71 ms │ no change │
│ QQuery 8  │ 5321.92 ms │        5345.64 ms │ no change │
│ QQuery 9  │  785.08 ms │         783.16 ms │ no change │
│ QQuery 10 │ 1270.93 ms │        1277.88 ms │ no change │
│ QQuery 11 │  356.02 ms │         360.06 ms │ no change │
│ QQuery 12 │  355.46 ms │         364.91 ms │ no change │
│ QQuery 13 │  125.42 ms │         125.32 ms │ no change │
│ QQuery 14 │  124.42 ms │         124.87 ms │ no change │
│ QQuery 15 │  123.58 ms │         124.77 ms │ no change │
│ QQuery 16 │  124.08 ms │         124.21 ms │ no change │
│ QQuery 17 │  127.17 ms │         124.62 ms │ no change │
└───────────┴────────────┴───────────────────┴───────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ Benchmark Summary                ┃            ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ Total Time (HEAD)                │ 17337.71ms │
│ Total Time (nlj-chunked-build)   │ 17399.93ms │
│ Average Time (HEAD)              │  1019.87ms │
│ Average Time (nlj-chunked-build) │  1023.53ms │
│ Queries Faster                   │          0 │
│ Queries Slower                   │          0 │
│ Queries with No Change           │         17 │
│ Queries with Failure             │          0 │
└──────────────────────────────────┴────────────┘

Distribution per query (min / mean ±stddev / max):

Comparing HEAD and nlj-chunked-build
--------------------
Benchmark nlj.json
--------------------
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Query     ┃                                  HEAD ┃                     nlj-chunked-build ┃    Change ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ QQuery 1  │     255.38 / 256.60 ±0.87 / 257.63 ms │     255.64 / 256.52 ±0.70 / 257.29 ms │ no change │
│ QQuery 2  │     319.03 / 319.70 ±0.87 / 321.38 ms │     318.80 / 319.67 ±0.87 / 321.35 ms │ no change │
│ QQuery 3  │     413.08 / 414.09 ±0.79 / 415.40 ms │     414.14 / 416.84 ±1.53 / 418.47 ms │ no change │
│ QQuery 4  │  1037.39 / 1039.43 ±1.68 / 1041.82 ms │  1041.33 / 1044.15 ±3.38 / 1050.30 ms │ no change │
│ QQuery 5  │     643.17 / 645.08 ±1.44 / 646.75 ms │     643.88 / 645.69 ±1.73 / 648.69 ms │ no change │
│ QQuery 6  │ 5309.33 / 5317.29 ±10.60 / 5338.27 ms │ 5324.00 / 5348.20 ±26.89 / 5397.75 ms │ no change │
│ QQuery 7  │     646.26 / 646.98 ±0.56 / 647.83 ms │     646.71 / 648.13 ±1.39 / 650.78 ms │ no change │
│ QQuery 8  │ 5321.92 / 5340.21 ±19.45 / 5369.59 ms │ 5345.64 / 5370.43 ±23.09 / 5408.13 ms │ no change │
│ QQuery 9  │     785.08 / 788.21 ±1.73 / 789.97 ms │     783.16 / 784.48 ±1.23 / 786.75 ms │ no change │
│ QQuery 10 │  1270.93 / 1277.98 ±5.96 / 1286.85 ms │  1277.88 / 1285.36 ±5.58 / 1294.03 ms │ no change │
│ QQuery 11 │     356.02 / 359.96 ±3.29 / 363.92 ms │     360.06 / 362.68 ±1.85 / 365.28 ms │ no change │
│ QQuery 12 │     355.46 / 361.58 ±3.55 / 366.27 ms │     364.91 / 370.86 ±3.79 / 374.94 ms │ no change │
│ QQuery 13 │     125.42 / 126.22 ±0.65 / 127.01 ms │     125.32 / 126.23 ±0.66 / 126.99 ms │ no change │
│ QQuery 14 │     124.42 / 126.09 ±1.03 / 127.52 ms │     124.87 / 125.90 ±0.93 / 127.54 ms │ no change │
│ QQuery 15 │     123.58 / 125.18 ±1.57 / 127.30 ms │     124.77 / 125.81 ±1.10 / 127.80 ms │ no change │
│ QQuery 16 │     124.08 / 125.38 ±1.25 / 127.27 ms │     124.21 / 124.86 ±0.75 / 126.33 ms │ no change │
│ QQuery 17 │     127.17 / 128.59 ±1.07 / 129.92 ms │     124.62 / 125.82 ±1.20 / 128.04 ms │ no change │
└───────────┴───────────────────────────────────────┴───────────────────────────────────────┴───────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ Benchmark Summary                ┃            ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ Total Time (HEAD)                │ 17398.59ms │
│ Total Time (nlj-chunked-build)   │ 17481.62ms │
│ Average Time (HEAD)              │  1023.45ms │
│ Average Time (nlj-chunked-build) │  1028.33ms │
│ Queries Faster                   │          0 │
│ Queries Slower                   │          0 │
│ Queries with No Change           │         17 │
│ Queries with Failure             │          0 │
└──────────────────────────────────┴────────────┘

Resource Usage

nlj — base (merge-base)

Metric Value
Wall time 90.0s
Peak memory 65.0 MiB
Avg memory 58.6 MiB
CPU user 215.7s
CPU sys 3.5s
Peak spill 0 B

nlj — branch

Metric Value
Wall time 90.0s
Peak memory 59.8 MiB
Avg memory 54.7 MiB
CPU user 214.8s
CPU sys 3.5s
Peak spill 0 B

File an issue against this benchmark runner

@2010YOUY01 2010YOUY01 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.

Thank you for working on it. Left some suggestions.

Comment on lines +1016 to +1025
/// Build-side data as bounded chunks, in input order. Kept as chunks rather than one
/// `concat_batches` result so buffering never needs input and output to coexist, and a
/// chunk that already arrived at target size is retained without being copied at all.
chunks: Vec<RecordBatch>,
/// Row index of the first row of each chunk, i.e. prefix sums over the chunk lengths.
/// The visited-left bitmap is indexed by these global row numbers.
row_offsets: Vec<usize>,
total_rows: usize,
/// Build-side schema, kept so an empty chunk list still knows its shape
schema: SchemaRef,

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.

Can we wrap it in a module so that callers can use it as if it were a single concatenated batch?

I feel the current implementation has several leaks -- operator logic has to understand the internal physical representation of the chunked build-side data. Some of these could be avoided with such a design.

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.

the operator now asks JoinLeftData::range(start, max_len) for the next run of rows and gets back a ChunkRange with the batch to address, the local start, the global start for the bitmap and the length, already clamped at the chunk end, so the translation and the clamp live in one place and the probe and unmatched-left paths just consume ranges.

let mut batches: Vec<RecordBatch> = Vec::new();
let mut chunks: Vec<RecordBatch> = Vec::new();
// Batches at or above half the target size pass through without being copied.
let mut coalescer = BatchCoalescer::new(Arc::clone(&schema), target_batch_size)

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.

This coalescer is not necessary, there is a hidden convention: each operator should promise to output batches coalesced to batch_size, so here we can assume input doesn't contain small batches.

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.

The convention does not hold at this operator's build side. coalesce_batches wraps only FilterExec, HashJoinExec and RepartitionExec, so aggregates, unions, memory tables and row-group tails arrive at whatever size produced them, and MemorySourceConfig forwards stored batches as they are.

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.

The convention does not hold at this operator's build side.

I think it should hold, but there might be some violations here and there, we should fix them instead.

The evidence for such convention is no operator is doing this input coalescing currently. I think it's a reasonable design, since we don't have to introduce complexity/execution overhead in all operators for such pre-processing, as long as all operator promise to produce batch-size coalesced output.

Arc::new(TestMemoryExec::update_cache(&source))
}

/// A build side that already arrives in target-sized batches is retained as-is: the chunks

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.

Can we move all tests to end-to-end tests, like sqllogictests, or remove them?

The issue with those UTs asserting internal properties is that they have a lot of correct assertions, but it's very hard to figure out what the end goal of the test is, and maintaining them is very hard. Today's AI likes to over-generate them.

For those two tests, I can't easily understand their test goals.

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.

Dropped join_left_data_drops_zero_row_chunks, the execution-level join_across_build_chunk_boundaries covers the row translation and I mutation-tested it against the same faults. Kept build_side_chunks_reuse_the_input_buffers with its goal stated in one line: it asserts by buffer pointer that a build side arriving at target size is retained without a copy, which is the property this PR exists for and which no sqllogictest can observe since the results are identical either way.

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed a5bbd22 against 5e168c9 with five independent review passes. I found one P2 memory-budget regression, described inline.

Validation: all 1,886 physical-plan library tests passed. An independent expected-row oracle passed 10,500 executions on each of base and head, including 3,900 executions that spilled per version. No incorrect results were found. The full workspace suite was not run.

Comment on lines +1282 to +1285
coalescer.push_batch(batch)?;
while let Some(chunk) = coalescer.next_completed_batch() {
spill_file.append_batch(&chunk)?;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Bound spill coalescing by memory

After reservation.free(), this loop keeps accumulating input until the coalescer reaches its row target or EOF. For wide strings arriving in small batches, this retains substantial unreserved build data and creates oversized spill batches that replay must accept over budget.

I reproduced this on base 5e168c9d0 and head a5bbd22a0 using a lazy source that emits 512 one-row Utf8 batches containing 64 KiB strings, a 256 KiB memory pool, the default 8192-row target, and an empty right input:

  • Base: 0.51 MiB peak live allocations.
  • Head: 64.18 MiB peak live allocations, with 32.17 MiB still live just before the left source returns EOF while the pool reports 0 bytes reserved.

These are tracked live Rust allocations relative to the pre-execution baseline, not RSS. The source retains no previous batches; both runs spill once and return zero rows. Setting the head's batch target to 1 restores approximately base memory usage, confirming that the amplification follows the new coalescing.

Please flush partial chunks according to a byte budget, or preserve the input-batch spill granularity, so spilling does not accumulate much more memory than the configured limit. Otherwise this workload can exhaust process memory despite spilling.

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.

Yes, reproduced. Fixed in cc8badf by finishing the coalescer's partial chunk when the load spills (its inputs were all reserved, so that copy is inside the budget the pool already granted) and writing the rest of the stream to the spill file batch by batch as it arrives, the way the base did.

… at input granularity, coalesce each replay pass, and move chunk range clamping into JoinLeftData::range
@2010YOUY01

Copy link
Copy Markdown
Contributor

This is the main part I'd suggest implementing differently. I'm happy to take it on myself — WDYT?

Idea: #24820 (comment)

I agree this seems optional if we only look at this PR. I feel strongly about this structure because we're likely to implement the same idea repeatedly in HJ and piecewise merge join, given #23076. A reusable struct would help there, so we don't have to handle the same complexity inside each operator.

@ranflarion

Copy link
Copy Markdown
Contributor Author

This is the main part I'd suggest implementing differently. I'm happy to take it on myself — WDYT?

Idea: #24820 (comment)

I agree this seems optional if we only look at this PR. I feel strongly about this structure because we're likely to implement the same idea repeatedly in HJ and piecewise merge join, given #23076. A reusable struct would help there, so we don't have to handle the same complexity inside each operator.

Understood. I'm happy to let you implement this. Let me know if I can be of any assistance.

@kosiew

kosiew commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

@ranflarion
Can you resolve the merge conflicts?

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

@ranflarion,

Thanks for working through the earlier review feedback. The chunk-boundary coverage looks much better now, and the spill draining issue has been addressed by processing input a batch at a time.

I found one remaining memory-budget issue in the replay path that I think needs to be fixed before merging. The new replay-side coalescing can temporarily hold both the reserved decoded input batches and the newly concatenated output batch at the same time, which can push the nested-loop join beyond its configured memory limit.

Thanks again for the updates.

let merged_batch = concat_batches(&left_schema, &pending_batches)?;
let n_rows = merged_batch.num_rows();
// Every batch of the pass is reserved above, so compacting it here stays within budget.
let chunks = coalesce_chunks(pending_batches, &left_schema, target_batch_size)?;

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.

I think this can exceed the configured memory limit during replay.

load_one_chunk keeps reserving decoded input batches until try_grow fails, and those batches are still live when they are passed to coalesce_chunks. The coalescing step then allocates the concatenated output without acquiring an additional reservation for that copy.

For a chunk made up of many small or wide batches, this means we can temporarily hold roughly both the fully reserved inputs and another full copied output. That brings back the same kind of memory amplification the earlier spill handling was trying to avoid, just on the replay side.

Could we either preserve the replay batches at their existing granularity, or explicitly limit or reserve the coalescing allocation before materializing the combined batch?

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.

You're right. I made some updates and added relevant testing. @2010YOUY01 feel free to use this as a reference if the decision is to do a replacement PR.

variant peak live allocation
PR head (both copies) 40.05 MiB
head with replay kept raw (transition copy only) 40.02 MiB
fixed with replay coalesced again (replay copy only) 13.74 MiB
fixed 8.14 MiB

…ed and replayed batches as they arrived so the memory-limited path never copies a pass
@2010YOUY01

Copy link
Copy Markdown
Contributor

@kosiew Hi, I'm doing a replacement PR due to #24820 (comment)

Happy to hear thoughts on this direction. If we can agree on it, I'll put together a PR in 1–2 days.

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

@ranflarion,

Thanks for continuing to work through the memory accounting issues here. I checked the earlier review concerns against the current branch. The chunk-boundary regression coverage is now present, and the replay-side concern is addressed by keeping replay batches raw in load_one_chunk. The new spill/replay allocator test also passes.

I found one remaining issue on the non-spilling path, which I left inline. coalesce_chunks can allocate a near-full target-sized copied chunk while all of the fully reserved input batches are still live. That means a build side can fit within the memory pool but still exceed the configured budget during coalescing.

I think we should fix this before merging. If @2010YOUY01 can deliver the reusable chunk abstraction discussed earlier promptly, I would prefer replacing this PR with that implementation. The segmented-batch representation and range mechanics seem likely to be useful for hash join and piecewise merge join as well. I would keep NLJ-specific bitmap handling, probe coordination, cancellation, and spill policy local to NLJ.

If the replacement timing is uncertain, I think this PR should stay open long enough to address the in-memory accounting issue first. Any replacement should also retain the current chunk-boundary tests and allocator-peak coverage for spilling and replay, and add equivalent coverage for a near-limit in-memory build.

let merged_batch = concat_batches(&schema, &batches)?;
// Compacted only once the whole side is reserved, so a load that spills never has a
// partially built chunk to materialize while the pool is exhausted.
let chunks = coalesce_chunks(batches, &schema, target_batch_size)?;

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.

I think there is still a memory-budget issue here on the non-spilling path. coalesce_chunks runs while batches still owns all of the fully reserved input batches, but the allocation for the concatenated chunk is not separately reserved.

As a result, a build side that fits just below the pool limit can temporarily hold both the full input and a near-full target-sized copied chunk. Being fully reserved tells us that the input fits, but it does not account for the output allocation that exists at the same time during coalescing.

I reproduced this by varying the allocator test to use 126 one-row 64-KiB strings, a 9-MiB pool, and an empty right batch so the build stays in memory and there is no probe cross-product contribution. Peak live allocation reached 15.84 MiB, above the test's 13.50-MiB bound.

The committed spill/replay test passes, but it does not exercise this in-memory case. Could we either preserve the in-memory batches without copying here, or reserve and limit the output materialization before doing the copy? We should also make sure the ownership accounting remains exact after the input batches are dropped.

@2010YOUY01

Copy link
Copy Markdown
Contributor

This is the main part I'd suggest implementing differently. I'm happy to take it on myself — WDYT?

Idea: #24820 (comment)

I agree this seems optional if we only look at this PR. I feel strongly about this structure because we're likely to implement the same idea repeatedly in HJ and piecewise merge join, given #23076. A reusable struct would help there, so we don't have to handle the same complexity inside each operator.

The replacement PR is now open:

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed 1659313 against c5257f0 with five independent passes. The earlier spill-coalescing memory regression is fixed: its reproducer now peaks at approximately 0.51 MiB on both base and head.

I found two P2 issues, detailed inline: an empty-build spill panic and an allocator test that includes backtrace initialization in its measurement.

Validation: 2,251 physical-plan library tests and the allocator test passed under default local settings. An independent expected-row oracle passed 24,000 executions per revision, including 15,000 confirmed spilling executions per revision. The targeted empty-build reproducer fails only on head, and controlled backtrace runs reproduce the allocator-test failure. The full workspace suite was not run.

Comment on lines +1377 to 1379
for batch in batches {
spill_file.append_batch(&batch)?;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Preserve empty-batch filtering during spill

Removing the num_rows() > 0 guard allows an entirely empty build side to create a spill file. append_batch initializes the writer even for a zero-row batch, so finish() returns a file; replay then skips every batch and returns no chunk. This bypasses the assignment to active.left_schema, and final right-side emission panics with left_schema must be set.

I reproduced this against base c5257f054 and head 16593136d using three empty Int32 build batches, right-side row 7, and a 97-byte memory pool (each empty batch accounts for 96 bytes). For a Right join, base returns (NULL, 7) while head panics. Full, RightSemi, RightAnti, and RightMark also panic on head while base succeeds. The same Right-join failure reproduces with empty slices retaining 4 MiB buffers and a roughly 4 MiB pool, so it also occurs beyond tiny test budgets.

Please retain the previous empty-batch guard here and add coverage for an empty build side that exhausts its reservation after buffering an empty batch.

Comment on lines +197 to +200
let baseline = LIVE_BYTES.load(Ordering::Relaxed);
PEAK_BYTES.store(baseline, Ordering::Relaxed);
let output = collect(join.execute(0, ctx)?).await?;
let peak = PEAK_BYTES.load(Ordering::Relaxed) - baseline;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Initialize backtrace diagnostics before the allocation baseline

The baseline is taken before the first reservation failure. With DataFusion backtraces enabled and debug information retained, that failure initializes process-wide symbolization caches, which this test counts against the join's 12 MiB threshold. The new test therefore fails even when build-side spilling stays bounded; the AMD64 CI job reports 14.21 MiB against 12 MiB.

In a controlled copy of this test linked to the current head, the measured peak is 8.14 MiB with RUST_BACKTRACE=0 and 13.75 MiB with RUST_BACKTRACE=1. Calling and dropping DataFusionError::get_back_trace() before recording the baseline initializes about 5.61 MiB of retained diagnostic caches; the measured peak then returns to 8.14 MiB with backtraces still enabled and the same join execution. Retaining line-table debug information gives the same failure/pass distinction.

Please warm the backtrace machinery before taking the baseline, or isolate the measurement in a process with backtraces disabled. The existing 12 MiB threshold can remain unchanged.

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

6 participants