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 68489a7bc6 perf: encode shuffle IPC schema once per writer instead of
per block (#5006)
68489a7bc6 is described below
commit 68489a7bc6cf51fa3afcaefdce5494fc93db4a7b
Author: Andy Grove <[email protected]>
AuthorDate: Fri Jul 24 08:48:17 2026 -0600
perf: encode shuffle IPC schema once per writer instead of per block (#5006)
* perf: encode shuffle IPC schema once per writer instead of per block
ShuffleBlockWriter::write_batch used StreamWriter::try_new for every
block, which re-serializes the schema flatbuffer into each block. Since
every block in a shuffle shares one schema, this repeats identical work,
which matters for wide or deeply nested schemas and for shuffles with
many small blocks (high partition counts).
Pre-encode the schema message once in try_new and write it verbatim at
the start of every block, writing the record batch via
IpcDataGenerator::encode. Schemas containing dictionary types keep using
StreamWriter, whose dictionary-id bookkeeping ties schema and batch
encoding together. Output framing is unchanged.
A new criterion benchmark (shuffle_block_schema_encoding) covers a wide
flat schema and a deeply nested schema. Encoding an 8192-row batch is
~7% (wide flat schema) / ~24% (deeply nested schema) faster with the
pre-encoded schema.
Part of #5002.
* chore: trigger CI
* refactor: fold schema-encoding fields into single Option, document header
size
* refactor: address review on shuffle schema pre-encoding
- Fold schema/schema_message fields into a SchemaEncoding enum stating the
Precoded/Fallback invariant and dropping the fast-path deep schema clone
- Detect dictionary types via Schema::flattened_fields instead of a
hand-maintained recursive match, covering all containers arrow recurses
into
- Store one IpcWriteOptions on the writer, shared by the schema and batch
encoders, and pin it to metadata version V5 (which IPC_EOS depends on)
* fix: share shuffle block writer buffers via Arc to avoid per-partition
deep copy
ShuffleBlockWriter is cloned once per output partition when building
per-partition spill writers. After pre-encoding the IPC schema, each clone
deep-copied the schema message (and header) Vec, so a shuffle with a very
large partition count (e.g. the SPARK-48037 test uses 16M+ partitions)
allocated gigabytes and stalled the executor.
Hold the immutable header and pre-encoded schema behind Arc so cloning is an
O(1) refcount bump. Add a regression test asserting the buffers are shared
across clones.
---
native/shuffle/benches/shuffle_writer.rs | 95 +++++++++-
native/shuffle/src/shuffle_writer.rs | 38 ++++
native/shuffle/src/writers/shuffle_block_writer.rs | 207 +++++++++++++++++----
3 files changed, 299 insertions(+), 41 deletions(-)
diff --git a/native/shuffle/benches/shuffle_writer.rs
b/native/shuffle/benches/shuffle_writer.rs
index ea6ad83fdf..2f1f193cda 100644
--- a/native/shuffle/benches/shuffle_writer.rs
+++ b/native/shuffle/benches/shuffle_writer.rs
@@ -201,6 +201,99 @@ fn create_batch(num_rows: usize, allow_nulls: bool) ->
RecordBatch {
.unwrap()
}
+/// Benchmarks the per-block IPC encoding cost (schema + record batch) in
isolation, using the
+/// `None` codec so that compression does not obscure the schema-encoding
cost. Covers a wide flat
+/// schema and a deeply nested schema, where the schema flatbuffer is largest.
+fn schema_encoding_benchmark(c: &mut Criterion) {
+ let mut group = c.benchmark_group("shuffle_block_schema_encoding");
+
+ for (name, batch) in [
+ ("flat", flat_schema_batch(8192)),
+ ("nested", nested_schema_batch(8192)),
+ ] {
+ let writer =
+ ShuffleBlockWriter::try_new(batch.schema().as_ref(),
CompressionCodec::None).unwrap();
+ let ipc_time = Time::default();
+ group.bench_function(format!("write_batch ({name} schema)"), |b| {
+ let mut buffer = vec![];
+ b.iter(|| {
+ buffer.clear();
+ let mut cursor = Cursor::new(&mut buffer);
+ writer.write_batch(&batch, &mut cursor, &ipc_time).unwrap();
+ });
+ });
+ }
+
+ group.finish();
+}
+
+/// A wide flat schema of primitive columns.
+fn flat_schema_batch(num_rows: usize) -> RecordBatch {
+ let num_cols = 50;
+ let fields: Vec<Field> = (0..num_cols)
+ .map(|i| Field::new(format!("c{i}"), DataType::Int32, false))
+ .collect();
+ let schema = Arc::new(Schema::new(fields));
+ let columns: Vec<Arc<dyn Array>> = (0..num_cols)
+ .map(|i| {
+ let values: Vec<i32> = (0..num_rows as i32).map(|r| r +
i).collect();
+ Arc::new(Int32Array::from(values)) as Arc<dyn Array>
+ })
+ .collect();
+ RecordBatch::try_new(schema, columns).unwrap()
+}
+
+/// A schema of several deeply nested struct columns.
+fn nested_schema_batch(num_rows: usize) -> RecordBatch {
+ let num_cols = 4;
+ let depth = 6;
+
+ let mut fields: Vec<Field> = Vec::with_capacity(num_cols);
+ let mut columns: Vec<Arc<dyn Array>> = Vec::with_capacity(num_cols);
+ for col in 0..num_cols {
+ let array = nested_struct_array(num_rows, depth);
+ fields.push(Field::new(
+ format!("col{col}"),
+ array.data_type().clone(),
+ false,
+ ));
+ columns.push(array);
+ }
+ let schema = Arc::new(Schema::new(fields));
+ RecordBatch::try_new(schema, columns).unwrap()
+}
+
+/// Builds a struct array with a multi-field leaf, wrapped in `depth`
single-field structs.
+fn nested_struct_array(num_rows: usize, depth: usize) -> Arc<dyn Array> {
+ use arrow::array::{Float64Array, Int64Array, StringArray, StructArray};
+
+ // Leaf: struct<a: int64, b: utf8, c: float64>
+ let mut array: Arc<dyn Array> = Arc::new(StructArray::from(vec![
+ (
+ Arc::new(Field::new("a", DataType::Int64, false)),
+ Arc::new(Int64Array::from(vec![1_i64; num_rows])) as Arc<dyn
Array>,
+ ),
+ (
+ Arc::new(Field::new("b", DataType::Utf8, false)),
+ Arc::new(StringArray::from(vec!["x"; num_rows])) as Arc<dyn Array>,
+ ),
+ (
+ Arc::new(Field::new("c", DataType::Float64, false)),
+ Arc::new(Float64Array::from(vec![1.0_f64; num_rows])) as Arc<dyn
Array>,
+ ),
+ ]));
+
+ for level in 0..depth {
+ let field = Arc::new(Field::new(
+ format!("s{level}"),
+ array.data_type().clone(),
+ false,
+ ));
+ array = Arc::new(StructArray::from(vec![(field, array)]));
+ }
+ array
+}
+
fn config() -> Criterion {
Criterion::default()
}
@@ -208,6 +301,6 @@ fn config() -> Criterion {
criterion_group! {
name = benches;
config = config();
- targets = criterion_benchmark
+ targets = criterion_benchmark, schema_encoding_benchmark
}
criterion_main!(benches);
diff --git a/native/shuffle/src/shuffle_writer.rs
b/native/shuffle/src/shuffle_writer.rs
index bf296a13a2..12025fe80d 100644
--- a/native/shuffle/src/shuffle_writer.rs
+++ b/native/shuffle/src/shuffle_writer.rs
@@ -313,6 +313,44 @@ mod test {
}
}
+ /// A dictionary-typed column must roundtrip. Such schemas take the
`StreamWriter` fallback
+ /// (rather than the pre-encoded schema path), because dictionary encoding
requires the schema
+ /// and record batch to share a dictionary tracker.
+ #[test]
+ #[cfg_attr(miri, ignore)] // miri can't call foreign function
`ZSTD_createCCtx`
+ fn roundtrip_ipc_dictionary() {
+ use arrow::array::DictionaryArray;
+ use arrow::datatypes::Int32Type;
+
+ let values: Vec<String> = (0..8192).map(|i| format!("v{}", i %
7)).collect();
+ let dict: DictionaryArray<Int32Type> = values.iter().map(|s|
s.as_str()).collect();
+ let schema = Arc::new(Schema::new(vec![Field::new(
+ "d",
+ dict.data_type().clone(),
+ false,
+ )]));
+ let batch =
+ RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(dict) as
Arc<dyn Array>])
+ .unwrap();
+
+ for codec in &[
+ CompressionCodec::None,
+ CompressionCodec::Zstd(1),
+ CompressionCodec::Snappy,
+ CompressionCodec::Lz4Frame,
+ ] {
+ let mut output = vec![];
+ let mut cursor = Cursor::new(&mut output);
+ let writer = ShuffleBlockWriter::try_new(schema.as_ref(),
codec.clone()).unwrap();
+ writer
+ .write_batch(&batch, &mut cursor, &Time::default())
+ .unwrap();
+
+ let batch2 = read_ipc_compressed(&output[16..]).unwrap();
+ assert_eq!(batch, batch2);
+ }
+ }
+
#[test]
#[cfg_attr(miri, ignore)] // miri can't call foreign function
`ZSTD_createCCtx`
fn test_single_partition_shuffle_writer() {
diff --git a/native/shuffle/src/writers/shuffle_block_writer.rs
b/native/shuffle/src/writers/shuffle_block_writer.rs
index 5ed5330e3a..7b6846b3ba 100644
--- a/native/shuffle/src/writers/shuffle_block_writer.rs
+++ b/native/shuffle/src/writers/shuffle_block_writer.rs
@@ -16,12 +16,24 @@
// under the License.
use arrow::array::RecordBatch;
-use arrow::datatypes::Schema;
-use arrow::ipc::writer::StreamWriter;
+use arrow::datatypes::{DataType, Schema, SchemaRef};
+use arrow::ipc::writer::{
+ write_message, CompressionContext, DictionaryTracker, IpcDataGenerator,
IpcWriteOptions,
+ StreamWriter,
+};
+use arrow::ipc::MetadataVersion;
use datafusion::common::DataFusionError;
use datafusion::error::Result;
use datafusion::physical_plan::metrics::Time;
-use std::io::{Cursor, Seek, SeekFrom, Write};
+use std::io::{Seek, SeekFrom, Write};
+use std::sync::Arc;
+
+/// Arrow IPC stream end-of-stream marker: the continuation marker
(`0xFFFFFFFF`) followed by a
+/// zero message length. This is what `StreamWriter::finish` emits for
metadata version V5 with
+/// non-legacy framing; a V4-legacy stream would instead emit four zero bytes
with no continuation
+/// marker. The fast path pins its `IpcWriteOptions` to V5 (see
[`ShuffleBlockWriter::try_new`]), so
+/// this constant is valid; if that assumption ever changes, this must be
revisited.
+const IPC_EOS: [u8; 8] = [0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00];
/// Compression algorithm applied to shuffle IPC blocks.
#[derive(Debug, Clone)]
@@ -32,42 +44,136 @@ pub enum CompressionCodec {
Snappy,
}
+/// How a writer encodes the Arrow IPC schema at the start of each block. The
two arms are mutually
+/// exclusive by schema shape, decided once in
[`ShuffleBlockWriter::try_new`], so the retained
+/// state never carries both a pre-encoded message and a schema at the same
time.
+///
+/// Both arms hold their payload behind an `Arc` because a
`ShuffleBlockWriter` is cloned once per
+/// output partition (see `LocalPartitionWriter`), and a shuffle can request
millions of partitions.
+/// Deep-copying the pre-encoded schema per clone would allocate gigabytes for
a large partition
+/// count; sharing it makes cloning O(1).
+#[derive(Clone)]
+enum SchemaEncoding {
+ /// Dictionary-free schema: the IPC schema message, pre-encoded once, is
written verbatim at the
+ /// start of every block instead of being re-serialized per block.
+ Precoded(Arc<[u8]>),
+ /// Schema containing dictionary types: the schema and record batch must
share a dictionary
+ /// tracker, so each block is encoded with `StreamWriter`, which
re-serializes the schema.
+ Fallback(SchemaRef),
+}
+
/// Writes a record batch as a length-prefixed, compressed Arrow IPC block.
+///
+/// Each block is a self-contained Arrow IPC stream (schema message,
dictionary messages, record
+/// batch message, end-of-stream marker). For the common case of a schema with
no dictionary types,
+/// the schema flatbuffer is encoded once in [`Self::try_new`] and written
verbatim at the start of
+/// every block, rather than being re-serialized per block as
`StreamWriter::try_new` would do.
+/// Schemas that contain dictionary types fall back to `StreamWriter`, whose
dictionary-id
+/// bookkeeping ties schema and batch encoding together.
#[derive(Clone)]
pub struct ShuffleBlockWriter {
codec: CompressionCodec,
- header_bytes: Vec<u8>,
+ /// Shared behind an `Arc` so cloning the writer per output partition
stays O(1); see the note
+ /// on [`SchemaEncoding`].
+ header_bytes: Arc<[u8]>,
+ /// IPC options shared by the schema and record-batch encoders so the two
can never diverge;
+ /// pinned to metadata version V5, which [`IPC_EOS`] depends on.
+ write_options: IpcWriteOptions,
+ schema_encoding: SchemaEncoding,
}
impl ShuffleBlockWriter {
pub fn try_new(schema: &Schema, codec: CompressionCodec) -> Result<Self> {
- let header_bytes = Vec::with_capacity(20);
- let mut cursor = Cursor::new(header_bytes);
+ // Header layout: 8-byte block length placeholder + 8-byte field count
(usize) + 4-byte
+ // codec tag = 20 bytes.
+ let mut header_bytes = Vec::with_capacity(20);
- // leave space for compressed message length
- cursor.seek_relative(8)?;
+ // leave space for compressed message length (filled in per block by
write_batch)
+ header_bytes.extend_from_slice(&[0u8; 8]);
// write number of columns because JVM side needs to know how many
addresses to allocate
let field_count = schema.fields().len();
- cursor.write_all(&field_count.to_le_bytes())?;
+ header_bytes.extend_from_slice(&field_count.to_le_bytes());
// write compression codec to header
- let codec_header = match &codec {
+ let codec_header: &[u8] = match &codec {
CompressionCodec::Snappy => b"SNAP",
CompressionCodec::Lz4Frame => b"LZ4_",
CompressionCodec::Zstd(_) => b"ZSTD",
CompressionCodec::None => b"NONE",
};
- cursor.write_all(codec_header)?;
+ header_bytes.extend_from_slice(codec_header);
- let header_bytes = cursor.into_inner();
+ // Shuffle blocks are always written with metadata version V5. Pin it
explicitly rather than
+ // relying on `IpcWriteOptions::default`, because IPC_EOS is only the
correct end-of-stream
+ // marker for V5. Alignment 64 and non-legacy framing match the arrow
defaults.
+ let write_options = IpcWriteOptions::try_new(64, false,
MetadataVersion::V5)?;
+
+ // `flattened_fields` walks the full nested field tree, so this
catches dictionary types
+ // nested under any container arrow itself recurses into when emitting
dictionaries.
+ let has_dictionaries = schema
+ .flattened_fields()
+ .iter()
+ .any(|f| matches!(f.data_type(), DataType::Dictionary(_, _)));
+
+ // For dictionary-free schemas, pre-encode the IPC schema message once
so it does not have
+ // to be re-serialized per block. Dictionary schemas use the
`StreamWriter` fallback.
+ let schema_encoding = if has_dictionaries {
+ SchemaEncoding::Fallback(Arc::new(schema.clone()))
+ } else {
+ let data_gen = IpcDataGenerator::default();
+ let mut dictionary_tracker = DictionaryTracker::new(true);
+ let encoded_schema =
data_gen.schema_to_bytes_with_dictionary_tracker(
+ schema,
+ &mut dictionary_tracker,
+ &write_options,
+ );
+ let mut buf = Vec::new();
+ write_message(&mut buf, encoded_schema, &write_options)?;
+ SchemaEncoding::Precoded(Arc::from(buf))
+ };
Ok(Self {
codec,
- header_bytes,
+ header_bytes: Arc::from(header_bytes),
+ write_options,
+ schema_encoding,
})
}
+ /// Serialize `batch` as a standalone Arrow IPC stream into `out`.
+ fn encode_ipc_stream<W: Write>(&self, batch: &RecordBatch, out: &mut W) ->
Result<()> {
+ let schema_message = match &self.schema_encoding {
+ SchemaEncoding::Fallback(schema) => {
+ // Dictionary encoding requires the schema and record batch to
share a dictionary
+ // tracker, so `StreamWriter` (which re-encodes the schema per
block) is used here.
+ let mut stream_writer =
+ StreamWriter::try_new_with_options(out, schema,
self.write_options.clone())?;
+ stream_writer.write(batch)?;
+ stream_writer.finish()?;
+ return Ok(());
+ }
+ SchemaEncoding::Precoded(schema_message) => schema_message,
+ };
+
+ // Fast path: reuse the pre-encoded schema message and write the
record batch manually.
+ let data_gen = IpcDataGenerator::default();
+ let mut dictionary_tracker = DictionaryTracker::new(true);
+ let mut compression_context = CompressionContext::default();
+ let (encoded_dictionaries, encoded_batch) = data_gen.encode(
+ batch,
+ &mut dictionary_tracker,
+ &self.write_options,
+ &mut compression_context,
+ )?;
+ debug_assert!(encoded_dictionaries.is_empty());
+
+ out.write_all(schema_message)?;
+ write_message(&mut *out, encoded_batch, &self.write_options)?;
+ out.write_all(&IPC_EOS)?;
+ Ok(())
+ }
+
/// Writes given record batch as Arrow IPC bytes into given writer.
/// Returns number of bytes written.
pub fn write_batch<W: Write + Seek>(
@@ -86,42 +192,30 @@ impl ShuffleBlockWriter {
// write header
output.write_all(&self.header_bytes)?;
- let output = match &self.codec {
+ match &self.codec {
CompressionCodec::None => {
- let mut arrow_writer = StreamWriter::try_new(output,
&batch.schema())?;
- arrow_writer.write(batch)?;
- arrow_writer.finish()?;
- arrow_writer.into_inner()?
+ self.encode_ipc_stream(batch, output)?;
}
CompressionCodec::Lz4Frame => {
- let mut wtr = lz4_flex::frame::FrameEncoder::new(output);
- let mut arrow_writer = StreamWriter::try_new(&mut wtr,
&batch.schema())?;
- arrow_writer.write(batch)?;
- arrow_writer.finish()?;
+ let mut wtr = lz4_flex::frame::FrameEncoder::new(&mut *output);
+ self.encode_ipc_stream(batch, &mut wtr)?;
wtr.finish().map_err(|e| {
DataFusionError::Execution(format!("lz4 compression error:
{e}"))
- })?
+ })?;
}
-
- CompressionCodec::Zstd(level) => {
- let encoder = zstd::Encoder::new(output, *level)?;
- let mut arrow_writer = StreamWriter::try_new(encoder,
&batch.schema())?;
- arrow_writer.write(batch)?;
- arrow_writer.finish()?;
- let zstd_encoder = arrow_writer.into_inner()?;
- zstd_encoder.finish()?
- }
-
CompressionCodec::Snappy => {
- let mut wtr = snap::write::FrameEncoder::new(output);
- let mut arrow_writer = StreamWriter::try_new(&mut wtr,
&batch.schema())?;
- arrow_writer.write(batch)?;
- arrow_writer.finish()?;
+ let mut wtr = snap::write::FrameEncoder::new(&mut *output);
+ self.encode_ipc_stream(batch, &mut wtr)?;
wtr.into_inner().map_err(|e| {
DataFusionError::Execution(format!("snappy compression
error: {e}"))
- })?
+ })?;
}
- };
+ CompressionCodec::Zstd(level) => {
+ let mut encoder = zstd::Encoder::new(&mut *output, *level)?;
+ self.encode_ipc_stream(batch, &mut encoder)?;
+ encoder.finish()?;
+ }
+ }
// fill ipc length
let end_pos = output.stream_position()?;
@@ -134,7 +228,6 @@ impl ShuffleBlockWriter {
)));
}
- // fill ipc length
output.seek(SeekFrom::Start(start_pos))?;
output.write_all(&ipc_length.to_le_bytes())?;
output.seek(SeekFrom::Start(end_pos))?;
@@ -144,3 +237,37 @@ impl ShuffleBlockWriter {
Ok((end_pos - start_pos) as usize)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow::datatypes::{DataType, Field};
+
+ /// A `ShuffleBlockWriter` is cloned once per output partition (see
`LocalPartitionWriter`), and
+ /// a shuffle can request millions of partitions (e.g. the SPARK-48037
test uses more than 16
+ /// million). Cloning must share the immutable header and pre-encoded
schema buffers rather than
+ /// deep-copying them; otherwise a large partition count allocates
gigabytes and stalls the
+ /// executor.
+ #[test]
+ fn clone_shares_buffers() {
+ let schema = Schema::new(vec![
+ Field::new("a", DataType::Int64, false),
+ Field::new("b", DataType::Int64, false),
+ ]);
+ let writer = ShuffleBlockWriter::try_new(&schema,
CompressionCodec::None).unwrap();
+ let cloned = writer.clone();
+
+ assert!(
+ Arc::ptr_eq(&writer.header_bytes, &cloned.header_bytes),
+ "header bytes should be shared across clones, not deep-copied"
+ );
+
+ match (&writer.schema_encoding, &cloned.schema_encoding) {
+ (SchemaEncoding::Precoded(a), SchemaEncoding::Precoded(b)) =>
assert!(
+ Arc::ptr_eq(a, b),
+ "pre-encoded schema should be shared across clones, not
deep-copied"
+ ),
+ _ => panic!("dictionary-free schema should use the pre-encoded
fast path"),
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]