Skip to content

perf: bypass shuffle BatchCoalescer for already-sized batches#5003

Open
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:perf/shuffle-coalescer-bypass
Open

perf: bypass shuffle BatchCoalescer for already-sized batches#5003
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:perf/shuffle-coalescer-bypass

Conversation

@andygrove

Copy link
Copy Markdown
Member

Which issue does this PR close?

Addresses the "Every batch is copied an extra time through BatchCoalescer, even when already full" item in #5002. That issue is an umbrella tracking several shuffle-writer optimizations, so this PR does not close it.

Rationale for this change

BufBatchWriter::write pushes every batch through a BatchCoalescer. In arrow 58.3 the normal push path always does copy_rows into the in-progress builders, even when the pushed batch is exactly target_batch_size and the buffer is empty. The zero-copy bypass in BatchCoalescer only activates when biggest_coalesce_batch_size is explicitly set, which Comet never does.

PartitionedBatchIterator already emits exactly batch_size-row batches from interleave_record_batch (except the tail), so in the multi-partition spill and finish paths, and the single-partition path, essentially all shuffle data was copied twice: once by interleave, once by the coalescer.

What changes are included in this PR?

BufBatchWriter::write now bypasses the coalescer when it holds no buffered rows and the incoming batch is already at least batch_size rows, serializing the batch directly. The buffered-rows check preserves output ordering: any rows already buffered in the coalescer must be emitted before the new batch, so the bypass only applies when the buffer is empty. Smaller (tail) batches still flow through the coalescer as before.

Since PartitionedBatchIterator never emits batches larger than batch_size, the bypass fires only for exactly-batch_size batches, so block boundaries and output bytes are identical to before. This removes one full copy of the shuffle payload.

An end-to-end microbenchmark (shuffle_writer bench, 8192-row batches, 16 partitions, Lz4) shows roughly a 3% improvement, with a larger relative win expected for the uncompressed path where the copy is a bigger fraction of total work.

How are these changes tested?

Existing datafusion-comet-shuffle unit tests continue to pass. A new test_full_batches_bypass_coalescer test feeds a mix of full and partial batches and asserts that full batches are emitted verbatim as their own IPC blocks while a partial batch is still coalesced with the following full batch, and that all rows roundtrip in order.

@mbutrovich

mbutrovich commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Doesn't BatchCoalescer already have an optimization for this? Are we just not using it? See biggest_coalesce_batch_size. Elsewhere in DataFusion we usually initialize it to target_batch_size / 2.

BufBatchWriter pushes every batch through a BatchCoalescer, which by
default copies rows into its in-progress builders even when the pushed
batch is already exactly target_batch_size and the buffer is empty.
PartitionedBatchIterator already emits exactly batch_size-row batches
(except the tail), so essentially all shuffle data was copied twice:
once by interleave, once by the coalescer.

Configure the coalescer's biggest_coalesce_batch_size so batches that
are already at least batch_size rows pass straight through without a
copy. The limit is batch_size - 1 because the passthrough fires for
batches strictly larger than the limit, and our batches are exactly
batch_size. This removes one full copy of the shuffle payload with
bit-identical output.

Part of apache#5002.
@andygrove
andygrove force-pushed the perf/shuffle-coalescer-bypass branch from 051ec4c to bd91354 Compare July 23, 2026 14:49
@andygrove

Copy link
Copy Markdown
Member Author

Doesn't BatchCoalescer already have an optimization for this? Are we just not using it? See biggest_coalesce_batch_size. Elsewhere in DataFusion we usually initialize it to target_batch_size / 2.

Thanks, updated to use that config. Much simpler.

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

Thanks @andygrove looks good to me

Comment thread native/shuffle/src/shuffle_writer.rs Outdated
let block_rows: Vec<usize> = blocks.iter().map(|b| b.num_rows()).collect();
assert_eq!(
block_rows,
vec![100, 100, 100, 30],

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 a case with a batch larger than batch_size, like 150 rows with batch_size = 100 ?
I think vec![100, 100, 100, 30] is what the coalescer alone would produce, so this test would still pass without the fast path.
But I'm not really sure about this. Thanks @andygrove

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the review @0lai0. I added a test based on your feedback.

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

Just one question on the test, otherwise this looks nice

@andygrove andygrove added this to the 1.0.0 milestone Jul 23, 2026
The prior test's expected block boundaries [100, 100, 100, 30] are what
the coalescer alone would produce, so it passed without the fast path.
Add a 150-row batch (> batch_size): the coalescer caps output at
target_batch_size and would split it into a 100-row block plus 50
buffered rows, so a single 150-row block can only appear when the
passthrough fires.
@mbutrovich

Copy link
Copy Markdown
Contributor

output bytes are identical to before

True for multi-partition and spill. For single-partition, a >= batch_size batch on an empty coalescer buffer now yields one oversized block instead of batch_size-row blocks, because single_partition.rs:107-108 already routes such batches straight to the coalescer on main. Worth qualifying the claim so it does not read as unconditional. Rows still roundtrip in order, so this is a block-boundary and buffer-size change, not a correctness issue.

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

Minor discussion points, thanks @andygrove!

@@ -28,7 +28,9 @@ use std::io::{Cursor, Seek, SeekFrom, Write};
///
/// Small batches are coalesced using Arrow's [`BatchCoalescer`] before serialization,
/// producing exactly `batch_size`-row output batches to reduce per-block IPC schema overhead.

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.

"producing exactly batch_size-row output batches" is not accurate once a >= batch_size batch is bypassed verbatim, which the single-partition path already does today. Could soften to "at least batch_size" and mention the passthrough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants