feat: add spark.comet.shuffle.maxBufferBytes to cap native shuffle writer memory#4989
Conversation
…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.
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.
| spillCount = find(shuffled.queryExecution.executedPlan) { | ||
| case _: CometShuffleExchangeExec => true | ||
| case _ => false | ||
| }.map(_.metrics("spill_count").value).get | ||
| } |
There was a problem hiding this comment.
| 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 |
There was a problem hiding this comment.
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
}.getKept 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, |
There was a problem hiding this comment.
perhaps we can use an Option<usize> ?
comphead
left a comment
There was a problem hiding this comment.
some non blocking minors
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
left a comment
There was a problem hiding this comment.
Thanks @andygrove! Do we have any guidance on how we might tune this?
Which issue does this PR close?
Closes #.
Rationale for this change
MultiPartitionShuffleRepartitionerbuffers input batches and per-partition row indices in memory, and spills to disk only when the DataFusion memory pool denies an allocation: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 as512mstill parse.The value is carried to the native side on a new
uint64 max_buffer_bytesfield of theShuffleWriterproto message (64-bit rather than 32-bit because a valid setting can exceed 2GB), set inCometNativeShuffleWriter.buildUnifiedPlanand threaded throughplanner.rsandShuffleWriterExecinto the repartitioner.Zero on the wire means the limit is disabled.
planner.rsnormalizes that toNoneat the proto boundary, so the writer holds anOption<usize>andSome(0)is never constructed. That keeps the sentinel in one place and matches the existingmemory_limit: Option<usize>knob in the same crate.The trigger itself is at the existing spill site:
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 ofpartition_indices.spill()callsreservation.free(), so it resets after each spill and always reflects bytes currently buffered.try_growis 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.
SinglePartitionShufflePartitionerwrites straight through and never spills, so it takes no threshold. The JVM columnar shuffle path is unaffected.shuffle_bench.rsgains a matching--max-buffer-bytesarg.How are these changes tested?
Three Rust unit tests in
native/shuffle/src/shuffle_writer.rs, all against a memory pool large enough thattry_grownever fails, so any spill observed must come from the new limit:ShuffleWriterExec, decoding the written shuffle blocks and asserting a spilling run produces the same rows in the same order as a non-spilling runThe 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 0while the others still pass.One JVM test in
CometNativeShuffleSuitecovers 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.