Conversation
Signed-off-by: Hongbin Ma <mahongbin@apache.org>
|
There was a problem hiding this comment.
🟡 Changes recommended
Critical server retry and interruption-handling issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds bounded GPU OOM retry handling for UCX shuffle send preparation, with exponential backoff and deadline enforcement.
Changes:
- Wraps preparation OOMs for retry handling.
- Requeues recoverable OOM failures with bounded backoff.
- Adds deterministic retry and deadline tests.
File summaries
| File | Summary |
|---|---|
tests/src/test/scala/com/nvidia/spark/rapids/shuffle/RapidsShuffleServerSuite.scala |
Tests initial requeue and persistent-OOM deadline behavior. |
sql-plugin/src/main/scala/com/nvidia/spark/rapids/shuffle/RapidsShuffleServer.scala |
Implements retry and timeout handling; critical issues remain with interruption handling and per-state deadline enforcement. |
sql-plugin/src/main/scala/com/nvidia/spark/rapids/shuffle/BufferSendState.scala |
Wraps preparation OOMs and resets retry state; normal-path bookkeeping has a performance nit. |
Review details
Suppressed comments (3)
sql-plugin/src/main/scala/com/nvidia/spark/rapids/shuffle/BufferSendState.scala:276
- This reset executes for every successful send preparation, so the change does add work to the normal transfer path even though retry logging and sleeping are failure-only. The
Performance: Not requiredchecklist item is therefore not justified without a benchmark/profile or a documented measured basis for ruling out a runtime impact.
resetOomRetryWindow()
sql-plugin/src/main/scala/com/nvidia/spark/rapids/shuffle/RapidsShuffleServer.scala:405
- When this deadline path throws, the exception escapes the long-running
bssExecloop (which callsdoHandleTransferRequestwithout a catch), so the single-threaded worker exits. Subsequent transfer requests stay inpendingTransfersQueueand are never processed; this abandons the whole server rather than just releasing the timed-out send state. Handle deadline exhaustion without killing the loop, or explicitly restart/fail the server as a whole.
val ise = new IllegalStateException(
s"Unable to prepare any sends. $reason The sends will not be retried.")
supressedErrors.foreach(ise.addSuppressed)
throw ise
sql-plugin/src/main/scala/com/nvidia/spark/rapids/shuffle/RapidsShuffleServer.scala:393
- This sleep runs on the single-threaded
bssExecutorloop (UCXShuffleTransport.scala:299-305). If the OOM persists untilspark.network.timeout(120 seconds by default), the worker cannot drain unrelated pending or continuing transfers for the entire recovery window, so one request can stall all shuffle sends. Use a delayed/non-blocking requeue or a separate scheduler rather than blocking this executor.
waitBeforeOomRetry(backoffMillis)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| private[shuffle] def waitBeforeOomRetry(backoffMillis: Long): Unit = | ||
| Thread.sleep(backoffMillis) |
There was a problem hiding this comment.
The revised delay is a fixed 10 ms, matching the cluster-validated prototype, rather than a growing delay. Each attempt then returns to the outer queue-draining loop; it does not sleep continuously for spark.network.timeout. The worker already uses interruptible Object.wait(100) in that same loop, so interruption is existing executor-shutdown behavior rather than a new 120-second blocking mode. More importantly, timeout exhaustion no longer throws from doHandleTransferRequest: it closes the affected states, logs the suppressed causes, and returns, so persistent OOM cannot terminate the sole BSS worker. The unit test verifies that the timeout call returns and performs cleanup without another requeue.
| // Requeue sends that could not acquire enough memory. Non-memory failures | ||
| // reach this point only when another send made progress. | ||
| addToContinueQueue(toTryAgain.toSeq) |
There was a problem hiding this comment.
This is intentionally a continuous zero-progress timeout rather than a per-state wall-clock deadline. If another BufferSendState prepares successfully, the BSS worker has made forward progress; the revised code resets the failed states' OOM retry accounting and requeues them. If a later batch makes no progress, a new timeout window starts. I added a unit test that verifies the reset and requeue behavior. This avoids expiring a request merely because it previously shared a batch with an OOM while still preventing an all-OOM zero-progress loop from running forever.
Signed-off-by: Hongbin Ma <mahongbin@apache.org>
|
build |
|
The failing Maven install/verify matrix is blocked before compiling this PR by an integration regression currently reproducible on After #15944 and #15957 were both merged, The isolated fixture fix and reproduction are in #15985. After that base fix lands, I will refresh this PR's Maven checks. |
Follow-up to #15944 and #15957. ### Description After both Iceberg packaging changes reached `main`, `package-parallel-worlds.py` began reading `system_runtime_path` from the loaded `iceberg_runtime.py` module. `RootSafeProviderSelectionTest` creates a minimal fake version of that module, but the fixture still defined only `coordinates`. As a result, the standard-assembler subtest failed with `KeyError: 'system_runtime_path'` before Maven compilation, causing install and verify matrix jobs on subsequent pull requests to fail or be canceled by fail-fast. This change adds the missing `system_runtime_path` function to the fake module. It returns `None`, matching the test fixture's existing behavior when no explicit system Iceberg runtime is configured. Production code is unchanged. Validation on current `main` (`b23ab02e986767dfd26fde2e221d58658d9a2179`): ```text $ python3 dist/scripts/tests/test_root_safe_provider_selection.py . ---------------------------------------------------------------------- Ran 1 test in 0.251s OK ``` The failure was also reproduced before the fixture change with the same `KeyError` reported by the Maven matrix in #15982. This change was prepared with AI assistance and reviewed by the author. ### Checklists Documentation - [ ] Updated for new or modified user-facing features or behaviors - [x] No user-facing change Testing - [x] Added or modified tests to cover new code paths - [ ] Covered by existing tests - [ ] Not required Performance - [ ] Tests ran and results are added in the PR description - [ ] Issue filed with a link in the PR description - [x] Not required Signed-off-by: Hongbin Ma <mahongbin@apache.org>
amahussein
left a comment
There was a problem hiding this comment.
Thanks for the fix. I agree that having stopOomRetries return rather than throw is the right shape.
Requesting changes
- The one code ask is inline: the zero-progress branch uses
forall, so one expired window closes every co-batched send, including a peer's that just started. Base closed the whole batch too, so this is an incomplete improvement, but the description claims the opposite. - Two asks about the evidence rather than the code. The prototype at
429b3bf3has no retry window and a broader catch, so the 25/25 run supports catch-and-requeue but says nothing about the timeout, expiry or reset paths. And#13098is not the gap:BufferReceiveStatealready retries on a non-task thread viaRmmSpark.shuffleThreadWorkingOnTasks. The real reason is that the server has no task identity, since the waiting tasks are remote. That is a better argument.
| val buff = try { | ||
| spillable.materialize() | ||
| } catch { | ||
| case oom: OutOfMemoryError => |
There was a problem hiding this comment.
Agreed there is no GPU subtype to catch: in cudf-spark-jni, GpuOOM and OffHeapOOM both extend RuntimeException. But on the disk tier materialize() decompresses via RapidsSerializerManager.wrapStream, and spark.shuffle.spill.compress defaults true, so an lz4 heap allocation lands inside this catch and is retried for the full window as "GPU memory exhausted".
Suggest: Appending oom.toString to the message would distinguish them.
There was a problem hiding this comment.
Addressed in 90f4b4d. The wrapper message is now neutral (Memory exhausted), includes oom.toString, and preserves the original cause. The PR and issue now explicitly state that generic OutOfMemoryError at this boundary can also represent JVM-heap failure during disk-tier decompression; this change does not claim typed GPU/heap classification.
| // This allocation runs on the shuffle server's non-task thread. Yield to task threads | ||
| // so they can spill and release GPU memory before this retry. |
There was a problem hiding this comment.
The spill has already happened here: DeviceMemoryEventHandler.onAllocFailure runs synchronously inside the failing allocation, calls store.spill, and device-synchronizes up to oomMaxRetries (default 2) before RMM throws. So each retry re-pays a spill sweep plus up to two device-wide syncs, up to ~12k times over a 120s window. Worth rewording the comment and reconsidering the backoff #15981 specified.
There was a problem hiding this comment.
Addressed in 90f4b4d. Exponential backoff is restored at 10/20/40/80/100 ms with a 100 ms cap, matching #15981. The comment now explains that the delay avoids a hot loop because each failed materialization can repeat spill sweeps and device synchronization; it no longer claims the sleep gives task threads their first opportunity to spill. Backoff bounds are covered by the suite.
| } | ||
| } | ||
|
|
||
| test("OOM retry accounting resets after shuffle send progress") { |
There was a problem hiding this comment.
Test 1 does pin the boundary: it drives a real BufferSendState. This one mocks both, so it verifies the call, not the effect. Replacing the body of resetOomRetryWindow with a no-op still passed all 8 tests, while deleting the call site here is caught. One case on a real state would close that gap.
There was a problem hiding this comment.
Addressed in 90f4b4d. The mock interaction test was replaced with a real BufferSendState behavior test: it opens an OOM window, successfully prepares a send, and verifies that an OOM at the old deadline starts a fresh window. A no-op reset body or removal of the successful-preparation reset call makes the assertion return None instead of Some(1).
There was a problem hiding this comment.
Confirmed by mutation: a no-op resetOomRetryWindow body now fails a test, where it passed all 8 before. That was the gap, so this is closed.
Signed-off-by: Hongbin Ma <mahongbin@apache.org>
|
Addressed the requested changes in 90f4b4d. In addition to the inline fixes, I refreshed the PR and #15981 descriptions so the evidence and implementation contracts match: the 25/25 prototype validates only catch-and-requeue reachability/output correctness, not the current timeout/expiry/reset/backoff paths; and the RMM-retry rationale now identifies the sender server lacking the remote waiting task identities, while acknowledging that BufferReceiveState already registers known task IDs on its non-task thread. Local Spark 3.5.3/CUDA 13 ARM64 validation passed the full -pl tests -am install and RapidsShuffleServerSuite 10/10. |
|
build |
1 similar comment
|
build |
amahussein
left a comment
There was a problem hiding this comment.
Nothing blocking, and two small asks, neither of which needs a respin:
-
The Performance box ticks "Tests ran and results are added in the PR description", but the description has no timings, and says "No performance improvement is claimed". Either paste the wall-clock numbers from the 25/25 Q21 run, or tick "Not required" instead.
-
The template's Description section asks what the user experience will be. Under sustained GPU pressure a UCX send now fails after up to
spark.network.timeoutinstead of immediately, and that setting now also bounds sender-side retry. One sentence saying so, and then whichever Documentation box matches.
For a follow-up rather than this PR: the backoff is skipped whenever any co-batched send makes progress, so a failing send re-materializes at loop speed until its window expires. That gating predates this PR and the window now bounds it, so it is not urgent, but a per-state earliest-retry timestamp would express the per-state contract this commit introduces. Happy to file it if you would rather not carry it.
| } | ||
|
|
||
| if (bssBuffers.isEmpty && retryableOom.nonEmpty) { | ||
| val retryAttempt = retryableOom.flatMap(_._3).max |
There was a problem hiding this comment.
Non-blocking, no need to respin for it. Changing this .max to .min passes all ten tests, so the aggregate is unpinned. max is the right choice: min would hold the delay at 10 ms whenever a fresh state joins the batch, which is the hot loop this commit removes. Worth one assertion next time you touch this file.
There was a problem hiding this comment.
Agreed. max is intentional so a fresh co-batched state cannot hold the aggregate delay at 10 ms, but the existing tests do not distinguish max from min. I will keep the per-state earliest-retry timestamp and its mutation-resistant assertion as follow-up work rather than respinning this approved patch.
| val spillable = blockRange.block.bufferHandle.spillable | ||
| val buff = spillable.materialize() | ||
| val buff = try { | ||
| spillable.materialize() |
There was a problem hiding this comment.
A retry is OK to unblock, but it is not what I was expecting.
We are packing a bounce buffer here which is already allocated. If the source data could not be materialized to GPU, especially if it's in host mem currently, this retry should be "copy from the tier where the buffer is currently". The next issue then is ensuring we have host buffers for a disk->host->bounce buffer trampoline.
Currently getting a host bounce buffer is optional. I think we could make that required, in which case we would always be able to satisfy the send, guaranteeing that OOM errors go away. We still need to handle transport errors but this class of errors that could cause nightmare spills while we insist to materialize go away.
There was a problem hiding this comment.
Agreed that tier-aware sending is the stronger architectural endpoint: if the source is already in host or disk tier, repeatedly rematerializing it on device can cause unnecessary spill churn. This PR remains the bounded recovery path that unblocks the current failure. Making a host bounce buffer reliably available and copying from the source tier into it needs a separate design covering reservation/capacity, lifecycle, and device/host/disk source paths; I will keep that scope out of this patch.
|
Thanks, Ahmed. I updated the PR description to state the user-visible behavior under sustained memory pressure and that spark.network.timeout now also bounds sender-side recovery. I also changed the Performance checklist to Not required because this PR makes no performance claim. |
|
CI blocked by #16004 |
Fixes #15981.
Description
The UCX shuffle server can encounter an
OutOfMemoryErrorwhenSpillableDeviceBufferHandle.materialize()restores a spilled shuffle buffer while preparing a send. The observed production exception was emitted by an RMM allocation through JNI, but Java exposes the genericOutOfMemoryErrortype. The existingBufferSendStatehandler catches onlyException, so this error escaped the shuffle-server background thread. In addition, the server terminated when every send in a server batch failed preparation, even when each failure came from this materialization boundary.From the user's perspective, under sustained memory pressure an affected UCX send can now retry for up to
spark.network.timeoutbefore failing instead of failing immediately; the existing setting therefore also bounds sender-side recovery. This change introduces no new configuration.This change:
OutOfMemoryErroronly aroundSpillableDeviceBufferHandle.materialize(), rather than around the entire send-preparation path, and wraps it inRapidsShuffleSendPrepareException;OutOfMemoryErrortext in the diagnostic because this Java type can represent either the observed native/RMM failure or a JVM-heap failure during disk-tier decompression;BufferSendStatean independent continuous materialization-OOM episode: its first OOM starts the retry window, and only that same state successfully preparing a send resets the window;spark.network.timeout, without claiming that the sender's deadline is synchronized with the receiver's polling deadline;BufferSendStatereset behavior.The server batch is only a scheduling unit. It is not a Spark task, stage, or shuffle failure boundary. Consequently, one expired transfer request must not close a fresh request or have its own retry window reset by another peer's progress.
The retry is implemented at the shuffle-server state-machine boundary rather than by mechanically wrapping the allocation in
RmmRapidsRetryIterator.withRetryNoSplit.BufferReceiveStatecan register its known local task IDs withRmmSpark.shuffleThreadWorkingOnTasks, but the sender's BSS worker is serving remote waiting tasks and does not have their task identities. It therefore cannot participate correctly in the task-scoped RMM retry protocol.An earlier prototype of the catch-and-requeue path was validated on an eight-node B200 Spark 3.5.3 workload. It completed 25 out of 25 Q21 attempts, executed the sender OOM retry branch 29 times, and produced the same full-result SHA-256 in every retained result:
614b224bd54f3248fcdd0762d4511ab1d352f2a034bf54ec1e7dc3358d5d49d3. The prototype fix isbinmahone/spark-rapids@429b3bf3fd43f39744d7134dbb2a973115cefeda; the retained build/source revision isbinmahone/spark-rapids@a3392fb3e935be3f86f47c5c476fa3bb80ad7c56.That production evidence is intentionally limited: the prototype used a broader catch, fixed 10 ms delay, and no timeout, per-state expiry, or reset behavior. The 25/25 result validates production reachability of catch-and-requeue and the retained outputs, not the current PR's timeout, expiry, reset, exception-boundary, or backoff behavior.
The current behavior was validated locally at commit
90f4b4dd5dca3907c611bdb749927bbe302141e5with Spark 3.5.3 and CUDA 13 on ARM64:The reset test uses a real
BufferSendStateand verifies the next retry episode receives a fresh window. ReplacingresetOomRetryWindowwith a no-op or removing its successful-preparation call site makes that test fail.This change does not eliminate the underlying memory pressure, distinguish native/GPU and JVM-heap OOM with a typed exception, immediately propagate an exhausted retry window to the peer, or implement tier-aware sending. After local timeout cleanup, the peer can still discover the failed request through its own fetch timeout; immediate protocol-level failure propagation remains separate work.
No performance improvement is claimed.
This change was prepared with AI assistance and reviewed by the author.
Checklists
Documentation
Testing
Performance