Repository navigation
Conversation
|
cc @viirya @cloud-fan @dongjoon-hyun @peter-toth could you take a look? thanks! |
viirya
left a comment
There was a problem hiding this comment.
The fix looks reasonable for the concurrent acquire/release interleaving covered by the tests. Retaining the task entry while an acquisition is waiting, and removing an empty entry when the last waiter exits, appears consistent with the existing accounting and fairness rules.
My main question is about the practical use case. In the usual single-threaded task execution, once a consumer blocks acquiring memory, that thread cannot proceed to release memory through another consumer. The new test explicitly introduces another thread to produce this interleaving.
Could you point to an existing Spark execution path or a real-world integration that performs these concurrent memory operations for the same task? The API-level reproducer demonstrates the failure, but a concrete use case would help establish whether this fixes a supported usage pattern and justify the additional bookkeeping. If this is intended to support a new concurrency pattern, it would also be useful to clarify its scope and how callers coordinate task cleanup with outstanding acquisitions.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Thank you for the PR, @sunchao. The bug is real. To answer @viirya's question about a concrete path: the Python UDF writer thread. PythonRunner's writer thread sets the same TaskContext (TaskContext.setTaskContext(context) in PythonRunner.scala) and drives the upstream iterator, which can spill and free execution memory, while the main task thread runs the downstream operators after the UDF and requests memory. Both share one TaskMemoryManager, and TaskMemoryManager.releaseExecutionMemory does not take the TMM monitor, so the release/wait interleaving described here can happen without any external engine.
That said, I think the fix is broader than what is reachable through the public API. See the inline comments.
viirya
left a comment
There was a problem hiding this comment.
Thanks, @dongjoon-hyun. The pipelined Python UDF writer path helps explain why concurrent acquire/release operations within one task are relevant to Spark itself, beyond the API-level reproducer.
On the fix scope, I think we should distinguish preventing the missing-entry exception from changing which tasks participate in memory arbitration. Removing an entry when its allocation reaches zero is already the current policy, even if the task is still running and may request execution memory again shortly afterward. The transient reduction in the task count with the re-registration approach therefore follows the existing policy; it is not entirely new behavior introduced by that fix.
Retaining the entry while an acquisition is waiting also seems reasonable: in that case, we know there is an outstanding demand. But that is a more specific lifecycle guarantee, and the additional cleanup on interruption or callback failure should be considered separately from fixing the exception.
Another possible policy would be to retain entries until task completion, since zero allocation does not imply that a task has finished using execution memory. That would have a broader trade-off, though: tasks with no current demand would continue to limit other tasks' allocations. I would not expand this PR into that policy change.
For this PR, I lean toward the smaller re-registration fix unless there is a concrete reason that a waiting task must remain continuously counted. If we retain the waiter-based approach, could we document that requirement and keep the bookkeeping and lifecycle changes limited to what is needed for it?
There was a problem hiding this comment.
Thanks for the PR, @sunchao!
acquireMemory reads memoryForTask(taskAttemptId) with apply after every lock.wait(). A concurrent release of the task's last byte deregisters it, so the waiter throws NoSuchElementException on wake-up. Retaining the entry while an acquisition is waiting is the right shape for that. I reran your negative control and it reproduces exactly: 16 of the 18 new cases fail against 80479fa48a2, and the 2 that pass are the partial-release ones.
Two points already on the thread that I reached independently, so I am not opening threads for them. The TaskMemoryManager monitor argument at r3998051507 holds - acquireExecutionMemory keeps synchronized (this) across the blocking lock.wait(). So does r3998051511: memoryMode reaches nothing but poolName in this class.
Non-blocking
- 1. Cleanup half of the waiter bookkeeping is untested: the suite pins retaining the entry in depth and never checks that the waiter count is released. I leaked it on the success path only and all 18 cases still passed. inline:
ExecutionMemoryPoolSuite.scala:152
Minor
- 2. Lifecycle note is on the wrappers, not on the method that enforces it:
releaseMemorycarries the new guard and its scaladoc is unchanged, while both delegating callers got the note. inline:ExecutionMemoryPool.scala:205
|
Thanks @viirya, @dongjoon-hyun, and @peter-toth. Applied the narrow fix in 2dcee6d and updated the title, description, and tests. The production change moves the existing registration-and-notification block to the top of the acquisition loop. It removes the waiter map and waiter-specific cleanup, and accepts the existing temporary drop in the active-task count after a last-byte release. Preventing the missing-entry crash is sufficient for the Comet acquire/release interleaving that motivated this work; continuous fairness participation is a separate policy decision. I kept The revised suite includes the public two-consumer regression in both memory modes, checks that the peer retains all 700 bytes after the completed waiter is released, and covers interruption with the original 100-byte reservation still held. Pool-only cases run once. The multiple-waiter and new zero-byte-cleanup cases are removed. The two consumers share one Validation so far: all five focused cases pass against cached Spark 4.0.1 dependencies, both public crash cases fail against the base pool, and the silent-reinsertion notification control times out. Repository ScalaStyle checks pass for both changed files. The description explains the focused harness and its limits; the full local source build was stopped during dependency resolution after repeated mirror timeouts, before compilation or tests, and CI for the new revision is pending. |
apache/spark#59103 (SPARK-59827) was closed as a duplicate of apache/spark#58747 (SPARK-59444), which fixes the same race in ExecutionMemoryPool, so link that issue and PR instead.
… the task's entry (apache#6403) * fix: retry a shuffle page allocation that Spark failed after dropping the task's entry Spark's ExecutionMemoryPool registers a task's memoryForTask entry once, before its wait loop, and reads it on every pass, while releaseMemory removes the entry when the task's balance reaches zero. When the JVM shuffle allocator's page or pointer array request was parked below the task's minimum share and Comet's native memory consumer released the task's last bytes, the request woke to NoSuchElementException "key not found: <taskAttemptId>" and failed the task, because the shuffle writers only expect SparkOutOfMemoryError. CometUnifiedShuffleMemoryAllocator now retries allocate and allocateArray when Spark throws that exception from ExecutionMemoryPool. The retry registers the task again and waits as the first attempt would have. Nothing is counted or allocated before the exception, so retrying cannot leak. After three attempts it refuses the request with the same SparkOutOfMemoryError it uses for a refused page, with the last exception as its cause. The Spark issue is SPARK-59827. * docs: say which callers the page allocation retry covers Only the shuffle allocator's callers are guarded; Spark's own operators in the task can still hit the removed entry until Spark re-registers a waiting task (apache/spark#59103). * docs: point the shuffle allocator retry at SPARK-59444 apache/spark#59103 (SPARK-59827) was closed as a duplicate of apache/spark#58747 (SPARK-59444), which fixes the same race in ExecutionMemoryPool, so link that issue and PR instead.
… the task's entry (#6403) (#6869) * fix: retry a shuffle page allocation that Spark failed after dropping the task's entry Spark's ExecutionMemoryPool registers a task's memoryForTask entry once, before its wait loop, and reads it on every pass, while releaseMemory removes the entry when the task's balance reaches zero. When the JVM shuffle allocator's page or pointer array request was parked below the task's minimum share and Comet's native memory consumer released the task's last bytes, the request woke to NoSuchElementException "key not found: <taskAttemptId>" and failed the task, because the shuffle writers only expect SparkOutOfMemoryError. CometUnifiedShuffleMemoryAllocator now retries allocate and allocateArray when Spark throws that exception from ExecutionMemoryPool. The retry registers the task again and waits as the first attempt would have. Nothing is counted or allocated before the exception, so retrying cannot leak. After three attempts it refuses the request with the same SparkOutOfMemoryError it uses for a refused page, with the last exception as its cause. The Spark issue is SPARK-59827. * docs: say which callers the page allocation retry covers Only the shuffle allocator's callers are guarded; Spark's own operators in the task can still hit the removed entry until Spark re-registers a waiting task (apache/spark#59103). * docs: point the shuffle allocator retry at SPARK-59444 apache/spark#59103 (SPARK-59827) was closed as a duplicate of apache/spark#58747 (SPARK-59444), which fixes the same race in ExecutionMemoryPool, so link that issue and PR instead. (cherry picked from commit 7e3cbc0) Co-authored-by: dustin <[email protected]>
spark-test
left a comment
There was a problem hiding this comment.
Review summary
The narrowed fix does what the PR describes. Each pass of ExecutionMemoryPool.acquireMemory now re-registers the task before it reads the task's entry. A waiting acquisition whose last bytes another consumer of the same task releases therefore re-enters normal fair-share allocation instead of throwing NoSuchElementException. Keeping notifyAll() with the insert is needed for the three-task wake-up case, because re-adding a task lowers the minimum share other waiters need. This review found no defects in the change or in the new ExecutionMemoryPoolSuite. The earlier inline requests are addressed at this revision: the waiter bookkeeping and the lifecycle-change tests are gone, an interruption case now checks that an existing reservation survives, pool-only cases run in one memory mode, and the two-consumer test checks that a completed request leaves no entry behind. One question for the author about task cleanup follows.
Findings
0 total: 0 P0, 0 P1, 0 P2, 0 P3.
No findings.
Decision challenges
Loop-head re-registration can revive a task after cleanUpAllAllocatedMemory releases it
This follows up on the inline discussion about whether task cleanup can race a waiting acquisition, and on the question of how callers coordinate cleanup with outstanding acquisitions.
Verified in the source: TaskMemoryManager.cleanUpAllAllocatedMemory holds synchronized (this) only through TaskMemoryManager.java:928. Its final memoryManager.releaseExecutionMemory(...) (931-932) and releaseAllExecutionMemoryForTask(taskAttemptId) (934) run after the monitor is released. So a thread of the same task that blocked on the monitor during cleanup, or that calls acquireExecutionMemory right after that section, can be waiting in ExecutionMemoryPool.acquireMemory below its 1/2N share when those calls remove the task's entry.
- On the base branch, that waiter throws
NoSuchElementExceptionwhen it wakes and leaves no state behind. - With this PR, the loop-head block (
ExecutionMemoryPool.scala:109-112) re-registers the finished task. A grant made then is charged to the task after cleanup has returned the freed total that the executor's leak check uses, so the check never sees it. If the waiter waits again and is interrupted, it leaves a zero-byte entry instead. Unless a later release for that task removes the entry, it lasts for the executor's lifetime: granted bytes stay unavailable, and the entry counts as one more active task in every other task's 1/N share.
This does not affect the concurrent-release case the PR fixes. Acquisitions that start after line 934 already behave this way on the base branch, so the PR widens an existing lifecycle gap rather than adding a new kind of leak.
On Spark's own threads: the pipelined Python writer is joined at task completion, and PipedRDD and the continuous-processing reader interrupt their threads, so a later lock.wait() throws. The script-transformation writers (SparkScriptTransformationExec, HiveScriptTransformationExec) and the R writer (BaseRRunner) also set the task context and pull rows through the child plan. Nothing joins or interrupts them at completion.
Open question: can a supported caller, Comet in particular, still have an acquisition waiting when cleanup reaches those release calls? And is memory granted at that point ever freed? If the schedule is unsupported, a short note saying why would settle this. If it is supported, two options:
- Have a waiter whose task was released through
releaseAllMemoryForTaskfail or return 0 instead of re-registering. An ordinary zero-byte release would keep this PR's behavior. - Move the two release calls into
cleanUpAllAllocatedMemory's synchronized block, so they serialize with same-task acquisitions.
Not verified by this review (environment unavailable). Repro sketch:
In ExecutionMemoryPoolSuite (package org.apache.spark.memory), using the conf of the existing two-consumer test: (1) Subclass UnifiedMemoryManager(conf, 1000L, 500L, 1) and override releaseExecutionMemory without synchronized: when the current thread is the cleanup thread, count down a 'paused' latch and await a 'resume' latch, then call super. Its first call from cleanUpAllAllocatedMemory is the one at TaskMemoryManager.java:931, after the monitor is released. (2) With ON_HEAP TestMemoryConsumers, the peer task (2) acquires 900 bytes and task 1's owner acquires 100. (3) Run task 1's cleanUpAllAllocatedMemory() on a thread named 'cleanup' and await 'paused'. (4) Start requester.acquireMemory(300L) on another thread and use awaitWaiting() to confirm that it waits in ExecutionMemoryPool.acquireMemory. (5) Count down 'resume', check that cleanup returns 100, then call peer.freeMemory(300L). Expected with this PR: the requester gets 300 bytes, and memory.getExecutionMemoryUsageForTask(1L) is still 300 after cleanup returned. Reference on the base branch: the requester fails with NoSuchElementException and task 1 holds 0 bytes. Control without the pause: start the requester first, then cleanup on its own thread, and wait until cleanup is BLOCKED on the TaskMemoryManager monitor. After peer.freeMemory(300L), the requester gets 300, cleanup returns 400, and task 1 holds 0 bytes on both branches.
This follows up on existing discussions 1, 2, 3.
Generated by Omnigent on Databricks
What changes were proposed in this pull request?
This PR fixes a failure in Spark's execution-memory allocator when one memory consumer in a task is waiting for memory and another consumer in the same task releases the task's last allocated bytes. Today, that release can cause the waiting request to throw
NoSuchElementExceptionwhen it resumes.The fix makes a waiting request re-establish its task's registration before retrying the allocation. Each pass through the acquisition loop first ensures that the task is present in the pool's accounting, then checks its current allocation and share of the pool. This lets the request continue through the normal allocation process even if the task's entry disappeared while it was waiting.
Registration also wakes other waiting tasks so they can reconsider their allocations using the updated task count. This matters because adding a task lowers the minimum share that another task needs to reach before it can proceed. The change keeps the existing policy of removing a task's entry when its allocation reaches zero; the pending request restores that entry when it resumes.
Why are the changes needed?
Holding no memory does not mean a task has no outstanding memory request. Spark accounts for execution memory per task, while a task can have several memory consumers sharing one
TaskMemoryManager. Under memory pressure, one consumer can block waiting for an allocation while another consumer, running on a different thread, releases memory.The motivating case is Comet's concurrent acquire/release pattern within a Spark task. Spark's pipelined Python UDF execution also illustrates why work within one task can span threads: a writer thread drives the upstream iterator while the main task thread consumes output and runs downstream operators, sharing the same task context. At the memory-manager level, acquisition holds the
TaskMemoryManagermonitor, but ordinary memory release does not require that monitor, so a release can overlap a waiting acquisition.The bug comes from assuming that registration survives the wait. Before this change,
acquireMemoryregisters the task once, before entering its retry loop. Waiting releases the pool's lock, allowing another consumer to free the task's remaining memory. That removes the task's accounting entry. When the pending request wakes up, it reads an entry that no longer exists and throwsNoSuchElementException.For example, consider a fixed 1,000-byte execution-memory pool, with all allocations in the same memory mode:
Before the fix, A fails when it resumes and looks up its removed entry. With the fix, A re-registers, waits again if necessary, and acquires the requested 300 bytes after B's release. B then holds 600 bytes, A holds 300, and 100 remain free.
This is the sequence reproduced by the regression tests through two consumers sharing one
TaskMemoryManager.Tracks SPARK-59444.
Does this PR introduce any user-facing change?
Yes. Tasks encountering this concurrent acquire/release sequence can continue allocating execution memory instead of failing with
NoSuchElementException. No API or configuration changes are required.How was this patch tested?
The new
ExecutionMemoryPoolSuitereproduces the two-consumer sequence above in both on-heap and off-heap modes. It also checks that re-registration wakes peers whose minimum share has changed, that a partial release preserves the remaining allocation, and that interrupting a request preserves memory the task already holds. After the successful acquisition is released, the tests verify that the task's empty entry no longer restricts a peer's allocation.CI for commit
2dcee6dd08fcompleted successfully on attempt 2. The core test run passed all 5ExecutionMemoryPoolSuitetests and all 25UnifiedMemoryManagerSuitetests. This workflow tests the PR changes applied to a newer upstream revision.Focused negative controls confirm that the tests detect the regression: both two-consumer cases fail against the original pool with
NoSuchElementException, and re-registration without notifying peers causes the peer-notification test to time out.Additional local validation details
The focused tests compiled the modified pool and suite against cached Spark 4.0.1 dependencies, Scala 2.13.13, ScalaTest 3.2.15, and JDK 21. A harness-only logging adapter bridges the newer MDC helper to the released logging API without changing memory behavior. All five tests passed; the partial-release and existing-reservation interruption controls also passed against the original pool.
Both changed Scala files passed the repository's ScalaStyle configuration, and
git diff --checkpassed. The full local source build was stopped during dependency resolution after repeated mirror timeouts, before compilation or tests.The regressions exercise the memory-allocator APIs. An end-to-end Comet or pipelined Python UDF query reproducer was not run.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex.
Codex assisted with implementation, tests, review, and this description.