Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[SPARK-59461][CORE] Reclaim optional execution memory before active query allocations - #58763

Open
sunchao wants to merge 2 commits into
apache:masterfrom
sunchao:dev/chao/codex/optional-execution-memory-policy
Open

[SPARK-59461][CORE] Reclaim optional execution memory before active query allocations#58763
sunchao wants to merge 2 commits into
apache:masterfrom
sunchao:dev/chao/codex/optional-execution-memory-policy

Conversation

@sunchao

@sunchao sunchao commented Sep 12, 2026

Copy link
Copy Markdown
Member

Why are the changes needed?

JIRA: SPARK-59461.

Speculative work, such as downloading the next selected Parquet row group while decoding the current one, can hide I/O latency. Its unused buffers should not prevent a query from obtaining memory for work it needs to complete.

For example, consider a task with 400 bytes of disposable read-ahead in a 1,000-byte execution pool. Its next ordinary allocation needs 700 bytes. Admitting the read-ahead only when memory was free is insufficient: unless those 400 bytes can be returned, the later allocation sees just 600 bytes. Other tasks' optional reservations can also reduce the requester's available capacity or fair share.

TaskMemoryManager can ask consumers in its own task to spill, while UnifiedMemoryManager can evict borrowed storage memory. Neither provides an executor-wide release mechanism for disposable execution buffers owned by other tasks. Unmanaged-memory reporting reduces effective capacity, but does not provide an ownership or reclamation protocol.

This is an internal memory-management enhancement, not a fix for an existing built-in reader's correctness bug. SPARK-56918 is related work on shrinking executor-wide external storage caches; this change instead retains task attribution and execution-pool accounting.

What changes were proposed in this pull request?

Add an internal optional-memory policy to the existing execution pool. An optional request receives all its bytes from currently free execution memory or receives zero, without borrowing storage, evicting blocks, spilling, or waiting for capacity. Successful reservations use existing task accounting and release methods.

Ordinary allocations retain optional buffers when the full request fits immediately under Spark's existing capacity and fair-share rules. Otherwise they first ask registered owners to release unused optional work, then retry the existing allocator. A shared admission gate prevents fresh optional reservations during reclamation. Callbacks run outside the memory-manager and registration monitors and must release only their own unused resources; they must not allocate memory, wait for I/O, or acquire a task-manager monitor.

Some MemoryStore operations already hold the memory-manager monitor across nested allocation or eviction. Those operations establish the reclamation boundary before taking that monitor, so callbacks cannot create a lock-order inversion. Their outer boundaries conservatively drain optional owners. Focused cleanup changes preserve block contents/metadata consistency, unroll credits, and consumed-buffer ownership if a callback fails.

The admission preflights respect upstream unmanaged-memory accounting. A successful preflight and immediate grant use the same unmanaged-memory sample; allocation paths that can wait retain normal fresh sampling.

Public task/consumer APIs, task-completion integration, and reader-specific prefetch scheduling are intentionally separate follow-ups. This PR does not enable read-ahead or change file selection, page skipping, or the general spilling/fairness policy.

Does this PR introduce any user-facing change?

No new public API, SQL behavior, configuration, or reader feature. There is no production optional-memory consumer in this change. Existing allocation and storage paths gain internal coordination for future consumers; that bookkeeping is not claimed to be free or to improve existing workloads by itself.

How was this patch tested?

Built Spark core and its test sources against upstream master 5d388249c3cf28f5ba11cdd5275d1fe5ae9cc1c1, then ran the following on JDK 17 / Apple Silicon:

SPARK_LOCAL_IP=127.0.0.1 SERIAL_SBT_TESTS=1 build/sbt -batch \
  'core/testOnly org.apache.spark.memory.UnifiedMemoryManagerSuite org.apache.spark.memory.TestMemoryManagerSuite org.apache.spark.storage.MemoryStoreSuite org.apache.spark.util.io.ChunkedByteBufferOutputStreamSuite' \
  'core/testOnly org.apache.spark.storage.BlockManagerSuite -- -z "optional reclaimer failure" -z "cache unroll preserves resource ownership"' \
  'core/Compile/scalastyle' 'core/Test/scalastyle'

At 9d965e4be4be060c7c99bd68569cc44d7616d7cd: 73 tests passed, zero failures. One existing unmanaged-memory test is canceled on Apple Silicon. Both production and test scalastyle checks passed with zero errors and warnings. Source hashes were unchanged after validation. Full upstream CI has not run.

An isolated negative control removed the shared unmanaged-memory sample from a copy of the current implementation. It compiled, and the polling-race regression failed at the expected storage-admission assertion. The tested source checkout was not modified by this control.

Coverage includes full/denied optional admission; no-owner and ample-capacity paths; same-task and cross-task pressure; on/off-heap isolation; callback lock ordering and failures; real MemoryStore/BlockManager cleanup; consumed-buffer disposal; and upstream unmanaged-memory limits and polling races.

No end-to-end reader performance improvement or current no-owner allocator-overhead result is claimed by this PR. Those measurements are separate from correctness validation and require an adopting consumer where applicable.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Codex

@sunchao

sunchao commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed write-up. I went through the memory-manager and MemoryStore changes carefully. Accounting atomicity and the marker-before-monitor ordering hold on every current code path, but I found one behavior change that is reachable today without any reclaimer, several places where the reclamation policy defeats its own goal, and some structural concerns. Details inline.

Two nits outside the inline comments: the new members in MemoryManager.scala sit between the constructor require and the first // -- Methods related to ... section header rather than inside a section, and the Generated-by: line should include the tool version per the PR template.

// A normal denial must retain them for the returned partial iterator.
Utils.tryWithSafeFinally { throw error } {
releaseUnrollMemoryForThisTask(memoryMode, unrollMemoryUsedByThisBlock)
freeUnrolledValues(valuesHolder)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes behavior even with no optional reclaimer registered. freeUnrolledValues (and freeMemoryEntry(entry) in the transfer catch below) close every already-unrolled AutoCloseable value, but MemoryStore only takes ownership of values on a successful put (PartiallyUnrolledIterator.close never closes values; freeMemoryEntry is only called on entries already in entries).

This throw path is reachable today: reserveUnrollMemoryForThisTask -> acquireStorageMemory -> evictBlocksToFreeSpace -> dropFromMemory -> diskStore.put throwing an IOException. For example, TorrentBroadcast.writeBlocks does putSingle(broadcastId, value, MEMORY_AND_DISK) with the user's live object, so sc.broadcast(autoCloseable) failing on a disk error now hands the caller an exception and a closed object. The catch is also on Throwable, so an InterruptedException closes values as well.

Could we limit the close to the reclaimer-failure case (or drop it, matching the existing ownership semantics) rather than closing on every throw?

}
}
}
if (failure != null) throw failure

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A NonFatal failure from another task's reclaimer propagates out of acquireExecutionMemory / acquireStorageMemory / reserveUnrollMemoryForThisTask of the requesting task, and none of those paths fall back to ordinary admission (spill / evict / partial grant). So a bug in one optional owner fails unrelated tasks that would have succeeded before this PR; in putIterator it additionally closes the unrolled values.

I see this is intentional and tested, but a failed reclaimer never "invents" credit: its bytes stay charged, so ordinary admission just sees less free memory. Could this be treated like a failed spill() of another consumer, i.e. log and continue into ordinary admission, instead of failing the requester?

if ((onHeapOptionalReclaimers != 0 || offHeapOptionalReclaimers != 0) &&
optionalAdmissionGate.getReadHoldCount == 1) {
try {
reclaimOptionalMemory(None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike acquireExecutionMemory / acquireStorageMemory, which preflight and only reclaim on a miss, this drains every optional owner of both memory modes at any outermost MemoryStore entry with no capacity check:

  • reserveUnrollMemoryForThisTask drains on the initial reservation and on every growth step of every cache put, even when storage is nearly empty (the nested acquireStorageMemory takes the enteredWithMonitor short-circuit and skips canAcquireStorageMemory).
  • The unroll -> storage transfer drains although it can never need new memory (it releases >= entry.size and re-acquires entry.size under one monitor hold).
  • remove / clear drain although they free memory, so BlockManager.removeRdd / removeBroadcast (ContextCleaner traffic) drain all owners once per block.
  • An ON_HEAP put drains OFF_HEAP owners.

With any registered owner, optional memory becomes unusable on an executor that ever caches or uncaches a block. Could the unroll path use the same preflight-under-monitor-then-drain pattern as acquireStorageMemory, pass Some(memoryMode), and have release-only paths take the marker without draining? The read gate alone already makes tryAcquireExecutionMemory decline while they run.

availableMemory: Long): Boolean = lock.synchronized {
val tasks = memoryForTask.size + (if (memoryForTask.contains(taskAttemptId)) 0 else 1)
val current = memoryForTask.getOrElse(taskAttemptId, 0L)
numBytes <= availableMemory && numBytes <= math.max(0L, maxPoolSize / tasks - current)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This preflight requires the full request to fit within the caller's fair share, so a request that merely exceeds the share (with plenty of free memory) fails the preflight and drains every optional owner of that mode. But draining cannot raise maxPoolSize / numActiveTasks: computeMaxExecutionPoolSize depends only on storage usage and unmanaged memory, and the task count only shrinks when an owner's entry drops to zero. Meanwhile ordinary acquireMemory would have granted the partial maxToGrant immediately without waiting.

Example: pool 1000, tasks A and B; A holds 400, B holds 100 ordinary + 100 optional. A requests 200: share cap = 500 - 400 = 100 < 200, preflight fails, B's optional 100 is reclaimed, retry grants A 100 -- exactly what it would have gotten without the drain. A task already at its cap drains all owners on every request and still gets 0. Since TaskMemoryManager routinely issues page-sized requests and accepts got < required, this looks like the common case.

Could the preflight distinguish "capacity short" (drain helps) from "share-bound" (drain cannot help)?

memoryMode: MemoryMode): Long = synchronized {
memoryMode: MemoryMode): Long = {
val gate = optionalAdmissionGate.readLock()
gate.lock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read gate is held across the whole call, including ExecutionMemoryPool.acquireMemory's lock.wait(), which releases the monitor but not the RRWL read hold. Since tryAcquireExecutionMemory uses writeLock().tryLock(), optional admission is denied executor-wide for as long as any task is parked in the 1/2N fairness wait (potentially minutes), even when hundreds of MB of execution memory are free. The no-owner fast path below also holds the read lock during ordinary grants, so a steady allocation stream causes tryLock misses too.

The scaladoc mentions "eviction or a capacity wait", but the PR description doesn't call out the capacity-wait starvation and there's no test with a task in lock.wait() alongside an optional request. Is this intended? If so, could it be documented and tested; if not, could the marker be released around lock.wait()?

try {
reclaimOptionalMemory(None)
} catch {
case NonFatal(error) if releaseOnly =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require(!Thread.holdsLock(this), ...) in reclaimOptionalMemory throws IllegalArgumentException, which is NonFatal, so on the releaseOnly path (MemoryStore.remove / clear) a marker-before-monitor violation is caught here, logged as a transient "Failed to reclaim optional memory" warning, and cleanup proceeds without draining. No current caller violates it (all monitor-holding callers reach remove via evictBlocksToFreeSpace with the gate already held), but StorageMemoryPool.acquireMemory / freeSpaceToShrinkPool rely purely on convention, and a future memoryManager.synchronized { memoryStore.remove(...) } would either hit this swallowed assertion or, if a writer already won tryLock and is blocked on synchronized, deadlock on readLock().lock().

Could the lock-order check be moved outside the releaseOnly catch (or throw an AssertionError) so it always propagates, keeping the catch for callback failures only?

protected final def reclaimOptionalMemory(memoryMode: Option[MemoryMode]): Unit = {
require(!Thread.holdsLock(this), "optional callbacks cannot run under the memory manager")
val callbacks = optionalReclaimers.synchronized {
optionalReclaimers.iterator.collect {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every preflight miss snapshots the registry and runs every callback (each taking its owner lock), and because optional bytes are released through the undifferentiated releaseExecutionMemory path there's no "outstanding optional bytes" state, so already-drained owners are re-invoked as no-ops on every later miss. Under memory pressure a miss is the normal case: TaskMemoryManager.trySpillAndAcquire calls back into acquireExecutionMemory after each consumer spill, so each spill iteration pays monitor + registry lock + O(R) list + R callbacks + monitor with nothing left to reclaim. Between iterations the gate is released, so another task's owner can re-admit via tryLock, start prefetch I/O, and be cancelled by the next drain.

A per-mode "optional bytes admitted since last drain" flag (set in tryAcquireMemory, cleared after a completed drain) would let the preflight/drain be skipped when nothing is reclaimable.

taskAttemptId: Long,
memoryMode: MemoryMode): Long = synchronized {
memoryMode: MemoryMode): Long = {
val gate = optionalAdmissionGate.readLock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The read gate is taken before the hasOptionalMemoryReclaimers check (same in acquireStorageMemory and withMemoryReclamation), so every TaskMemoryManager page allocation and every storage/unroll/remove/evict op now pays a ReentrantReadWriteLock shared acquire/release (CAS on one global state word plus hold-count bookkeeping) on top of the existing monitor -- in the no-reclaimer case, which is 100% of production since this PR ships no consumer. All task threads contend on that one word.

I realize checking the counter first isn't trivially safe (a waiter that skipped the gate could be starved by a later-registered owner). Do we have a no-owner allocator overhead measurement for the hot path? The description says the overhead "is not claimed to be free" but doesn't quantify it.

if (isStorageMemoryRequestTooLarge(numBytes, memoryMode)) {
return synchronized { acquireStorageMemoryInternal(blockId, numBytes, memoryMode) }
}
val gate = optionalAdmissionGate.readLock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acquireExecutionMemory and acquireStorageMemory repeat the same ~30-line skeleton (read lock, no-owner fast path, Thread.holdsLock(this), synchronized { nested-or-no-owner return; unmanaged snapshot; preflight return }, reclaimOptionalMemory(Some(mode)), synchronized { internal }, finally unlock), and withMemoryReclamation is a third variant. The pool-selection 4-tuple match now appears three times (and the 3-tuple twice), and the computeMaxExecutionPoolSize formula is retyped in tryAcquireExecutionMemory and canAcquireExecutionMemory. Also, the enteredWithMonitor branch in acquireExecutionMemory looks dead: its only production caller, TaskMemoryManager, never holds the manager monitor.

Since the comments call this choreography correctness-critical, could it be factored into one private helper (plus a poolsFor(memoryMode) helper and a hoisted maxExecutionPoolSize) so a fix to e.g. the "first registration can race this check" window lands in one place? Relatedly, freeUnrolledValues' serialized branch duplicates PartiallySerializedBlock.discard.

* can hold their own owner locks while reclaiming each other. Optional admission and release
* may use that lock: neither invokes reclamation nor acquires a TaskMemoryManager monitor.
*/
private[memory] final def registerOptionalMemoryReclaimer(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stepping back: this adds a second cross-task reclamation mechanism (a Runnable registry with its own lock, a prose lock-order contract, and its own failure semantics) alongside the existing MemoryConsumer.spill / TaskMemoryManager mechanism, and it lands ~700 lines (gate, preflights, five MemoryStore wrappers, ~90 lines of failure cleanup, ChunkedByteBufferOutputStream.dispose) with no production caller of registerOptionalMemoryReclaimer or tryAcquireExecutionMemory.

The two mechanisms are invisible to each other: optional reservations don't show up in TaskMemoryManager.showMemoryUsage / getMemoryConsumptionBreakdown, the spill-priority ordering can't see that a cheaper cross-task drain exists, and deadlock-freedom rests on every future callback obeying "Never hold a lock needed by a reclaimer while requesting ordinary memory", which a read-ahead holding a buffer-pool lock while decoding will naturally violate.

Have you considered modelling the optional owner as a MemoryConsumer subtype (discardable flag, zero-cost spill) registered executor-wide by TaskMemoryManager, and plumbing the reclaim hook into ExecutionMemoryPool.acquireMemory's existing loop (next to maybeGrowPool / computeMaxPoolSize)? That would remove the preflight duplication and the RW gate, and keep MemoryStore untouched. Either way, I'd prefer to review this API surface together with its first consumer rather than freezing it beforehand.

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The motivation makes sense: admitting optional work only from currently free memory does not ensure that ordinary work can reclaim that capacity later. The accounting and unmanaged-memory snapshot handling are thoughtful.

After tracing the interactions with TaskMemoryManager, StorageMemoryPool, MemoryStore, and BlockManager, I agree that the ownership and reclamation-policy concerns raised above remain unresolved in the current revision.

The most immediate issue is that failed cache puts now close caller-owned values even without any optional reclaimer registered. For the proposed optional-memory policy, callback failures can also fail unrelated tasks, and several reclamation paths discard optional work when doing so cannot improve the requesting operation.

Before merging, I would like to see the ownership regression fixed, failure isolation clarified, and reclamation restricted to situations where it can help. Since this introduces coordination into existing allocation paths without a production consumer, a no-owner allocation-overhead measurement and a concrete first-consumer lifecycle would also help establish that the complexity is justified.

Two qualifications to the earlier discussion: optional reservations do appear in the existing memory diagnostics as unattributed memory, and existing MemoryConsumer.spill() failures are not universally ignored. The concerns here are missing owner-level attribution and the new cross-task failure dependency.

// A normal denial must retain them for the returned partial iterator.
Utils.tryWithSafeFinally { throw error } {
releaseUnrollMemoryForThisTask(memoryMode, unrollMemoryUsedByThisBlock)
freeUnrolledValues(valuesHolder)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree this changes ownership semantics on an existing failure path. Consuming a value from the iterator does not transfer ownership of that value to MemoryStore before the put succeeds.

For example, TorrentBroadcast.writeBlocks passes the caller's object through putSingle. If a later unroll reservation triggers eviction and the disk write throws, this catch now closes the original object even when no optional reclaimer exists. The transfer catch has the same ownership concern.

Could we retain cleanup of this operation's unroll credits and internally allocated serialized buffers, while leaving uncommitted deserialized values open? Restricting the close to reclaimer failures would not itself establish ownership either. The regression test should inject an eviction failure without a reclaimer and verify that the caller's object remains open.

}
}
}
if (failure != null) throw failure

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing here prevents the requesting task from reaching ordinary admission, even if the other callbacks have already released enough memory. It also means a registered owner holding zero bytes can still fail unrelated tasks when its callback throws.

For a non-fatal callback failure that leaves accounting valid, could we retain the outstanding charge, log the failing owner/task/mode, and continue into ordinary admission? The allocator can then determine whether the request can proceed with the capacity actually available. Fatal failures or broken accounting invariants should remain separate cases.

This differs from existing task-local spill failure handling primarily in its failure scope: a problem in optional work owned by task A now becomes a failure of required work in task B.

if ((onHeapOptionalReclaimers != 0 || offHeapOptionalReclaimers != 0) &&
optionalAdmissionGate.getReadHoldCount == 1) {
try {
reclaimOptionalMemory(None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we separate holding the admission gate from actually reclaiming optional memory?

This unconditional, both-mode drain is reached by unroll reservations with ample free capacity, the unroll-to-storage accounting transfer, and release-only operations such as remove and clear. An on-heap cache operation consequently discards off-heap optional buffers too.

The nested storage preflight cannot prevent this: reserveUnrollMemoryForThisTask has already drained before entering the manager monitor, and its nested admission takes the enteredWithMonitor branch.

Release-only operations should not need reclamation. For allocations, could the outer boundary preflight under the monitor, reclaim only the relevant mode outside it when necessary, and then recheck? Please cover this through the actual MemoryStore paths; the direct acquireStorageMemory tests do not exercise this behavior.

availableMemory: Long): Boolean = lock.synchronized {
val tasks = memoryForTask.size + (if (memoryForTask.contains(taskAttemptId)) 0 else 1)
val current = memoryForTask.getOrElse(taskAttemptId, 0L)
numBytes <= availableMemory && numBytes <= math.max(0L, maxPoolSize / tasks - current)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed full-request check does not necessarily mean reclamation can improve the grant.

With a 1000-byte execution pool, suppose A holds 400 ordinary bytes and B holds 100 ordinary plus 100 optional bytes. If A requests 200, its remaining share is 100. Reclaiming B's optional bytes leaves B active, so A still receives exactly 100—the same immediate partial grant available before reclamation.

Could the preflight distinguish capacity pressure from a share-bound request where draining cannot help? It should still account for cases where reclaiming removes an optional-only task or releases the requester's own optional bytes. A regression test should verify that the example above preserves B's optional reservation.

memoryMode: MemoryMode): Long = synchronized {
memoryMode: MemoryMode): Long = {
val gate = optionalAdmissionGate.readLock()
gate.lock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This read hold survives ExecutionMemoryPool.acquireMemory calling lock.wait(): the wait releases the manager monitor, but not the admission gate. Consequently, a fairness waiter disables optional admission executor-wide, including admission in the other memory mode.

Is that deliberately part of the policy? If so, please document the cross-mode effect and add a test that places an allocator in the actual capacity-wait loop before attempting optional admission. The existing marked-operation and stalled-eviction tests do not establish that behavior.

If the gate is instead released around the wait, the wake-up path would need to re-establish the reclamation boundary before granting memory.

try {
reclaimOptionalMemory(None)
} catch {
case NonFatal(error) if releaseOnly =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The require(!Thread.holdsLock(this)) failure is also NonFatal, so releaseOnly can swallow a lock-order violation as though it were a callback failure.

Could we validate marker-before-monitor ordering before attempting to acquire the gate, allowing correctly marked nested calls, and keep that validation outside this catch? Checking only inside reclaimOptionalMemory is too late if the caller already holds the manager monitor while an optional admission holds the write gate and is waiting for that monitor.

I have not found a current production caller violating the ordering; this is about making the correctness-critical contract fail reliably rather than depending on convention.

@viirya

viirya commented Sep 13, 2026

Copy link
Copy Markdown
Member

One policy question beyond the reclamation mechanics: “optional” establishes that a buffer can be discarded without breaking correctness, but it does not establish that discarding it is cheap or preferable to slowing another task.

For example, A may have already paid the I/O cost for a prefetched buffer that it will consume shortly. Reclaiming it for B can increase A's latency and duplicate I/O. If A belongs to a more latency-sensitive or higher-priority workload, the tradeoff may be undesirable even though both tasks remain correct.

This appears to introduce an executor-wide policy where ordinary allocations take precedence over other tasks' optional reservations, without considering workload priority or reclamation cost. Is that explicitly the intended contract? How should a consumer decide whether its buffers are suitable for such revocable reservations?

It would help to evaluate this with the first consumer, measuring both the requesting task's benefit and the owner's cost, including repeated reclamation and tail latency. That would also clarify whether cross-task reclamation should apply universally or only to a more narrowly defined class of disposable work.

@peter-toth peter-toth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, @sunchao!

The motivation holds up. Admitting a buffer only from currently-free execution memory says nothing about whether a later ordinary request can get that capacity back. Neither TaskMemoryManager spilling nor storage eviction reaches another task's disposable execution buffers, so there is a real gap here. I traced the gate/monitor ordering, the preflights and the MemoryStore cleanup paths from the code before reading the existing threads, and landed on the same concerns already filed. I am not restating those. What I have below is one lifecycle problem nobody has raised, plus smaller items.

Independently reached and already filed, so not restating: unrolled-value ownership (also), cross-task failure propagation (also), the unconditional both-mode drain (also), the share-bound preflight (also), the gate held across lock.wait() (also), the lock-order check being unreachable in the deadlock ordering (also), per-retry drain amplification, the no-owner hot-path cost, the duplicated admission skeleton, reviewing this API surface with its first consumer.

The suites pass on this head: UnifiedMemoryManagerSuite, MemoryStoreSuite, ChunkedByteBufferOutputStreamSuite (63 tests, 1 cancelled on Apple Silicon) and BlockManagerSuite + TaskMemoryManagerSuite (140 tests).

Blocking

  • 1. Optional reservations trip the managed-memory-leak check: an optional reservation is charged to the task in the execution pool but owned by no MemoryConsumer, so a reservation outliving the task body reports Managed memory leak detected and throws under spark.unsafe.exceptionOnMemoryLeak, which every Spark test sets. That makes task-completion integration a prerequisite, not a follow-up. inline

Non-blocking

  • 2. MemoryStore's class comment now prescribes the deadlock: lines 91-92 still say every allocation change must be synchronized on memoryManager, and I reproduced the hang that recipe gives on this head. inline
  • 3. enteredWithMonitor is untested on the execution path: no in-tree caller reaches acquireExecutionMemory with this monitor held, and no test covers the branch. inline

Minor

  • 4. Lost diagnostic: the new early return skips the existing "Will not store ... exceeds our memory limit" log for an impossible unroll request. inline
  • 5. ValuesHolder is not sealed: the new match would MatchError inside the cleanup of an already-failing unroll. inline
  • 6. dispose() reuses toChunkedByteBufferWasCalled: a later toChunkedByteBuffer fails with "can only be called once" although it never was. inline

*
* @return `numBytes` on success, or zero with no reservation on denial
*/
private[memory] def tryAcquireExecutionMemory(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 1. An optional reservation is charged to the task in ExecutionMemoryPool.memoryForTask but is owned by no MemoryConsumer, so TaskMemoryManager cannot see it. Two consequences, both reachable by the first consumer:

  • cleanUpAllAllocatedMemory() returns the leftover bytes, so Executor reports Managed memory leak detected (core/src/main/scala/org/apache/spark/executor/Executor.scala:917-925) for any reservation still held when the task body returns. spark.unsafe.exceptionOnMemoryLeak turns that warning into a thrown SparkException. It defaults to false, but every Spark test run sets it to true (project/SparkBuild.scala:2038, pom.xml:2903). So a consumer whose prefetch outlives the task body only warns in production and fails outright in CI.
  • snapshotMemoryUsage() computes memoryNotAccountedFor as the task's pool charge minus the sum over consumers (core/src/main/java/org/apache/spark/memory/TaskMemoryManager.java:490). Optional bytes therefore land in the unattributed bucket of the UNABLE_TO_ACQUIRE_MEMORY breakdown, which is the diagnostic someone reads when a task OOMs next to a prefetcher.

The description defers task-completion integration to a follow-up. The contract as written makes it a prerequisite instead. Nothing ties the registration or the reservation to task completion, so a task that dies before its finally leaves a live registration. Its counter then keeps every later allocation on the preflight-and-drain path for the life of the executor, invoking a callback into a dead reader.

Two ways out. Add the completion-listener integration here, releasing the outstanding bytes and unregistering. Or model the owner as a MemoryConsumer, as suggested at r3998074909, which gets the lifecycle, the breakdown and showMemoryUsage for free. One caveat on that second option: ExecutionMemoryPool.acquireMemory is lock.synchronized, so hooking the reclaim into its loop would run callbacks under the manager monitor. That is the thing this design spends the RW gate to avoid.

/**
* Remove a block and release its storage charge; optional admission skips object close callbacks.
*/
def remove(blockId: BlockId): Boolean = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 2. The class comment at MemoryStore.scala:91-92 still reads "all changes to memory allocations, notably putting blocks, evicting blocks, and acquiring or releasing unroll memory, must be synchronized on memoryManager". After this PR that recipe hangs.

I ran it on this head. One reclaimer registered, thread A in mm.synchronized { mm.acquireStorageMemory(block, 100, ON_HEAP) }, thread B in mm.tryAcquireExecutionMemory(100, 2, ON_HEAP) once A holds the monitor. Result: threadA=WAITING threadB=BLOCKED, neither returns in 15 seconds. That is the deadlock described at r3998074901.

The new rule is the opposite of the comment: gate first, monitor second. It is stated only in the scaladoc of withMemoryReclamation, which a MemoryStore contributor is less likely to read than the comment at the top of the class they are editing.

Please rewrite lines 91-92 to state the ordering and name the methods that establish it. The runtime check asked for on that thread catches the accident. This catches the person writing the next MemoryStore method.

acquireExecutionMemoryInternal(numBytes, taskAttemptId, memoryMode)
}
}
val enteredWithMonitor = Thread.holdsLock(this)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 3. No in-tree caller reaches acquireExecutionMemory with this monitor held. The only production caller is TaskMemoryManager.acquireExecutionMemory, which holds its own monitor, not this one, and a reclaimer callback is forbidden from allocating. The storage twin at line 335 is covered by "outer storage marker drains before inherited monitor and excludes new optional admission", but nothing covers this one.

The branch is load-bearing where it does fire: without it, a nested call whose full request exceeds the caller's fair share falls through to reclaimOptionalMemory, whose require(!Thread.holdsLock(this)) then fails. So the test needs a registered owner that the drain does not fully release, plus a second task holding ordinary bytes so the task count stays put, and then an execution request under the monitor that must come back as a partial grant rather than an exception.

If there is no caller and none planned, dropping the branch and asserting !Thread.holdsLock(this) here is smaller than keeping an untested one.

val unrollMemoryMap = memoryMode match {
case MemoryMode.ON_HEAP => onHeapUnrollMemoryMap
case MemoryMode.OFF_HEAP => offHeapUnrollMemoryMap
if (memoryManager.isStorageMemoryRequestTooLarge(memory, memoryMode)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 4. This early return drops the diagnostic that used to fire for an impossible unroll request. Before, the call reached acquireStorageMemoryInternal and logged "Will not store <block> as the required space (N bytes) exceeds our memory limit (M bytes)" on its way to false (UnifiedMemoryManager.scala:401). Same result now, one fewer line explaining why nothing got cached. A logInfo here, or reusing that message, keeps it.

}

/** Dispose only values consumed by this unroll operation. */
private def freeUnrolledValues(valuesHolder: ValuesHolder[_]): Unit = valuesHolder match {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 5. This match is not exhaustive and ValuesHolder is not sealed (MemoryStore.scala:760). A third implementation would fail with a MatchError at the worst moment: inside the cleanup of an already-failing unroll, replacing the original error with a confusing one. The trait and both implementations live in this file, so private sealed trait ValuesHolder[T] turns that into a compile error. MemoryEntry next door is already declared that way (MemoryStore.scala:44), and freeMemoryEntry relies on it.

def size: Long = _size

/** Release untransferred chunks without allocating a compact final chunk. */
def dispose(): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 6. dispose() reuses toChunkedByteBufferWasCalled to mean "the chunks are gone", so a later toChunkedByteBuffer fails on require(!toChunkedByteBufferWasCalled, "toChunkedByteBuffer() can only be called once") with a message saying the opposite of what happened. The new test asserts the IllegalArgumentException without checking its text, so the wrong message is locked in. A separate disposed flag with its own require(!disposed, "cannot call toChunkedByteBuffer() after dispose()") keeps both messages honest for the price of one field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants