This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new b75c570748 perf: bypass shuffle BatchCoalescer for already-sized
batches (#5003)
b75c570748 is described below
commit b75c5707489f51485b15fa1d5079948e4d3b9ef2
Author: Andy Grove <[email protected]>
AuthorDate: Thu Jul 23 19:22:17 2026 -0600
perf: bypass shuffle BatchCoalescer for already-sized batches (#5003)
---
native/shuffle/src/shuffle_writer.rs | 79 ++++++++++++++++++++++++++
native/shuffle/src/writers/buf_batch_writer.rs | 27 +++++++--
2 files changed, 100 insertions(+), 6 deletions(-)
diff --git a/native/shuffle/src/shuffle_writer.rs
b/native/shuffle/src/shuffle_writer.rs
index 8d1336dc1c..bf296a13a2 100644
--- a/native/shuffle/src/shuffle_writer.rs
+++ b/native/shuffle/src/shuffle_writer.rs
@@ -924,6 +924,85 @@ mod test {
.sum()
}
+ /// Batches that are already at least `batch_size` rows should bypass the
+ /// coalescer, being written one-block-per-batch, while smaller batches are
+ /// still coalesced. In both cases the data must roundtrip exactly, in
order.
+ #[test]
+ #[cfg_attr(miri, ignore)]
+ fn test_full_batches_bypass_coalescer() {
+ use crate::writers::BufBatchWriter;
+ use arrow::array::Int32Array;
+
+ let schema = Arc::new(Schema::new(vec![Field::new("v",
DataType::Int32, false)]));
+ let batch_size = 100;
+
+ let make_batch = |start: i32, len: i32| {
+ let values: Vec<i32> = (start..start + len).collect();
+ RecordBatch::try_new(
+ Arc::clone(&schema),
+ vec![Arc::new(Int32Array::from(values)) as Arc<dyn Array>],
+ )
+ .unwrap()
+ };
+
+ // Sequence: a full batch (bypass), an oversized batch (bypass,
emitted whole),
+ // a partial batch (buffered), then a full batch (must not bypass
because the
+ // coalescer still holds buffered rows).
+ //
+ // The 150-row batch is the case that distinguishes the fast path: the
+ // coalescer alone would split it into a 100-row block plus 50
buffered rows,
+ // so it can only appear as a single 150-row block when the
passthrough fires.
+ let inputs = vec![
+ make_batch(0, batch_size), // rows 0..100 -> bypass (100-row
block)
+ make_batch(batch_size, 150), // rows 100..250 -> oversized bypass
(150-row block)
+ make_batch(250, 30), // rows 250..280 -> buffered
+ make_batch(280, batch_size), // rows 280..380 -> coalesced with
buffered 30
+ ];
+
+ let codec = CompressionCodec::Lz4Frame;
+ let encode_time = Time::default();
+ let write_time = Time::default();
+
+ let mut output = Vec::new();
+ {
+ let mut writer = ShuffleBlockWriter::try_new(schema.as_ref(),
codec.clone()).unwrap();
+ let mut buf_writer = BufBatchWriter::new(
+ &mut writer,
+ Cursor::new(&mut output),
+ 1024 * 1024,
+ batch_size as usize,
+ );
+ for batch in &inputs {
+ buf_writer.write(batch, &encode_time, &write_time).unwrap();
+ }
+ buf_writer.flush(&encode_time, &write_time).unwrap();
+ }
+
+ let blocks = read_all_ipc_batches(&output);
+
+ // The full and oversized batches are emitted verbatim as their own
blocks,
+ // then the partial+full pair coalesces into a full block plus a
30-row tail.
+ let block_rows: Vec<usize> = blocks.iter().map(|b|
b.num_rows()).collect();
+ assert_eq!(
+ block_rows,
+ vec![100, 150, 100, 30],
+ "unexpected block boundaries"
+ );
+
+ // All 380 rows must roundtrip in order (0..380).
+ let mut roundtripped = Vec::new();
+ for block in &blocks {
+ let col = block
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ roundtripped.extend(col.values().iter().copied());
+ }
+ let expected: Vec<i32> = (0..380).collect();
+ assert_eq!(roundtripped, expected, "rows not preserved in order");
+ }
+
#[test]
#[cfg_attr(miri, ignore)]
fn test_empty_schema_shuffle_writer() {
diff --git a/native/shuffle/src/writers/buf_batch_writer.rs
b/native/shuffle/src/writers/buf_batch_writer.rs
index cfddb46539..72db86529d 100644
--- a/native/shuffle/src/writers/buf_batch_writer.rs
+++ b/native/shuffle/src/writers/buf_batch_writer.rs
@@ -26,9 +26,12 @@ use std::io::{Cursor, Seek, SeekFrom, Write};
/// The record batches were first written by ShuffleBlockWriter into an
internal buffer.
/// Once the buffer exceeds the max size, the buffer will be flushed to the
writer.
///
-/// Small batches are coalesced using Arrow's [`BatchCoalescer`] before
serialization,
-/// producing exactly `batch_size`-row output batches to reduce per-block IPC
schema overhead.
-/// The coalescer is lazily initialized on the first write.
+/// Small batches are coalesced using Arrow's [`BatchCoalescer`] before
serialization, reducing
+/// per-block IPC schema overhead. Output batches hold at least `batch_size`
rows, apart from the
+/// remainder emitted on flush. The coalescer is lazily initialized on the
first write and
+/// configured (via `biggest_coalesce_batch_size`) to pass batches that are
already at least
+/// `batch_size` rows straight through, verbatim and without copying them, so
an oversized input
+/// batch is written as a single oversized block.
pub(crate) struct BufBatchWriter<S: Borrow<ShuffleBlockWriter>, W: Write> {
shuffle_block_writer: S,
writer: W,
@@ -64,9 +67,21 @@ impl<S: Borrow<ShuffleBlockWriter>, W: Write>
BufBatchWriter<S, W> {
encode_time: &Time,
write_time: &Time,
) -> datafusion::common::Result<usize> {
- let coalescer = self
- .coalescer
- .get_or_insert_with(|| BatchCoalescer::new(batch.schema(),
self.batch_size));
+ let batch_size = self.batch_size;
+ let coalescer = self.coalescer.get_or_insert_with(|| {
+ // Enable BatchCoalescer's zero-copy passthrough for batches that
are already big
+ // enough, so we don't `copy_rows` the whole batch into the
in-progress builders just
+ // to re-emit a same-sized batch. The passthrough fires for
batches strictly larger
+ // than the limit, so set it to `batch_size - 1` to include
batches of exactly
+ // `batch_size`, which is what `PartitionedBatchIterator` emits
(except the tail),
+ // removing a full copy of the shuffle payload. Block boundaries
are unchanged for
+ // that iterator since it never emits more than `batch_size` rows.
The
+ // single-partition path routes `>= batch_size` batches here
directly, so those now
+ // form one oversized block rather than being split at
`batch_size`. Rows are written
+ // in the same order either way.
+ BatchCoalescer::new(batch.schema(), batch_size)
+
.with_biggest_coalesce_batch_size(Some(batch_size.saturating_sub(1)))
+ });
coalescer.push_batch(batch.clone())?;
// Drain completed batches into a local vec so the coalescer borrow
ends
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]