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 1981091602 feat: add spark.comet.shuffle.maxBufferBytes to cap native
shuffle writer memory (#4989)
1981091602 is described below
commit 198109160221e41cc97ecd7e415971006fa3b637
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 22 06:42:40 2026 -0600
feat: add spark.comet.shuffle.maxBufferBytes to cap native shuffle writer
memory (#4989)
---
docs/source/contributor-guide/native_shuffle.md | 4 +-
native/core/src/execution/planner.rs | 5 +
native/proto/src/proto/operator.proto | 3 +
native/shuffle/benches/shuffle_writer.rs | 1 +
native/shuffle/src/bin/shuffle_bench.rs | 9 ++
native/shuffle/src/partitioners/multi_partition.rs | 15 +-
native/shuffle/src/shuffle_writer.rs | 177 ++++++++++++++++++++-
.../main/scala/org/apache/comet/CometConf.scala | 13 ++
.../shuffle/CometNativeShuffleWriter.scala | 1 +
.../comet/exec/CometNativeShuffleSuite.scala | 34 ++++
10 files changed, 253 insertions(+), 9 deletions(-)
diff --git a/docs/source/contributor-guide/native_shuffle.md
b/docs/source/contributor-guide/native_shuffle.md
index c46b8b45a8..4d6421de5e 100644
--- a/docs/source/contributor-guide/native_shuffle.md
+++ b/docs/source/contributor-guide/native_shuffle.md
@@ -214,7 +214,9 @@ sizes.
Native shuffle uses DataFusion's memory management with spilling support:
- **Memory pool**: Tracks memory usage across the shuffle operation.
-- **Spill threshold**: When buffered data exceeds the threshold, partitions
spill to disk.
+- **Spill triggers**: Partitions spill to disk when the memory pool denies an
allocation, or
+ when the buffered bytes reach `spark.comet.shuffle.maxBufferBytes`. That
config defaults to
+ 0, which disables the fixed limit and leaves memory pressure as the only
trigger.
- **Per-partition spilling**: Each partition has its own spill file. Multiple
spills for a
partition are concatenated when writing the final output.
- **Scratch space**: Reusable buffers for partition ID computation to reduce
allocations.
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 33e7df9387..339bfff156 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -1633,6 +1633,10 @@ impl PhysicalPlanner {
}?;
let write_buffer_size = writer.write_buffer_size as usize;
+ // Zero on the wire means the limit is disabled; normalize it
here so the writer
+ // only ever sees a real limit or none at all.
+ let max_buffer_bytes =
+ (writer.max_buffer_bytes >
0).then_some(writer.max_buffer_bytes as usize);
let shuffle_writer = Arc::new(ShuffleWriterExec::try_new(
writer_input,
partitioning,
@@ -1641,6 +1645,7 @@ impl PhysicalPlanner {
writer.output_index_file.clone(),
writer.tracing_enabled,
write_buffer_size,
+ max_buffer_bytes,
)?);
Ok((
diff --git a/native/proto/src/proto/operator.proto
b/native/proto/src/proto/operator.proto
index 00158644d1..d00979f0a0 100644
--- a/native/proto/src/proto/operator.proto
+++ b/native/proto/src/proto/operator.proto
@@ -340,6 +340,9 @@ message ShuffleWriter {
// to absorb DataFusion-vs-Spark type drift. Empty when the child is a
placeholder Scan;
// that path already has a cast point upstream.
repeated SparkStructField expected_output_schema = 9;
+ // Maximum number of bytes that the writer buffers in memory before spilling
to disk.
+ // Zero means no limit, in which case spilling is driven only by memory pool
pressure.
+ uint64 max_buffer_bytes = 10;
}
message ParquetWriter {
diff --git a/native/shuffle/benches/shuffle_writer.rs
b/native/shuffle/benches/shuffle_writer.rs
index 27abd919fa..ea6ad83fdf 100644
--- a/native/shuffle/benches/shuffle_writer.rs
+++ b/native/shuffle/benches/shuffle_writer.rs
@@ -153,6 +153,7 @@ fn create_shuffle_writer_exec(
"/tmp/index.out".to_string(),
false,
1024 * 1024,
+ None,
)
.unwrap()
}
diff --git a/native/shuffle/src/bin/shuffle_bench.rs
b/native/shuffle/src/bin/shuffle_bench.rs
index cc1c960848..cd43d41dbc 100644
--- a/native/shuffle/src/bin/shuffle_bench.rs
+++ b/native/shuffle/src/bin/shuffle_bench.rs
@@ -106,6 +106,10 @@ struct Args {
#[arg(long, default_value_t = 1048576)]
write_buffer_size: usize,
+ /// Maximum bytes buffered in memory before spilling (unset = spill on
memory pressure only)
+ #[arg(long)]
+ max_buffer_bytes: Option<usize>,
+
/// Limit rows processed per iteration (0 = no limit)
#[arg(long, default_value_t = 0)]
limit: usize,
@@ -413,6 +417,7 @@ fn run_shuffle_write(
args.batch_size,
args.memory_limit,
args.write_buffer_size,
+ args.max_buffer_bytes,
args.limit,
data_file.to_string(),
index_file.to_string(),
@@ -436,6 +441,7 @@ async fn execute_shuffle_write(
batch_size: usize,
memory_limit: Option<usize>,
write_buffer_size: usize,
+ max_buffer_bytes: Option<usize>,
limit: usize,
data_file: String,
index_file: String,
@@ -480,6 +486,7 @@ async fn execute_shuffle_write(
index_file,
false,
write_buffer_size,
+ max_buffer_bytes,
)
.expect("Failed to create ShuffleWriterExec");
@@ -543,6 +550,7 @@ fn run_concurrent_shuffle_writes(
let batch_size = args.batch_size;
let memory_limit = args.memory_limit;
let write_buffer_size = args.write_buffer_size;
+ let max_buffer_bytes = args.max_buffer_bytes;
let limit = args.limit;
handles.push(tokio::spawn(async move {
@@ -553,6 +561,7 @@ fn run_concurrent_shuffle_writes(
batch_size,
memory_limit,
write_buffer_size,
+ max_buffer_bytes,
limit,
data_file,
index_file,
diff --git a/native/shuffle/src/partitioners/multi_partition.rs
b/native/shuffle/src/partitioners/multi_partition.rs
index be2d977e83..5644b1085d 100644
--- a/native/shuffle/src/partitioners/multi_partition.rs
+++ b/native/shuffle/src/partitioners/multi_partition.rs
@@ -112,6 +112,10 @@ pub(crate) struct MultiPartitionShuffleRepartitioner<T:
PartitionWriter> {
batch_size: usize,
/// Reservation for repartitioning
reservation: MemoryReservation,
+ /// Spill once the reservation reaches this many bytes, independently of
whether the memory
+ /// pool still has capacity. `None` disables the limit, leaving pool
pressure as the only
+ /// spill trigger.
+ max_buffer_bytes: Option<usize>,
tracing_enabled: bool,
/// Start addresses (as `usize`, since raw pointers are not `Send`) of the
backing buffers
/// currently pinned by `buffered_batches`, so the spill reservation
charges each distinct
@@ -174,6 +178,7 @@ impl<T: PartitionWriter>
MultiPartitionShuffleRepartitioner<T> {
runtime: Arc<RuntimeEnv>,
batch_size: usize,
tracing_enabled: bool,
+ max_buffer_bytes: Option<usize>,
) -> datafusion::common::Result<Self> {
let num_output_partitions = partitioning.partition_count();
assert_ne!(
@@ -211,6 +216,7 @@ impl<T: PartitionWriter>
MultiPartitionShuffleRepartitioner<T> {
scratch,
batch_size,
reservation,
+ max_buffer_bytes,
tracing_enabled,
pinned_buffers: HashSet::new(),
})
@@ -451,7 +457,14 @@ impl<T: PartitionWriter>
MultiPartitionShuffleRepartitioner<T> {
mem_growth += after_size.saturating_sub(before_size);
}
- if self.reservation.try_grow(mem_growth).is_err() {
+ // `try_grow` is evaluated first so the reservation accounts for this
batch either way.
+ // Checking after buffering lets the writer overshoot the limit by at
most one batch,
+ // which is how the memory-pressure trigger already behaves.
+ if self.reservation.try_grow(mem_growth).is_err()
+ || self
+ .max_buffer_bytes
+ .is_some_and(|limit| self.reservation.size() >= limit)
+ {
self.spill()?;
}
diff --git a/native/shuffle/src/shuffle_writer.rs
b/native/shuffle/src/shuffle_writer.rs
index de6989e52a..8d1336dc1c 100644
--- a/native/shuffle/src/shuffle_writer.rs
+++ b/native/shuffle/src/shuffle_writer.rs
@@ -67,6 +67,8 @@ pub struct ShuffleWriterExec {
tracing_enabled: bool,
/// Size of the write buffer in bytes
write_buffer_size: usize,
+ /// Maximum bytes buffered in memory before spilling; `None` disables the
limit
+ max_buffer_bytes: Option<usize>,
}
impl ShuffleWriterExec {
@@ -80,6 +82,7 @@ impl ShuffleWriterExec {
output_index_file: String,
tracing_enabled: bool,
write_buffer_size: usize,
+ max_buffer_bytes: Option<usize>,
) -> Result<Self> {
let cache = Arc::new(PlanProperties::new(
EquivalenceProperties::new(Arc::clone(&input.schema())),
@@ -98,6 +101,7 @@ impl ShuffleWriterExec {
codec,
tracing_enabled,
write_buffer_size,
+ max_buffer_bytes,
})
}
}
@@ -153,6 +157,7 @@ impl ExecutionPlan for ShuffleWriterExec {
self.output_index_file.clone(),
self.tracing_enabled,
self.write_buffer_size,
+ self.max_buffer_bytes,
)?)),
_ => panic!("ShuffleWriterExec wrong number of children"),
}
@@ -182,6 +187,7 @@ impl ExecutionPlan for ShuffleWriterExec {
self.codec.clone(),
self.tracing_enabled,
self.write_buffer_size,
+ self.max_buffer_bytes,
))
.try_flatten(),
)))
@@ -200,6 +206,7 @@ async fn external_shuffle(
codec: CompressionCodec,
tracing_enabled: bool,
write_buffer_size: usize,
+ max_buffer_bytes: Option<usize>,
) -> Result<SendableRecordBatchStream> {
let schema = input.schema();
@@ -237,6 +244,7 @@ async fn external_shuffle(
context.runtime_env(),
context.session_config().batch_size(),
tracing_enabled,
+ max_buffer_bytes,
)?),
};
@@ -369,6 +377,7 @@ mod test {
runtime_env,
1024,
false,
+ None,
)
.unwrap();
@@ -440,6 +449,7 @@ mod test {
runtime_env,
batch_size,
false,
+ None,
)
.unwrap();
@@ -457,6 +467,148 @@ mod test {
);
}
+ /// Buffer `num_batches` batches through a
`MultiPartitionShuffleRepartitioner` configured
+ /// with `max_buffer_bytes`, against a memory pool large enough that
`try_grow` never fails,
+ /// and return how many times it spilled.
+ async fn spill_count_with_max_buffer_bytes(
+ max_buffer_bytes: Option<usize>,
+ num_batches: usize,
+ ) -> usize {
+ let batch = create_batch(1000);
+ let num_partitions = 4;
+ // Far larger than anything these batches can reserve, so pool
pressure never triggers
+ // a spill and any spill observed must come from the max_buffer_bytes
limit.
+ let runtime_env = create_runtime(1024 * 1024 * 1024);
+ let metrics_set = ExecutionPlanMetricsSet::new();
+ let metrics = ShufflePartitionerMetrics::new(&metrics_set, 0);
+ let spill_count = metrics.spill_count.clone();
+ let dir = tempfile::tempdir().unwrap();
+ let shuffle_block_writer =
+ ShuffleBlockWriter::try_new(batch.schema().as_ref(),
CompressionCodec::Lz4Frame)
+ .unwrap();
+ let local_partition_writer = LocalPartitionWriter::try_new(
+ dir.path().join("data.out").to_str().unwrap().to_string(),
+ dir.path().join("index.out").to_str().unwrap().to_string(),
+ shuffle_block_writer,
+ num_partitions,
+ 1024,
+ 1024 * 1024,
+ Arc::clone(&runtime_env),
+ )
+ .unwrap();
+ let mut repartitioner = MultiPartitionShuffleRepartitioner::try_new(
+ 0,
+ local_partition_writer,
+ CometPartitioning::Hash(vec![Arc::new(Column::new("a", 0))],
num_partitions),
+ metrics,
+ runtime_env,
+ 1024,
+ false,
+ max_buffer_bytes,
+ )
+ .unwrap();
+
+ for _ in 0..num_batches {
+ repartitioner.insert_batch(batch.clone()).await.unwrap();
+ }
+ repartitioner.shuffle_write().unwrap();
+
+ spill_count.value()
+ }
+
+ #[tokio::test]
+ #[cfg_attr(miri, ignore)] // miri can't call foreign function
`ZSTD_createCCtx`
+ async fn max_buffer_bytes_triggers_spill_without_memory_pressure() {
+ // A limit far below what 20 batches buffer must spill, even though
the pool never
+ // denies an allocation. The paired zero case pins that the spills
come from the new
+ // limit and not from the pre-existing memory-pressure trigger.
+ let spills = spill_count_with_max_buffer_bytes(Some(8 * 1024),
20).await;
+ assert!(
+ spills > 0,
+ "a max_buffer_bytes limit below the buffered size must trigger
spilling, got {spills}"
+ );
+ }
+
+ #[tokio::test]
+ #[cfg_attr(miri, ignore)] // miri can't call foreign function
`ZSTD_createCCtx`
+ async fn max_buffer_bytes_none_leaves_spilling_to_memory_pressure() {
+ let spills = spill_count_with_max_buffer_bytes(None, 20).await;
+ assert_eq!(
+ spills, 0,
+ "an unset max_buffer_bytes disables the limit, and this pool never
denies an allocation"
+ );
+ }
+
+ /// Run a shuffle end-to-end through `ShuffleWriterExec` with the given
`max_buffer_bytes`
+ /// and return the shuffled rows in output-file order.
+ fn shuffle_output_with_max_buffer_bytes(
+ dir: &std::path::Path,
+ tag: &str,
+ max_buffer_bytes: Option<usize>,
+ ) -> Vec<String> {
+ let batch = create_batch(1000);
+ let batches = (0..20).map(|_| batch.clone()).collect::<Vec<_>>();
+ let data_file = dir.join(format!("{tag}_data.out"));
+ let index_file = dir.join(format!("{tag}_index.out"));
+
+ let exec = ShuffleWriterExec::try_new(
+ Arc::new(DataSourceExec::new(Arc::new(
+ MemorySourceConfig::try_new(std::slice::from_ref(&batches),
batch.schema(), None)
+ .unwrap(),
+ ))),
+ CometPartitioning::Hash(vec![Arc::new(Column::new("a", 0))], 16),
+ CompressionCodec::Zstd(1),
+ data_file.to_str().unwrap().to_string(),
+ index_file.to_str().unwrap().to_string(),
+ false,
+ 1024 * 1024,
+ max_buffer_bytes,
+ )
+ .unwrap();
+
+ // Large enough that the pool never denies an allocation, so only
max_buffer_bytes
+ // decides whether this run spills.
+ let runtime_env = create_runtime(1024 * 1024 * 1024);
+ let ctx = SessionContext::new_with_config_rt(SessionConfig::new(),
runtime_env);
+ let stream = exec.execute(0, ctx.task_ctx()).unwrap();
+ Runtime::new().unwrap().block_on(collect(stream)).unwrap();
+
+ read_all_ipc_batches(&std::fs::read(&data_file).unwrap())
+ .iter()
+ .flat_map(|batch| {
+ let column = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ column
+ .iter()
+ .map(|v| v.unwrap().to_string())
+ .collect::<Vec<_>>()
+ })
+ .collect()
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore)] // miri can't call foreign function
`ZSTD_createCCtx`
+ fn max_buffer_bytes_preserves_output() {
+ // End-to-end through ShuffleWriterExec: a small limit forces repeated
spills, and the
+ // shuffled rows must come out in the same order as an unlimited
writer produces.
+ let dir = tempfile::tempdir().unwrap();
+ let unlimited = shuffle_output_with_max_buffer_bytes(dir.path(),
"unlimited", None);
+ let limited = shuffle_output_with_max_buffer_bytes(dir.path(),
"limited", Some(8 * 1024));
+
+ assert_eq!(
+ unlimited.len(),
+ 20 * 1000,
+ "every input row must be written"
+ );
+ assert_eq!(
+ limited, unlimited,
+ "spilling on the max_buffer_bytes limit must not change the
shuffled output"
+ );
+ }
+
fn create_runtime(memory_limit: usize) -> Arc<RuntimeEnv> {
Arc::new(
RuntimeEnvBuilder::new()
@@ -535,6 +687,7 @@ mod test {
"/tmp/index.out".to_string(),
false,
1024 * 1024, // write_buffer_size: 1MB default
+ None,
)
.unwrap();
@@ -594,6 +747,7 @@ mod test {
index_file.clone(),
false,
1024 * 1024,
+ None,
)
.unwrap();
@@ -741,11 +895,11 @@ mod test {
);
}
- /// Read all IPC blocks from a byte buffer written by
BufBatchWriter/ShuffleBlockWriter,
- /// returning the total number of rows.
- fn read_all_ipc_blocks(data: &[u8]) -> usize {
+ /// Decode every IPC block in a byte buffer written by
BufBatchWriter/ShuffleBlockWriter,
+ /// in file order.
+ fn read_all_ipc_batches(data: &[u8]) -> Vec<RecordBatch> {
let mut offset = 0;
- let mut total_rows = 0;
+ let mut batches = vec![];
while offset < data.len() {
// First 8 bytes are the IPC length (little-endian u64)
let ipc_length =
@@ -756,11 +910,18 @@ mod test {
// read_ipc_compressed expects data starting after the 16-byte
header
// (i.e., after length + field_count), at the codec tag
let ipc_data = &data[block_start + 8..block_end];
- let batch = read_ipc_compressed(ipc_data).unwrap();
- total_rows += batch.num_rows();
+ batches.push(read_ipc_compressed(ipc_data).unwrap());
offset = block_end;
}
- total_rows
+ batches
+ }
+
+ /// Total number of rows across all IPC blocks in a byte buffer.
+ fn read_all_ipc_blocks(data: &[u8]) -> usize {
+ read_all_ipc_batches(data)
+ .iter()
+ .map(|b| b.num_rows())
+ .sum()
}
#[test]
@@ -798,6 +959,7 @@ mod test {
index_file.to_str().unwrap().to_string(),
false,
1024 * 1024,
+ None,
)
.unwrap();
@@ -886,6 +1048,7 @@ mod test {
index_file.to_str().unwrap().to_string(),
false,
1024 * 1024,
+ None,
)
.unwrap();
diff --git a/spark/src/main/scala/org/apache/comet/CometConf.scala
b/spark/src/main/scala/org/apache/comet/CometConf.scala
index 41c99d2724..1b71486f09 100644
--- a/spark/src/main/scala/org/apache/comet/CometConf.scala
+++ b/spark/src/main/scala/org/apache/comet/CometConf.scala
@@ -552,6 +552,19 @@ object CometConf extends ShimCometConf {
.doubleConf
.createWithDefault(1.0)
+ val COMET_SHUFFLE_MAX_BUFFER_BYTES: ConfigEntry[Long] =
+ conf("spark.comet.shuffle.maxBufferBytes")
+ .category(CATEGORY_SHUFFLE)
+ .doc(
+ "Maximum number of bytes that the native shuffle writer will buffer in
memory " +
+ "before spilling to disk. The default of 0 disables this limit, in
which case " +
+ "spilling is triggered only when the memory pool denies an
allocation. Setting a " +
+ "limit caps the writer's memory footprint independently of the size
of the memory " +
+ "pool, at the cost of more frequent spilling.")
+ .bytesConf(ByteUnit.BYTE)
+ .checkValue(v => v >= 0, "Must not be negative")
+ .createWithDefault(0)
+
val COMET_DEBUG_ENABLED: ConfigEntry[Boolean] =
conf("spark.comet.debug.enabled")
.category(CATEGORY_EXEC)
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
index a486e2e861..2f1b241174 100644
---
a/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
+++
b/spark/src/main/scala/org/apache/spark/sql/comet/execution/shuffle/CometNativeShuffleWriter.scala
@@ -235,6 +235,7 @@ class CometNativeShuffleWriter[K, V](
CometConf.COMET_EXEC_SHUFFLE_COMPRESSION_ZSTD_LEVEL.get)
shuffleWriterBuilder.setWriteBufferSize(
CometConf.COMET_SHUFFLE_WRITE_BUFFER_SIZE.get().min(Int.MaxValue).toInt)
+
shuffleWriterBuilder.setMaxBufferBytes(CometConf.COMET_SHUFFLE_MAX_BUFFER_BYTES.get())
outputPartitioning match {
case p if isSinglePartitioning(p) =>
diff --git
a/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
b/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
index 7c70c373fe..75c86e3568 100644
--- a/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/exec/CometNativeShuffleSuite.scala
@@ -446,6 +446,40 @@ class CometNativeShuffleSuite extends CometTestBase with
AdaptiveSparkPlanHelper
}
}
+ test("native shuffle: maxBufferBytes triggers spilling") {
+ withParquetTable((0 until 20000).map(i => (i, (i + 1).toLong, s"str$i")),
"tbl") {
+ def spillCountWithMaxBufferBytes(maxBufferBytes: String): Long = {
+ var spillCount = 0L
+ withSQLConf(CometConf.COMET_SHUFFLE_MAX_BUFFER_BYTES.key ->
maxBufferBytes) {
+ val shuffled = sql("SELECT * FROM tbl").repartition(10, $"_1")
+ checkShuffleAnswer(shuffled, 1)
+
+ // Materialize the shuffled data. The metrics live on this plan's
exchange, so the
+ // query has to run before they are read; checkShuffleAnswer
executes copies built
+ // from the logical plan and leaves this one's accumulators
untouched.
+ shuffled.collect()
+ // AdaptiveSparkPlanHelper's collectFirst, not TreeNode's:
AdaptiveSparkPlanExec is a
+ // leaf node, so a plain tree walk stops above the exchange when AQE
is on.
+ spillCount = collectFirst(shuffled.queryExecution.executedPlan) {
+ case e: CometShuffleExchangeExec => e.metrics("spill_count").value
+ }.get
+ }
+ spillCount
+ }
+
+ // A limit far below the size of this input forces the native writer to
spill, without
+ // relying on the memory pool denying an allocation. Compared against
the default of 0
+ // rather than asserted to be an absolute zero, since the default path
is still free to
+ // spill under memory pressure and how much of that happens depends on
the pool size.
+ val limited = spillCountWithMaxBufferBytes("64k")
+ val unlimited = spillCountWithMaxBufferBytes("0")
+ assert(
+ limited > unlimited,
+ "a 64k maxBufferBytes limit must spill more than the default of 0 " +
+ s"(got $limited vs $unlimited)")
+ }
+ }
+
test("native shuffle: round robin partitioning") {
withSQLConf(
CometConf.COMET_EXEC_SHUFFLE_WITH_ROUND_ROBIN_PARTITIONING_ENABLED.key
-> "true") {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]