Skip to content

perf: reuse per-partition scratch in the shuffle write path - #5568

Open
dwsmith1983 wants to merge 6 commits into
apache:mainfrom
dwsmith1983:perf/shuffle-scratch-reuse
Open

perf: reuse per-partition scratch in the shuffle write path#5568
dwsmith1983 wants to merge 6 commits into
apache:mainfrom
dwsmith1983:perf/shuffle-scratch-reuse

Conversation

@dwsmith1983

@dwsmith1983 dwsmith1983 commented Aug 31, 2026

Copy link
Copy Markdown

Which issue does this PR close?

Part of #5002 (the two per-partition allocation items in the medium tier; the issue stays open for its remaining items). Independent of #5565 — whichever merges second gets a small mechanical rebase over the shared BufBatchWriter constructor.

Rationale for this change

The multi-partition write path pays two allocation costs per partition per event:

  • BufBatchWriter starts from an empty byte buffer and regrows it toward the write-buffer size (1MB default). finish_partition and the spill path construct one per partition per event, so a 2,000-partition pass runs 2,000 growth cycles — roughly an extra copy of the payload through doubling-growth memcpy, plus the transient allocations.
  • PartitionedBatchIterator materializes the whole partition's (u32,u32) index list as a fresh (usize,usize) vector (16 bytes per row, re-materialized every write cycle) and rebuilds a batch-ref vector over all buffered batches for every non-empty partition.

What changes are included in this PR?

  • BufBatchWriter::new takes a caller-owned buffer and into_buffer() hands it back drained (capacity kept). The task-level LocalPartitionWriter owns one buffer and recycles it through both sequential partition loops (spill and finish), so an event allocates it once and holds exactly one regardless of partition count. Peak memory is unchanged (partitions were already written one at a time, so at most one buffer was ever live); the change is that the peak is retained across partitions rather than released, and flush caps the retained capacity at the configured write-buffer size. The scratch is borrowed per call (write/flush take &mut Vec<u8>), matching the convention perf: reuse zstd compression contexts across shuffle blocks #5565 uses for its codec context; the long-lived single-partition writer carries its own scratch.
  • PartitionedBatchesProducer::produce takes &self plus a caller-built batch-ref slice (batch_refs(), built once per write cycle); the iterator borrows the raw (u32,u32) indices and converts per output chunk into a small reusable scratch (capacity ≤ batch size) instead of widening the full list up front. The empty-partition path stays allocation-free.

Wire format and produced batches are unchanged, pinned by byte-level tests (recycled vs fresh buffers produce identical bytes; chunked conversion interleaves identically to full up-front conversion, tail chunk included).

Benchmarks (M-series macOS, 4M-row hash shuffle via shuffle_bench, 3 iterations after warmup): write time drops ~27% at 2,000 partitions (0.015s → 0.011s) with the other phases flat, and the criterion end-to-end suite shows no regressions (one config improved). Honest framing: a single-task bench understates this change — the removed churn is per-partition allocator traffic, which matters most under concurrent tasks sharing the allocator, and the bounded buffer count is the memory-profile win.

How are these changes tested?

Two new tests alongside the existing suites (94 in the shuffle crate, all passing, plus the core crate's 201):

  • a recycled buffer produces byte-identical output to fresh per-partition buffers, comes back drained, and keeps its grown capacity across partitions
  • chunked index conversion interleaves exactly like converting the whole partition up front, including the short tail chunk, sharing one batch-ref slice across partitions

cargo clippy --all-targets -- -D warnings and cargo fmt clean.

The multi-partition write path pays two allocation costs per partition
per event: BufBatchWriter starts from an empty byte buffer and regrows
it toward the 1MB write-buffer size, and the interleave iterator
materializes the whole partition index list as a fresh usize-pair
vector (16 bytes per row) plus a batch-ref vector over all buffered
batches.

The task-level writer now owns one byte buffer and recycles it through
the sequential partition loops, drained between partitions with its
capacity kept, so an event allocates the buffer once instead of once
per partition and holds exactly one regardless of partition count. The
interleave path builds the batch-ref list once per write cycle and
converts indices per output chunk into a small reusable scratch
instead of widening the full list up front.

Wire format and produced batches are unchanged, pinned by byte-level
tests. Write time drops ~27% on a 2000-partition zstd shuffle bench;
the larger effect is allocator pressure under concurrent tasks, which
a single-task bench understates.

Part of apache#5002.
@dwsmith1983

Copy link
Copy Markdown
Author

@sunchao the Iceberg 1.11 / Spark 4.1 runtime leg failed before any tests ran — Gradle could not fetch the shadow plugin from plugins.gradle.org (TLS handshake dropped during project configuration). Same leg passed on #5565 shortly before. Could you rerun that job when you get a chance? I don't have permissions to.

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

I checked this out and ran the output through the real ShuffleWriterExec path on both main and this branch across 15 configurations, hashing the data and index files each time: 2000-partition hash, single partition, forced spilling via max_buffer_bytes, a 64-byte write buffer to force a flush per block, across None, Lz4Frame and Zstd(1). Every digest matched, which covers the spill path that the new unit tests don't reach. All 94 shuffle tests pass and clippy is clean.

The win reproduces and it's bigger than you claimed. Running PR, main, main, PR alternating to control for drift, this branch was fastest in every configuration: roughly 10 to 15% end to end at 2000 partitions and 4 to 8% at 8000, flat at 200. Those are whole-plan numbers including hashing, so your write-phase measurement looks right. Nice result.

A few things I'd like to sort out before this goes in, mostly around the buffer's lifetime and settling the convention with #5565 while both are still open. Those are inline.

One more that doesn't anchor anywhere. Could you add a high-partition case to native/shuffle/benches/shuffle_writer.rs? It tops out at 16 partitions today, so "the criterion suite shows no regressions" is accurate but the suite can't actually see this change in either direction, and the whole argument for the PR is high partition counts. I added a throwaway bench at 200/2000/8000 and the win is clear at 2000 and up, so a case there would both demonstrate it and guard it, and it would give PR Benchmark Check something to watch.

/// Partitions are written strictly one at a time, so a single buffer keeps its
/// grown capacity across the whole task instead of every partition regrowing a
/// fresh allocation toward the write buffer size.
recycled_buffer: Vec<u8>,

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.

One thing I want to make sure we've thought about. write_batch_to_buffer appends a whole block and only then checks pos >= buffer_max_size, so the buffer's high-water mark is the write buffer size plus one full block, not the write buffer size. Previously the writer was dropped at the end of each partition and that peak went back to the allocator. Now it lives in recycled_buffer until the LocalPartitionWriter drops, and nothing charges it to the shuffle reservation. The spill path is where I'd worry, since spills happen under memory pressure and we'd now be holding an extra untracked allocation between them. Would a shrink_to(write_buffer_size) when you store it back be enough to bound that? #5565 bounds retention for its codec context, so it would be good for these two to agree.

Related, and worth fixing since we squash-merge: the description says this is "a strict improvement for memory at high partition counts". Partitions are already written one at a time, so at most one buffer was ever live and peak is unchanged. The real change is that the peak is retained rather than released. Could you reword that so the tradeoff is on the record?

Separately, into_buffer() will happily hand back a buffer with bytes still in it, and new() clears whatever it's given. Both callers flush first so this is fine today, but if someone later reuses a buffer from an unflushed writer the encoded bytes just disappear and it surfaces as missing rows two stages downstream. Could into_buffer() carry a debug_assert!(self.buffer.is_empty())? The doc comment already states the invariant.

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] Could you extend the requested benchmark with a wide-then-narrow forced-spill case to quantify this retained-buffer tradeoff? At 3b4425cb, a successful spill returns the byte buffer to LocalPartitionWriter before freeing the reservation, and the write threshold is checked only after a whole block is encoded. This establishes a lifetime/accounting change, not a measured RSS regression.

One bounded case would use an early hot-key batch of 8,192 rows with 8-KiB Binary values, followed by small values with the same schema. Compare that with reversed-order and narrow-only controls, using None compression and a forced-spill threshold. Compare base 199a910bd8623cac3db82d70f9bfb24865613c2c against head 3b4425cb4b0c7e0688248f6c2571c08307c3fdb1 with the same release build, allocator, hardware, input seed, warmup and repeated runs.

Could you report total time, allocation/growth bytes, retained buffer capacity and tracked reservation after each spill, peak/live memory, spill bytes, and matching decoded rows/order? This would complement the existing high-partition throughput request and help choose whether to cap, release or account for the retained buffer.

@dwsmith1983 dwsmith1983 Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

All three taken: flush now ends with shrink_to(write_buffer_size), so retained capacity is bounded at the configured size (mid-partition the high-water can still reach the cap plus the largest block, same as before, only what is retained changed); the description is reworded to say the peak is retained rather than released, and now capped, instead of claiming an improvement; and the drained-scratch invariant is debug-asserted with a test. The into_buffer question dissolved with the borrow change from your other comment.

@dwsmith1983 dwsmith1983 Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ran it with your shape: an 8,192-row hot batch of 8 KiB Binary payloads followed by ~500k rows of 16-byte payloads, plus reversed-order and narrow-only controls; codec none, 2,000 partitions, max-buffer-bytes 4 MiB (about 10 spills per run), 3 iterations after warmup, base vs head on the same build, machine, and input seed:

input base avg head avg
wide-then-narrow 0.401s 0.384s
narrow-then-wide 0.421s 0.413s
narrow-only 0.398s 0.397s

Head is slightly faster on all three, most on the wide-then-narrow case, so no time regression from retention. On the memory side the answer changed while you were writing the comment: flush now shrinks the scratch back to the configured write-buffer size, so retained capacity after the wide batch is bounded by construction and pinned by a test rather than instrumented at runtime, which I think lands on the "cap" option among cap/release/account. Spilled bytes and outputs match within the run-to-run variation of the spill trigger (Arrow's memory estimates shift block boundaries by a spill or so even between runs of the same binary, so byte-exact cross-tree diffs are not meaningful under forced spilling; row-level equality is pinned by the unit tests and the digest matrix in the review above). I did not instrument peak RSS or live reservation traces.

writer: W,
buffer_max_size: usize,
batch_size: usize,
mut buffer: Vec<u8>,

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.

How do you feel about matching the shape #5565 uses? It threads its task-scoped state as a &mut ShuffleCodecContext parameter on write and flush, where this moves a Vec<u8> in through the constructor and back out through into_buffer(). If both land we end up with two conventions for the same idea in the same struct, and whichever rebases second is unlikely to revisit it.

Borrowing would also clean up a small wart. The mem::take empties the caller's buffer up front and the write-back only runs on success, so any error in between quietly ends recycling for the rest of the task. Not a correctness issue, but it goes away on its own if the buffer is borrowed.

@dwsmith1983 dwsmith1983 Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, write and flush borrow the scratch per call now, same shape as the codec context, and the constructor param and into_buffer are gone. You are right that it also fixes the take/put-back wart; an error no longer ends recycling. The one thing ownership gave for free was buffer identity, so the writer now records the scratch address on first use and debug-asserts every later call passes the same one, a swapped buffer would silently drop un-flushed bytes otherwise.

pub(super) fn produce<'a>(
&'a mut self,
&'a self,
refs: &'a [&'a RecordBatch],

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.

The indices are positions into self.buffered_batches, but refs here is unconstrained, so a slice built from the wrong producer or a filtered one compiles fine and shuffles the wrong rows instead of failing. Would you mind adding a debug_assert_eq!(refs.len(), self.buffered_batches.len()) at the top? Cheap, and it catches the realistic version of that mistake.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added, with a debug-build test pinning that a truncated refs slice panics.

dwsmith1983 and others added 2 commits September 1, 2026 09:49
…city

The write scratch is now threaded through write and flush as a borrow
instead of moving through the constructor, so one convention covers all
task-scoped shuffle scratch, and an error mid-partition can no longer
strand the buffer inside a dropped writer. The writer asserts a fresh
scratch is drained and that every call passes the same buffer, since a
swapped buffer would silently lose unflushed bytes. Flush shrinks the
scratch back to the configured write-buffer size, bounding retained
capacity that could previously reach the buffer size plus the largest
block. The producer asserts the batch-ref slice covers every buffered
batch, and the criterion suite gains a high-partition group so
partition scaling stays visible to benchmark checks.
@dwsmith1983

dwsmith1983 commented Sep 1, 2026

Copy link
Copy Markdown
Author

Thanks for running the digest matri, good to have the spill path independently covered, and glad the win held up end to end. The criterion suite now has a high-partition group (200/2,000/8,000, hash partitioning, no compression) so PR Benchmark Check can actually watch this; on my machine it lands at 2.75/9.44/21.5 ms respectively. The retention and convention comments are addressed inline, and the description is reworded per your squash-merge point.

@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 the update at 08ec7be. Nonblocking: could you confirm the exact base/head commits and the native write_buffer_size used for the reported timings?

@dwsmith1983

dwsmith1983 commented Sep 1, 2026

Copy link
Copy Markdown
Author

@sunchao
For the spill-experiment timings: base was 199a910 (the merge base), head was the tree that landed as 08ec7be — the runs happened just before committing, with identical shuffle sources. write_buffer_size was the default 1 MiB (1048576); the only non-default memory setting was --max-buffer-bytes 4194304 to force spilling. The earlier writer-phase table in the description used the same base with the PR head at 3b4425c, same defaults.

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

Took another look at 08ec7be48, since the reshaping commit moved enough that my earlier pass no longer covered it. I re-ran an end-to-end comparison against the merge base, hashing the data and index files across 144 configurations (three input shapes including the 8 KiB Binary hot batch, partition counts 1 through 2000, None/Lz4Frame/Zstd(1), write buffers of 1 MiB and 64 bytes, and max_buffer_bytes unset and at 256 KiB). Every digest matched, and the criterion high-partition group reproduces a 4 to 5% win against the base once I port it to the baseline tree. Two things inline.

for batch in iter.by_ref() {
let batch = batch?;
buf_batch_writer.write(&batch, &metrics.encode_time, &metrics.write_time)?;
buf_batch_writer.write(

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.

The borrow change fixed the mem::take wart I raised, but I think it opened a different one on the same path. If the iterator or the writer errors part way through a partition, finish_partition returns before flush runs, so recycled_buffer keeps whatever was already encoded. I confirmed it with a probe: inject an error after one good batch and the buffer comes back holding 668 bytes. The next partition's BufBatchWriter then seeks to the end and appends after those bytes, and its flush writes them into that partition's byte range, so a reader sees a corrupt block rather than an error.

Nothing reaches that state today, because every error here aborts the task and check_scratch catches it in debug builds. But it is the one invariant in this design that the code does not enforce, and the release-build failure mode is silent wrong data rather than a crash. Both earlier shapes were immune for free: the owned buffer died with the writer, and the move-in/move-out version in 3b4425cb lost recycling rather than corrupting anything. The borrow shape I pushed you toward is what introduced it, so this one is on me.

Would you mind draining the scratch when the write loop or flush errors, in both SpillWriter::write and the Multi arm of finish_partition? Binding the loop and flush to a result and calling recycled_buffer.clear() before propagating would make the invariant true by construction instead of by assertion.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, this one is on me too. Both call sites now bind the loop and flush to a result and clear the scratch before propagating, so an errored partition always hands back an empty buffer. Added a failing test at each site first; they showed 1244 retained bytes and now come back empty.

let mut writer = BufBatchWriter::new(block_writer, &mut output, large_cap, 8192);
writer.write(&batch, &mut scratch, &time, &time).unwrap();
writer.flush(&mut scratch, &time, &time).unwrap();
assert!(scratch.capacity() > 0 && scratch.capacity() <= large_cap);

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.

The second half of flush_caps_retained_scratch_capacity cannot fail as written. With large_cap at 1 MiB and a block around a kilobyte, scratch.capacity() > 0 && scratch.capacity() <= large_cap holds for any implementation, including one where flush shrank to a single byte or did not shrink at all. The comment says it is checking that there is no over-shrinking, which is the property worth having, but with batch_size at 8192 the coalescer defers serialization to flush and shrink_to ends up a no-op, so nothing is being observed.

Could you use a batch_size below the row count the way the first half does, so write actually serializes into the scratch, then capture the capacity and assert flush leaves it alone? assert_eq!(scratch.capacity(), cap_after_write) would fail if this ever became shrink_to_fit, which is the regression the case exists to catch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are right, the coalescer was buffering everything so the second half was not proving anything. The test now uses a batch size below the row count so the writes actually serialize, captures the capacity right after them, and asserts flush leaves it exactly unchanged. I checked it fails if the flush path shrinks the buffer.

An error mid-partition left encoded bytes in the recycled scratch
buffer, and the next partition to reuse it would flush those bytes into
its own range. Both call sites now clear the scratch before propagating
the error. Also rewrites the capacity test to serialize for real and
assert the exact capacity is preserved across a flush.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants