fix: frame IPC messages when reading spill files so batches pin only their own bytes - #24592
Conversation
…their own bytes The spill reader fed raw 128 KB read chunks to arrow's zero-copy StreamDecoder. A batch whose message fit inside a chunk kept the whole chunk alive, and a message spanning chunks was gathered into a Vec grown by doubling, so read-back batches retained, and were accounted for, up to 27x the memory recorded for them at spill time. Reassemble each IPC message into an exactly sized allocation before decoding, using the metadata's bodyLength to size the body buffer. Closes apache#17340
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #24592 +/- ##
========================================
Coverage 81.44% 81.45%
========================================
Files 1118 1118
Lines 399602 399948 +346
Branches 399602 399948 +346
========================================
+ Hits 325458 325773 +315
- Misses 55146 55161 +15
- Partials 18998 19014 +16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
alamb
left a comment
There was a problem hiding this comment.
Thank you @jayzhan211 -- the description on this PR is beautiful
No API changes. Queries that spill use less memory when reading spills back, and the spurious accounting warning from #17340 no longer fires.
I think another potentially user visible tradeoff of this PR is that it copies data into the right sized allocations, which does improve memory accounting, but at the cost of an extra memcpy for each byte read.
Can we add the test from #17340 that shows this improving memory accounting? I worry that without such a test we could easily lose this feature during a refactoring.
I also remember another recent PR about memory accounting trying to take into account shared buffers
It seems like this function might already do what we want
https://docs.rs/datafusion/latest/datafusion/common/utils/memory/fn.get_record_batch_memory_size.html
Another thing claude code flagged is that this code doesn't seem to error on truncated input files (where on main it does)
On main, a spill file cut off mid-message leaves the partial bytes inside StreamDecoder's internal scratch, so the existing decoder.finish() call at EOF returns an error. On this PR, those partial bytes live in the MessageFramer instead
— and the framer's state is never checked at EOF.
Here is the reproducer
#[tokio::test]
async fn test_truncated_spill_file_errors() -> Result<()> {
let batch = build_table_i32(
("a", &(0..100).collect::<Vec<_>>()),
("b", &(100..200).collect::<Vec<_>>()),
("c", &(200..300).collect::<Vec<_>>()),
);
let schema = batch.schema();
let batches = vec![batch.clone(), batch];
let env = Arc::new(RuntimeEnv::default());
let metrics = SpillMetrics::new(&ExecutionPlanMetricsSet::new(), 0);
let spill_manager = SpillManager::new(env, metrics, Arc::clone(&schema));
let spill_file = spill_manager
.spill_record_batch_and_finish(&batches, "Test")?
.unwrap();
// Truncate the file mid-message: drop the 8-byte end-of-stream marker
// plus part of the last batch's body.
let path = spill_file.path().unwrap().to_path_buf();
let len = std::fs::metadata(&path)?.len();
let f = std::fs::OpenOptions::new().write(true).open(&path)?;
f.set_len(len - 8 - 13)?;
drop(f);
let stream = spill_manager.read_spill_as_stream(spill_file, None)?;
let result = collect(stream).await;
assert!(
result.is_err(),
"truncated spill file should error, got Ok with {} batches",
result.as_ref().map(|b| b.len()).unwrap_or(0)
);
Ok(())
}| body_len, | ||
| } => { | ||
| let to_read = input.len().min(*body_len - body.len()); | ||
| body.extend_from_slice(&input[..to_read]); |
There was a problem hiding this comment.
I think this line effectively copies each input byte twice (once into input and then once into body)
…d improve message framing
…-compact-small-batches
|
Tests added, not yet find better way to eliminate the memcpy, and the cost seems low 🤔 |
…pache#24637) ## Which issue does this PR close? - N/A (minor doc fix). Follow on to apache#24592, which I noticed while reviewing that PR. ## Rationale for this change The docs for [`get_record_batch_memory_size`](https://docs.rs/datafusion/latest/datafusion/common/utils/memory/fn.get_record_batch_memory_size.html) render poorly on docs.rs: 1. The ASCII-art buffer diagram is not in a code block, so rustdoc collapses it into a single unreadable line 2. A stray backtick in ```Current `RecordBatch`.get_array_memory_size()` will ...``` causes the rest of the paragraph to render as inline code The diagram currently renders as this single line: ``` {xxxxxxxxxxxxxxxxxxx} <— buffer ^ ^ ^ ^ | | | | col1->{ } | | col2———>{ } ``` <img width="1541" height="871" alt="Screenshot 2026-08-24 at 3 29 49 PM" src="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/user-attachments/assets/efd24f7e-f6d9-4ac6-aaee-9c2849851c2d" /> ## What changes are included in this PR? - Wrap the diagram in a `text` code fence so it renders as drawn - Replace the mis-quoted method reference with an intra-doc link to `RecordBatch::get_array_memory_size` ## Are these changes tested? Yes: `RUSTDOCFLAGS="-D warnings" cargo doc -p datafusion-common --no-deps` passes (this also verifies the new intra-doc link resolves). ## Are there any user-facing changes? Documentation only.
alamb
left a comment
There was a problem hiding this comment.
Thank you @jayzhan211
This code seems good to me and well reasoned. However, since I don't use the spill writer all that much I don't really know how much, if at all, the extra copy will help/hurt of if it is a good tradeoff for better memory usage
Maybe @rluvaton or @2010YOUY01 who I know have spent more time on this might be able to weigh in as well
|
Thanks for the review! I’ll wait another day to see if there’s any more input. |
Which issue does this PR close?
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 limitwarning in #17340 reports. Under a memory limit this is not just a noisy log: the multi-level merge inSortExecand the spilling aggregate size their merge fan-in frommax_record_batch_memoryrecorded 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'sStreamDecoder, 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:
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
Vecgrown by doubling and the batch keeps the spare capacity:In the
spill_iobench 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?
SpillReaderStreamnow reassembles each IPC message into allocations sized from the message's own headers before decoding, via a smallMessageFramerstate machine:meta_len;Vecof exactlyprefix + meta_lenbytes;bodyLengthfrom the flatbuffer metadata (arrow_ipc::root_as_message);Vec::with_capacity(body_len)from however many chunks it spans;[head, body]to the sameStreamDecoder.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:A 5 KB batch retains 5 KB and a 256 KB batch retains 256 KB, so
max_record_batch_memoryrecorded 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_iobench vsmain(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):mainmemory_limit::test_stringview_external_sort(the reproducer in #17340)memory_limitintegration suitespilling_fuzz_in_memory_constrained_env+sort_fuzz+aggregate_fuzzAre 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'sget_record_batch_memory_sizeis within the margin of the written maximum. Fails onmainwithread-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.memory_limitintegration 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.