perf: grow the native shuffle writer's reservation in steps - #5387
Draft
andygrove wants to merge 3 commits into
Draft
perf: grow the native shuffle writer's reservation in steps#5387andygrove wants to merge 3 commits into
andygrove wants to merge 3 commits into
Conversation
`buffer_partitioned_batch_may_spill` reserved exactly what each batch newly pinned, which for an 8192-row `i32` column is 32 KB. Against Comet's unified memory pool every one of those is a JNI round-trip into Spark's memory manager, so a 201-partition shuffle of 10M rows spent 256 acquisitions per task, 96% of them for 32 KB, to reserve 24 MB. Track the exact resident figure in `buffered_bytes` and grow the reservation in 1 MB steps on top of it. On the same benchmark that is 13 acquisitions per task instead of 256, and 50 µs per task inside the JNI call instead of 327 µs. The cost is up to one step of reservation held beyond what the writer is using, per active writer. Two details keep the spill behaviour where it was: - the `max_buffer_bytes` limit is now compared against `buffered_bytes`, since `reservation.size()` is rounded up and would trip the limit early - a rounded-up request that the pool denies is retried at the exact deficit before spilling, so a batch that would have fit before still fits Part of apache#5383.
Adds `spark.comet.shuffle.native.reservationStepBytes`, defaulting to the 1 MB that was hardcoded, and threads it to the writer alongside the existing `maxBufferBytes` limit. Zero grows the reservation by exactly what each batch needs, which is how the writer behaved before it grew in steps. The two knobs now travel together in a `ShuffleWriterMemoryConfig` rather than as more positional `usize` and `Option<usize>` arguments, since `ShuffleWriterExec::try_new` and `external_shuffle` already carry a `write_buffer_size` that is easy to confuse with them.
The first line of the doc string had an `s` prefix but no interpolation, which scalafix's RedundantSyntax rule rejects (Lint Scala and Lint Java both run it).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Part of #5383, following the measurements in #5383 (comment). Independent of #5384 (different crates, either merge order works).
Rationale for this change
buffer_partitioned_batch_may_spillreserved exactly what each batch newly pinned. For an 8192-rowi32column that is 32 KB, and against Comet's unified memory pool everytry_growis a JNI round-trip into Spark's memory manager plus contention on its executor-wide lock.Measuring a 201-partition shuffle of 10M rows with the counters from #5384,
ShuffleRepartitioner[0]was the only consumer touching the pool, and it made 256 acquisitions per task to reserve 24 MB — 247 of them for exactly 32 KB — followed by a single release of the whole amount. Nothing about that pattern needs a round-trip per batch.What changes are included in this PR?
The repartitioner tracks the exact resident figure itself in
buffered_bytesand growsreservationin steps on top of it, so a run of small batches costs one acquisition instead of one each. The step isspark.comet.shuffle.native.reservationStepBytes, defaulting to 1 MB; zero grows by exactly what each batch needs, which is the pre-change behaviour.Two details keep the spill behaviour where it was:
max_buffer_bytesis compared againstbuffered_bytesrather thanreservation.size(). The reservation is now rounded up, so comparing against it would trip the limit early and spill sooner than before.used()reportsbuffered_bytes, which is the same number it reported before this change (the exact resident bytes), so the spill log is unaffected.The two memory knobs now travel together in a
ShuffleWriterMemoryConfiginstead of as more positional arguments —ShuffleWriterExec::try_newandexternal_shufflealready carry awrite_buffer_sizethat is easy to transpose with amax_buffer_bytesor a step, and all three are byte counts.The cost of a non-zero step is up to that much reservation held beyond what a writer is using, per concurrent shuffle-write task. The default of 1 MB keeps that bounded while still removing 95% of the calls, and the step is capped at
max_buffer_bytes / 8so a writer with a small limit cannot reserve a multiple of its own budget.How are these changes tested?
Two new unit tests, over a
MemoryPoolthat counts thetry_growcalls reaching it:small_batches_share_one_reservation_step— 40 distinct batches of 100 rows produce 1 acquisition at the default stepzero_reservation_step_reserves_per_batch— the same input produces exactly 40 with the step set to zeroThe pair pins both that the reduction comes from the step and that the config is honoured end to end. The batches are built separately rather than cloned, because
count_new_buffersdedups by buffer address and re-inserting one batch would charge nothing after the first insert.Existing coverage:
cargo test -p datafusion-comet-shuffle --lib(33 tests) passes, includingmax_buffer_bytes_triggers_spill_without_memory_pressure,max_buffer_bytes_none_leaves_spilling_to_memory_pressureandmax_buffer_bytes_preserves_output, which guard the limit semantics this touches. Also green:cargo test -p datafusion-comet --lib(163),CometNativeShuffleSuite(27),CometConfSuite(13),cargo clippy --workspace --all-targets,cargo fmt --check,spotless:check.Measured effect
Same benchmark case as the issue comment (
SQL Single INT Shuffle(201 Partition), 10M rows, native shuffle, release build, 4 GB off-heap, defaultfair_unified,local[5]), with the #5384 counters enabled and the default 1 MB step:End-to-end wall time is not a useful signal here and I do not want to claim one: the saving is ~0.3 ms out of a ~91 ms task, and between the two runs every case moved by 5-10% including the pure-Spark baseline that this change cannot affect (620 -> 659 ms). The case for the change rests on the call count and the JNI time, both measured directly, plus the reduced traffic on Spark's shared memory-manager lock at higher concurrency than a local benchmark produces.