Repository navigation
fix: retry a native acquire that Spark failed after dropping the task's entry - #6310
Conversation
…'s entry Spark removes a task's execution pool entry when its balance reaches zero, and an acquire that was parked in the pool then throws NoSuchElementException when it wakes up. Releases take no task monitor, so another native thread or a JVM consumer of the same task can empty the balance while a native acquire waits. The error is not a memory refusal, so the operator could not spill and the task failed. CometTaskMemoryManager now recognises that one exception and calls Spark again. A missing entry means the task held nothing from Spark, so the failed call has no grant to reconcile, and the retry registers the task again and waits for its share as the first call would have. After three attempts it returns a zero grant, which the native side treats as a refusal and spills. Closes apache#6224.
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: Releasing a task’s last reserved bytes could remove Spark’s bookkeeping entry while a native acquisition waited, causing
NoSuchElementExceptionand task failure. - Design approach: Retry the specifically identified missing-entry exception, with three attempts before returning a zero grant.
- Correctness / compatibility analysis: Checked Spark’s memory-pool and task-manager sources for 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2.0. The zero-balance removal rule supports retrying without losing a partial grant. Unrelated exceptions still propagate.
- Key design decisions: Matching both the task-specific message and an
ExecutionMemoryPoolframe limits recovery to the intended failure. Successful acquisitions still make one Spark call. Exception inspection and retry logging occur only on failure, and no new locks are introduced. - Implementation sketch: Two private helpers wrap acquisition. Four regression tests extend the existing suite. Reviewed all five files in the supplied base-to-head diff, including both window-documentation edits.
- Behavioral changes worth calling out: A missing entry becomes recoverable. Repeated failures return zero through the existing native refusal/overcommit handling. Direct JVM page allocations remain outside this fix, as documented in #6304.
- Suggested improvements: None meeting the P1/P2 reporting threshold. No introduced P1/P2 issues found within this review.
Reviewed full SHA 6bc7575621819b7dd5ff21cab5c07c84e58756fc against base 76493618769ff85e7a43e6f6c79f8b58d9e0b1ad. The PR remains non-draft. Routed skills: review-comet-pr and review-comet-memory-pr. The snapshot and live discussion checks contained no existing review concerns.
Validation: Freshly compiled the changed Java class and Scala suite against cached Spark 4.1.3 dependencies. All seven tests passed. Running the same suite against the base implementation reproduced failures in both race tests and the retry-cap test. This was isolated component validation. Full Maven/native builds, end-to-end spill tests, other runtime profiles and performance benchmarks were not run.
Exact-head CI: The label job passed. Comet CI, CodeQL and Check PR Title report action_required, so no build/test CI verdict is available.
|
@sunchao could you approve a CI run on |
sunchao
left a comment
There was a problem hiding this comment.
Summary
- Prior state and problem: Releasing a task’s last reserved bytes could remove Spark’s bookkeeping entry while a native acquisition waited, causing
NoSuchElementExceptionand task failure. - Design approach: Retry the specifically identified missing-entry exception, with three attempts before returning a zero grant.
- Correctness / compatibility analysis: Checked
ExecutionMemoryPoolandTaskMemoryManagersources for Spark 3.4.3, 3.5.9, 4.0.4, 4.1.3 and 4.2.0. Their zero-balance removal rule supports retrying without stranding a partial grant. Unrelated exceptions still propagate, and successful grants retain the existing accounting. - Key design decisions: Matching the task-specific message and an
ExecutionMemoryPoolframe narrowly identifies the recoverable failure. Successful acquisition still makes one Spark call. Stack inspection and retry logging stay on the exception path. No new locks or shared state are introduced. - Implementation sketch: Two private helpers wrap acquisition, four regression tests extend the existing suite, and the memory-management guide documents the behavior. Reviewed all three files in the full base-relative diff.
- Behavioral changes worth calling out: Compared with
branch-1.1, this intentionally converts the missing-entry failure into recovery or the existing zero-grant refusal/overcommit handling. Direct JVM page allocations remain outside this fix, as tracked in #6304. - Suggested improvements: None meeting the reporting threshold. No introduced P1/P2 issues found within this review.
Reviewed full SHA 790d6fcaee799ebd162d83472b522b470bf0f92a against base fef94f6cd78b18151dff57b7a936798385356de5. The PR remains non-draft. Routed skills: review-comet-pr and review-comet-memory-pr. Read the snapshot and live discussions. No unresolved review blockers were identified.
Validation: Freshly compiled the current Java class and Scala suite against cached Spark 4.1.3 dependencies using JDK 17. All nine tests passed. Running the same suite against the supplied base implementation reproduced failures in both race tests and the retry-cap test, with the other six passing. This was isolated component validation. Full Maven/native builds, end-to-end spilling, other runtime profiles and performance benchmarks were not run.
Exact-head CI: The label job passed. Comet CI and CodeQL report action_required, so no build/test CI verdict is available.
|
@andygrove this is current with |
The anchor byte the fair pool kept with Spark made an exact-fit first allocation park or come back one byte short, and holding a byte back from a parked acquire of the same task can stall it. The pool now makes the same Spark calls as main for every operation, so a request Spark can grant is granted in one call. With nothing held at drop, the teardown wait in the task-shared registry goes too, and task_shared.rs, mod.rs and spill_replay.rs match main again. A release that empties the task's balance under a parked acquire of the same task is handled by the acquire retry in CometTaskMemoryManager (apache#6310), which this change depends on. Adds exact-fit tests, a test that a panicking grow rolls back its charge, and runs the short-grant test with debug logging on so the log path that must not take the task monitor is exercised.
|
@andygrove @viirya CI passed on |
viirya
left a comment
There was a problem hiding this comment.
Thanks @dwsmith1983, and sorry this sat for a while. I went through the Spark side again on 3.4 through master, and the core argument holds. Any earlier grant inside the same acquireExecutionMemory call keeps the task's balance above zero, so the call that fails with the missing entry really was granted nothing, and retrying is safe. Matching the exact task id plus the ExecutionMemoryPool frame is also tighter than what #6403 does, which is good. The race tests fail on the old code and pass here, so they cover the change itself.
I'd like to land this as it is. There are a few things I'll pick up in #6813, since that PR already moves both call sites onto one helper:
- Plan teardown.
releasePlanexpects that a Tokio worker may be parked in Spark'sacquireMemorywhile it drops the stream. If that worker holds nothing itself, dropping the stream's reservations can take the task's balance to zero. Before this change the worker failed fast. Now it registers the task again and can park a second time while holding theTaskMemoryManagermonitor, socleanUpAllAllocatedMemorywaits for other tasks to free memory. I'll first try to reproduce this with a test where the retry parks again after the task is marked completed. If it does, the shared helper will stop retrying once the task is completed or interrupted. - The doc paragraph. A zero grant is a refusal on the
try_growpath but overcommit ongrow. The "#6304" sentence and the shuffle paragraph's "Only the shuffle allocator's callers are guarded" also go stale once both paths retry. #6813 will fold them into one paragraph.
One thing that has to happen before merge: the PR description becomes the squash commit message, and SPARK-59827 was closed as a duplicate of SPARK-59444. Could you switch it to SPARK-59444 (and apache/spark#58747), to match what the shuffle paragraph from #6403 uses? Whoever merges can also fix it in the squash message.
Switched. The description now cites SPARK-59444 and apache/spark#58747, and it no longer lists the shuffle allocator as uncovered, since #6403 added its retry. |
…cator and the native pool apache#6403 added a retry for Spark's missing task entry (SPARK-59444) to CometUnifiedShuffleMemoryAllocator, and apache#6310 added its own copy to CometTaskMemoryManager.acquireMemory. Move both into MissingTaskEntryRetry under org.apache.comet. CometTaskMemoryManager passes its task id, which keeps apache#6310's exact "key not found: <id>" match; the shuffle allocator keeps apache#6403's prefix match. After the retries run out the native pool still gets a zero grant and the shuffle allocator still throws SparkOutOfMemoryError, and the warning now carries the last exception for both. The fixtures and the waiting-request harness of both suites move to TaskMemoryTestUtils, and CometTaskMemoryManagerSuite keeps one race test next to the refusal and rethrow tests. memory_management.md describes the retry once for both call sites, says what a zero grant does to try_grow and grow, and drops the stale apache#6304 reference. Co-authored-by: Isaac <no-reply@databricks.com>
releasePlan drops a plan's stream when the task ends, while a Tokio worker may still wait in Spark's acquireMemory for the memory that stream holds. When the release empties the task's balance, the worker loses the task's entry. Before apache#6310 it failed at once; with the retry it registered the ended task again and, below its minimum share, waited for other tasks to release memory while holding the TaskMemoryManager monitor that the executor's cleanUpAllAllocatedMemory needs. MissingTaskEntryRetry now refuses without retrying when the task has completed or been killed: CometTaskMemoryManager returns a zero grant and the shuffle allocator throws SparkOutOfMemoryError. Both callers pass the TaskContext they were created in, since native threads have none of their own. Co-authored-by: Isaac <no-reply@databricks.com>
…'s entry (#6310) (#6871) Spark removes a task's execution pool entry when its balance reaches zero, and an acquire that was parked in the pool then throws NoSuchElementException when it wakes up. Releases take no task monitor, so another native thread or a JVM consumer of the same task can empty the balance while a native acquire waits. The error is not a memory refusal, so the operator could not spill and the task failed. CometTaskMemoryManager now recognises that one exception and calls Spark again. A missing entry means the task held nothing from Spark, so the failed call has no grant to reconcile, and the retry registers the task again and waits for its share as the first call would have. After three attempts it returns a zero grant, which the native side treats as a refusal and spills. Closes #6224. (cherry picked from commit c1e00e7) Co-authored-by: dustin <dwsmith1983@users.noreply.github.com>
…e task has ended (apache#6813) * refactor: share the missing task entry retry between the shuffle allocator and the native pool apache#6403 added a retry for Spark's missing task entry (SPARK-59444) to CometUnifiedShuffleMemoryAllocator, and apache#6310 added its own copy to CometTaskMemoryManager.acquireMemory. Move both into MissingTaskEntryRetry under org.apache.comet. CometTaskMemoryManager passes its task id, which keeps apache#6310's exact "key not found: <id>" match; the shuffle allocator keeps apache#6403's prefix match. After the retries run out the native pool still gets a zero grant and the shuffle allocator still throws SparkOutOfMemoryError, and the warning now carries the last exception for both. The fixtures and the waiting-request harness of both suites move to TaskMemoryTestUtils, and CometTaskMemoryManagerSuite keeps one race test next to the refusal and rethrow tests. memory_management.md describes the retry once for both call sites, says what a zero grant does to try_grow and grow, and drops the stale apache#6304 reference. Co-authored-by: Isaac <no-reply@databricks.com> * fix: don't retry a missing task entry once the task has ended releasePlan drops a plan's stream when the task ends, while a Tokio worker may still wait in Spark's acquireMemory for the memory that stream holds. When the release empties the task's balance, the worker loses the task's entry. Before apache#6310 it failed at once; with the retry it registered the ended task again and, below its minimum share, waited for other tasks to release memory while holding the TaskMemoryManager monitor that the executor's cleanUpAllAllocatedMemory needs. MissingTaskEntryRetry now refuses without retrying when the task has completed or been killed: CometTaskMemoryManager returns a zero grant and the shuffle allocator throws SparkOutOfMemoryError. Both callers pass the TaskContext they were created in, since native threads have none of their own. Co-authored-by: Isaac <no-reply@databricks.com> --------- Co-authored-by: Isaac <no-reply@databricks.com>
Which issue does this PR close?
Closes #6224.
Rationale for this change
Spark's
ExecutionMemoryPool.acquireMemoryregisters the task'smemoryForTaskentry once, before its wait loop, and reads it withmemoryForTask(taskAttemptId)on every pass.releaseMemoryremoves the entry when the task's balance reaches zero and wakes the waiters. A caller that wakes after the removal throwsNoSuchElementException: key not found: <taskAttemptId>. The code is the same in Spark 3.4 through 4.1 and on master.TaskMemoryManager.acquireExecutionMemoryholds the task memory manager's monitor while it is parked, butreleaseExecutionMemorytakes no monitor. So a release can empty the task's balance while an acquire of the same task is parked: undergreedy_unified, one native thread's release fails another native thread's parked acquire; underfair_unified, a JVM consumer of the task freeing its last bytes withfreeMemorydoes the same. The exception reaches the native side as a plain error rather thanResourcesExhausted, so the operator cannot spill and the task fails.What changes are included in this PR?
CometTaskMemoryManager.acquireMemorycalls Spark through a helper that catches this one exception, identified by its message (key not found:plus this task's attempt id) and a frame inExecutionMemoryPool, and callsacquireExecutionMemoryagain. The retry re-registers the entry, re-checks the task's share and parks again if it has to, so the caller ends up with what Spark would have granted had the entry stayed. After three attempts it returns 0, which the native side treats as a refusal and spills on. Any other exception is rethrown. A retry logs at info level, and giving up logs a warning.The retry is safe because Spark only removes the entry at a zero balance, which cannot hold while the failed call has a partial grant. So the failed call has nothing to reconcile, and
usedand Spark's counters have not moved. The helper holds no lock of its own across attempts.Retrying from the acquire side reaches the same end state as fixing it in Spark (SPARK-59444, apache/spark#58747, which re-registers a waiting task in
ExecutionMemoryPool), without coordinating the release side, which does not know whether an acquire is parked. The Spark fix would also cover Spark's own operators in the same task, such as its sorters and aggregates, which this does not.CometUnifiedShuffleMemoryAllocatorhas its own retry from #6403.The contributor guide's memory management page describes the new failure and why the retry is safe.
How are these changes tested?
Four tests in
CometTaskMemoryManagerSuiteon a 100-byte off-heapUnifiedMemoryManager:CometTaskMemoryManager; the task holds 10 bytes and another task holds 90; an acquire of 20 parks below the minimum share; releasing the 10 bytes empties the balance. On main the parked acquire throwskey not found: 0. With this change it completes with 20 once the other task frees its memory, andgetUsedand the task's consumption both read 20.MemoryConsumerof the task and released withfreeMemory.NoSuchElementExceptionwith another task's id, or without anExecutionMemoryPoolframe, is rethrown unchanged after one call.CometTaskMemoryManagerSuite,CometUnboundedShuffleMemoryAllocatorSuiteandCometExecIteratorLifecycleSuitepass on Spark 3.4, 3.5, 4.0 and 4.1, the spill tests inCometExecSuitepass on 3.5, and the native memory pool tests pass. With the retry disabled, the two race tests and the cap test fail again.