jayzhan211 opened a new pull request, #24592:
URL: https://github.com/apache/datafusion/pull/24592

   ## Which issue does this PR close?
   
   - Closes #17340.
   
   ## Rationale for this change
   
   Record batches read back from a spill file retain far more memory than was 
recorded for them when they were written, which is what the `Record batch 
memory usage (...) exceeds the expected limit` warning in #17340 reports. Under 
a memory limit this is not just a noisy log: the multi-level merge in 
`SortExec` and the spilling aggregate size their merge fan-in from 
`max_record_batch_memory` recorded at spill time, so inflated read-back batches 
consume memory the operator never budgeted for.
   
   The cause is in `SpillReaderStream`. It reads the file in 128 KB chunks and 
hands them straight to arrow's `StreamDecoder`, which builds arrays on *slices* 
of the buffer it is given — and a slice keeps its whole backing allocation 
alive. That goes wrong in two ways.
   
   **Small batches pin the whole chunk.** With ~5 KB batches one chunk holds 
~27 messages. Each decoded batch's buffers are slices of that chunk, so each 
batch retains, and is accounted for, 128 KB:
   
   ```
   chunk (128 KB allocation)
   ┌──────┬──────┬──────┬─────┬───────┐
   │ msg1 │ msg2 │ msg3 │ ... │ msg27 │
   └──────┴──────┴──────┴─────┴───────┘
      ▲
      batch1's buffers slice here, yet keep all 128 KB alive
   ```
   
   Traced in the sort tests: a 100-row Utf8 batch read back with `caps=[(404, 
131072), (4316, 131072)]` — 4.7 KB of data, 128 KB retained, 27× what was 
recorded at spill time. And it is real retention, not just accounting: while 
the merge holds that batch, the other 26 in the chunk stay alive too.
   
   **Straddling batches double.** A message spanning two chunks cannot be 
sliced, so the decoder gathers it into a `Vec` grown by doubling and the batch 
keeps the spare capacity:
   
   ```
   chunk N                      chunk N+1
   ┌──────┬────────────────────┬──────────────┬────────┐
   │ ...  │ msgK (first part)  │ msgK (rest)  │ msgK+1 │
   └──────┴────────────────────┴──────────────┴────────┘
   ```
   
   In the `spill_io` bench about half of all 256 KB batches came back in a 512 
KB allocation (`retained=523264 data=262144`). How much spare capacity a 
straddling batch ends up with depends on where the chunk boundary fell, which 
is why the reports on #17340 range from ~10% over (`967744 vs 877568`) up to 2×.
   
   ## What changes are included in this PR?
   
   `SpillReaderStream` now reassembles each IPC message into allocations sized 
from the message's own headers before decoding, via a small `MessageFramer` 
state machine:
   
   1. read the 4-byte length prefix (skipping the continuation marker) → 
`meta_len`;
   2. fill a head `Vec` of exactly `prefix + meta_len` bytes;
   3. take `bodyLength` from the flatbuffer metadata 
(`arrow_ipc::root_as_message`);
   4. fill a body `Vec::with_capacity(body_len)` from however many chunks it 
spans;
   5. hand `[head, body]` to the same `StreamDecoder`.
   
   The whole body is now inside one buffer whose allocation is exactly 
`body_len`, so the decoder takes its zero-copy path and the batch pins exactly 
its own message:
   
   ```
   body for msg1 (5 KB)      body for msgK (256 KB)
   ┌──────┐                  ┌────────────────────┐
   │ msg1 │ ◀── batch1       │ msgK               │ ◀── batchK
   └──────┘                  └────────────────────┘
   ```
   
   A 5 KB batch retains 5 KB and a 256 KB batch retains 256 KB, so 
`max_record_batch_memory` recorded at write time matches what the merge 
actually gets back.
   
   This costs one memcpy per message — the one the decoder already paid for 
straddling messages — minus the doubling reallocation, so it is not slower. 
`spill_io` bench vs `main` (two runs on a quiet machine): 
`StreamReader/read_100` −7.7%, `q2/lz4_frame` −5.9%, all other cases within 
noise.
   
   Warnings from the #17340 check 
(`RUST_LOG=datafusion_physical_plan::spill=debug`):
   
   | | `main` | this PR |
   |---|---|---|
   | `memory_limit::test_stringview_external_sort` (the reproducer in #17340) | 
26 | **0** |
   | whole `memory_limit` integration suite | ~5000 | **0** |
   | `spilling_fuzz_in_memory_constrained_env` + `sort_fuzz` + `aggregate_fuzz` 
| 4170 | **0** |
   
   ## Are these changes tested?
   
   Yes:
   
   - `test_read_back_does_not_inflate_batch_memory`: spills 50 small batches 
(Int32, Utf8, Utf8View, List) and asserts every read-back batch's 
`get_record_batch_memory_size` is within the margin of the written maximum. 
Fails on `main` with `read-back batch retains 131072 bytes, written max was 
24196`.
   - `test_message_framer_across_chunk_boundaries`: frames and decodes an IPC 
stream delivered in chunks of 1, 3, 7, 64, 1000 bytes and as a whole, checking 
the batches are intact and each retains no more than its own message body.
   - Existing spill, sort, aggregate, repartition unit tests, the 
`memory_limit` integration tests and the extended spilling fuzz suites pass.
   
   ## Are there any user-facing changes?
   
   No API changes. Queries that spill use less memory when reading spills back, 
and the spurious accounting warning from #17340 no longer fires.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to