Skip to content

feat: add spark.comet.shuffle.maxBufferBytes to cap native shuffle writer memory#4989

Merged
andygrove merged 6 commits into
apache:mainfrom
andygrove:shuffle-max-buffer-bytes
Jul 22, 2026
Merged

feat: add spark.comet.shuffle.maxBufferBytes to cap native shuffle writer memory#4989
andygrove merged 6 commits into
apache:mainfrom
andygrove:shuffle-max-buffer-bytes

Conversation

@andygrove

@andygrove andygrove commented Jul 21, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Closes #.

Rationale for this change

MultiPartitionShuffleRepartitioner buffers input batches and per-partition row indices in memory, and spills to disk only when the DataFusion memory pool denies an allocation:

if self.reservation.try_grow(mem_growth).is_err() {
    self.spill()?;
}

That is the only spill trigger. When the pool is large or unbounded, the writer can hold an arbitrary amount of data resident before any spill happens, and there is no way to cap the writer's buffered footprint independently of the pool.

This PR adds an optional config that caps how many bytes the writer buffers. Spilling is then triggered by memory pressure or by hitting the configured limit, whichever comes first.

What changes are included in this PR?

New config spark.comet.shuffle.maxBufferBytes, defaulting to 0, which disables the limit and preserves today's behavior exactly. ByteUnit.BYTE, so a bare number means bytes and suffixed values such as 512m still parse.

The value is carried to the native side on a new uint64 max_buffer_bytes field of the ShuffleWriter proto message (64-bit rather than 32-bit because a valid setting can exceed 2GB), set in CometNativeShuffleWriter.buildUnifiedPlan and threaded through planner.rs and ShuffleWriterExec into the repartitioner.

Zero on the wire means the limit is disabled. planner.rs normalizes that to None at the proto boundary, so the writer holds an Option<usize> and Some(0) is never constructed. That keeps the sentinel in one place and matches the existing memory_limit: Option<usize> knob in the same crate.

The trigger itself is at the existing spill site:

if self.reservation.try_grow(mem_growth).is_err()
    || self
        .max_buffer_bytes
        .is_some_and(|limit| self.reservation.size() >= limit)
{
    self.spill()?;
}

The comparison value is reservation.size(), the same running estimate already used when requesting from the pool: deduped capacity of the buffers pinned by buffered batches, plus the allocated size of partition_indices. spill() calls reservation.free(), so it resets after each spill and always reflects bytes currently buffered. try_grow is evaluated first so the reservation accounts for the batch on both paths. The check runs after the batch is buffered, so the writer can overshoot by at most one batch, which is how the memory-pressure trigger already behaves.

Scope is limited to the multi-partition writer. SinglePartitionShufflePartitioner writes straight through and never spills, so it takes no threshold. The JVM columnar shuffle path is unaffected.

shuffle_bench.rs gains a matching --max-buffer-bytes arg.

How are these changes tested?

Three Rust unit tests in native/shuffle/src/shuffle_writer.rs, all against a memory pool large enough that try_grow never fails, so any spill observed must come from the new limit:

  • a small limit spills
  • an unset limit does not spill
  • end-to-end through ShuffleWriterExec, decoding the written shuffle blocks and asserting a spilling run produces the same rows in the same order as a non-spilling run

The first two are a deliberate pair: the unset case pins that the spills in the first come from the new limit rather than the pre-existing memory-pressure trigger. I verified the tests are not vacuous by temporarily disabling the new condition, which fails the first test with got 0 while the others still pass.

One JVM test in CometNativeShuffleSuite covers the plumbing the Rust tests cannot reach (config to proto to native), asserting that a 64k limit spills more than the default of 0. It compares against the default rather than asserting 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.

The design for this change was worked out using the brainstorming skill.

…iter memory

The native shuffle writer's multi-partition repartitioner buffers input
batches and per-partition row indices, and spills only when the DataFusion
memory pool denies an allocation. When the pool is large or unbounded, the
writer can hold an arbitrary amount of data resident before any spill.

Add an optional config that caps the buffered bytes. Spilling is then
triggered by memory pressure or by hitting the configured limit. The
default of 0 disables the limit and preserves existing behavior.
@andygrove
andygrove requested a review from mbutrovich July 21, 2026 13:47
Reuse the existing IPC block decoder in the shuffle writer tests rather
than adding a second copy, generalizing read_all_ipc_blocks into
read_all_ipc_batches and layering the row count on top.

Fix the JVM test to read spill metrics from a plan that actually ran.
checkShuffleAnswer executes copies built from the logical plan, so the
accumulators on the exchange the test inspected were never updated and
the spill count always read zero. Follow the pattern already used by the
"native shuffle metrics" test in the same suite.

Hoist the Parquet table out of the per-config helper so it is written
once, drop a comment that restated the spill condition, and describe the
second spill trigger in the native shuffle contributor guide.
@andygrove
andygrove marked this pull request as ready for review July 21, 2026 14:32
@andygrove andygrove added this to the 1.0.0 milestone Jul 21, 2026
Comment thread native/proto/src/proto/operator.proto Outdated
Comment on lines +461 to +465
spillCount = find(shuffled.queryExecution.executedPlan) {
case _: CometShuffleExchangeExec => true
case _ => false
}.map(_.metrics("spill_count").value).get
}

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.

Suggested change
spillCount = find(shuffled.queryExecution.executedPlan) {
case _: CometShuffleExchangeExec => true
case _ => false
}.map(_.metrics("spill_count").value).get
}
val spillCount =
shuffled.queryExecution.executedPlan.collectFirst {
case e: CometShuffleExchangeExec => e.metrics("spill_count").value
}.get

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.

Calling collectFirst on the plan directly resolves to TreeNode.collectFirst, which stops at AdaptiveSparkPlanExec since that is a LeafExecNode, so it would miss the exchange whenever AQE is on.

I went with the AdaptiveSparkPlanHelper version instead, which unwraps AQE and query stage nodes in allChildren. Same single partial function you suggested, just the curried form:

spillCount = collectFirst(shuffled.queryExecution.executedPlan) {
  case e: CometShuffleExchangeExec => e.metrics("spill_count").value
}.get

Kept it as an assignment to the outer var so the enclosing helper method still returns the count. Applied in 44f2f0f.

runtime: Arc<RuntimeEnv>,
batch_size: usize,
tracing_enabled: bool,
max_buffer_bytes: usize,

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.

perhaps we can use an Option<usize> ?

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.

Done

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

some non blocking minors

andygrove and others added 3 commits July 21, 2026 14:58
Co-authored-by: Oleks V <comphead@users.noreply.github.com>
The disabled state was carried as a sentinel 0 through ShuffleWriterExec
and MultiPartitionShuffleRepartitioner, which read as "buffer zero bytes"
at every call site and needed a `> 0` guard at the spill check. Use
Option<usize> instead, matching the existing memory_limit knob in this
crate, and normalize the proto's 0 to None once in the planner so Some(0)
is never constructed.
TreeNode.collectFirst stops at AdaptiveSparkPlanExec because it is a leaf
node, so it would miss the exchange when AQE is enabled. Use the curried
collectFirst from AdaptiveSparkPlanHelper, which unwraps AQE and query
stage nodes, in place of find plus map.

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

Thanks @andygrove! Do we have any guidance on how we might tune this?

@andygrove
andygrove merged commit 1981091 into apache:main Jul 22, 2026
71 checks passed
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