viirya commented on code in PR #58763:
URL: https://github.com/apache/spark/pull/58763#discussion_r3998224486


##########
core/src/main/scala/org/apache/spark/storage/memory/MemoryStore.scala:
##########
@@ -211,9 +211,22 @@ private[spark] class MemoryStore(
     // Keep track of unroll memory used by this particular block / 
putIterator() operation
     var unrollMemoryUsedByThisBlock = 0L
 
+    def reserveUnrollMemory(memory: Long): Boolean = {
+      try {
+        reserveUnrollMemoryForThisTask(blockId, memory, memoryMode)
+      } catch {
+        case error: Throwable =>
+          // No entry or partial iterator can own these values when 
reclamation throws.
+          // A normal denial must retain them for the returned partial 
iterator.
+          Utils.tryWithSafeFinally { throw error } {
+            releaseUnrollMemoryForThisTask(memoryMode, 
unrollMemoryUsedByThisBlock)
+            freeUnrolledValues(valuesHolder)

Review Comment:
   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.



##########
core/src/main/scala/org/apache/spark/memory/MemoryManager.scala:
##########
@@ -44,6 +48,127 @@ private[spark] abstract class MemoryManager(
 
   require(onHeapExecutionMemory > 0, "onHeapExecutionMemory must be > 0")
 
+  // Acquire the marker before this manager's monitor. Shared ownership never 
excludes ordinary
+  // operations, including capacity waiters; optional admission only tries the 
exclusive side.
+  protected val optionalAdmissionGate = new ReentrantReadWriteLock()
+
+  // This lock protects only registrations. Callbacks never run while it or 
this manager's
+  // monitor is held, and registration does not wait behind ordinary capacity 
waiters.
+  private val optionalReclaimers = new mutable.LinkedHashMap[Runnable, (Long, 
MemoryMode)]()
+  @volatile private var onHeapOptionalReclaimers = 0
+  @volatile private var offHeapOptionalReclaimers = 0
+
+  /**
+   * Register a task-owned, release-only callback before its first optional 
admission.
+   * Returns an idempotent unregister action; callers must drain the owner 
before unregistering.
+   * Callbacks may run concurrently, repeatedly, or after unregistering and 
must release each
+   * reservation exactly once. They may take a short owner-state lock, but 
must not acquire a
+   * TaskMemoryManager monitor, allocate execution memory, or wait for I/O or 
task cleanup.
+   *
+   * Never hold a lock needed by a reclaimer while requesting ordinary memory 
or invoking another
+   * operation that may reclaim optional memory, including storage cleanup. 
Otherwise two tasks
+   * 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(
+      taskAttemptId: Long,
+      memoryMode: MemoryMode,
+      reclaimer: Runnable): Runnable = {
+    // A distinct forwarding object gives each registration identity even if 
callbacks are reused.
+    val registered = new Runnable {
+      /** Release this owner's optional bytes without allocating or destroying 
a whole reader. */
+      override def run(): Unit = reclaimer.run()
+    }
+    optionalReclaimers.synchronized {
+      optionalReclaimers(registered) = (taskAttemptId, memoryMode)
+      memoryMode match {
+        case MemoryMode.ON_HEAP => onHeapOptionalReclaimers += 1
+        case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers += 1
+      }
+    }
+    new Runnable {
+      /** Remove this registration only; an already-captured callback remains 
safe to invoke. */
+      override def run(): Unit = optionalReclaimers.synchronized {
+        if (optionalReclaimers.remove(registered).isDefined) {
+          memoryMode match {
+            case MemoryMode.ON_HEAP => onHeapOptionalReclaimers -= 1
+            case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers -= 1
+          }
+        }
+      }
+    }
+  }
+
+  /** Check for eligible owners without invoking callbacks or inspecting 
native state. */
+  protected final def hasOptionalMemoryReclaimers(memoryMode: MemoryMode): 
Boolean = {
+    memoryMode match {
+      case MemoryMode.ON_HEAP => onHeapOptionalReclaimers != 0
+      case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers != 0
+    }
+  }
+
+  /**
+   * Drain a snapshot of matching owners under ordinary admission's shared 
gate, outside all
+   * this manager's and the registry's monitors. Owners may run under the 
requesting task's monitor
+   * and must follow the registration's lock-order contract. They must 
synchronously cancel pure
+   * I/O and release exact credits, without dropping readers/sessions or 
awaiting task cleanup.
+   * Continue draining other owners after a non-fatal failure, then propagate 
it without inventing
+   * freed credit. Registrations remain live so a failed drain may be retried 
safely.
+   */
+  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 {
+        case (callback, (_, mode)) if memoryMode.forall(_ == mode) => callback
+      }.toList
+    }
+    var failure: Throwable = null
+    callbacks.foreach { callback =>
+      try {
+        callback.run()
+      } catch {
+        case NonFatal(error) =>
+          if (failure == null) failure = error else if (failure ne error) {
+            failure.addSuppressed(error)
+          }
+      }
+    }
+    if (failure != null) throw failure

Review Comment:
   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.



##########
core/src/main/scala/org/apache/spark/memory/ExecutionMemoryPool.scala:
##########
@@ -67,6 +67,54 @@ private[memory] class ExecutionMemoryPool(
     memoryForTask.getOrElse(taskAttemptId, 0L)
   }
 
+  /**
+   * Check a prospective ordinary request without registering a task or 
changing its charge.
+   * `availableMemory` includes free storage the caller can borrow without 
eviction; `maxPoolSize`
+   * uses the same potential fair-share ceiling as ordinary acquisition. False 
asks the caller to
+   * drain optional owners before any grant, eviction, or capacity wait.
+   */
+  private[memory] def canAcquireMemory(
+      numBytes: Long,
+      taskAttemptId: Long,
+      maxPoolSize: Long,
+      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)

Review Comment:
   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.



##########
core/src/main/scala/org/apache/spark/memory/MemoryManager.scala:
##########
@@ -44,6 +48,127 @@ private[spark] abstract class MemoryManager(
 
   require(onHeapExecutionMemory > 0, "onHeapExecutionMemory must be > 0")
 
+  // Acquire the marker before this manager's monitor. Shared ownership never 
excludes ordinary
+  // operations, including capacity waiters; optional admission only tries the 
exclusive side.
+  protected val optionalAdmissionGate = new ReentrantReadWriteLock()
+
+  // This lock protects only registrations. Callbacks never run while it or 
this manager's
+  // monitor is held, and registration does not wait behind ordinary capacity 
waiters.
+  private val optionalReclaimers = new mutable.LinkedHashMap[Runnable, (Long, 
MemoryMode)]()
+  @volatile private var onHeapOptionalReclaimers = 0
+  @volatile private var offHeapOptionalReclaimers = 0
+
+  /**
+   * Register a task-owned, release-only callback before its first optional 
admission.
+   * Returns an idempotent unregister action; callers must drain the owner 
before unregistering.
+   * Callbacks may run concurrently, repeatedly, or after unregistering and 
must release each
+   * reservation exactly once. They may take a short owner-state lock, but 
must not acquire a
+   * TaskMemoryManager monitor, allocate execution memory, or wait for I/O or 
task cleanup.
+   *
+   * Never hold a lock needed by a reclaimer while requesting ordinary memory 
or invoking another
+   * operation that may reclaim optional memory, including storage cleanup. 
Otherwise two tasks
+   * 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(
+      taskAttemptId: Long,
+      memoryMode: MemoryMode,
+      reclaimer: Runnable): Runnable = {
+    // A distinct forwarding object gives each registration identity even if 
callbacks are reused.
+    val registered = new Runnable {
+      /** Release this owner's optional bytes without allocating or destroying 
a whole reader. */
+      override def run(): Unit = reclaimer.run()
+    }
+    optionalReclaimers.synchronized {
+      optionalReclaimers(registered) = (taskAttemptId, memoryMode)
+      memoryMode match {
+        case MemoryMode.ON_HEAP => onHeapOptionalReclaimers += 1
+        case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers += 1
+      }
+    }
+    new Runnable {
+      /** Remove this registration only; an already-captured callback remains 
safe to invoke. */
+      override def run(): Unit = optionalReclaimers.synchronized {
+        if (optionalReclaimers.remove(registered).isDefined) {
+          memoryMode match {
+            case MemoryMode.ON_HEAP => onHeapOptionalReclaimers -= 1
+            case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers -= 1
+          }
+        }
+      }
+    }
+  }
+
+  /** Check for eligible owners without invoking callbacks or inspecting 
native state. */
+  protected final def hasOptionalMemoryReclaimers(memoryMode: MemoryMode): 
Boolean = {
+    memoryMode match {
+      case MemoryMode.ON_HEAP => onHeapOptionalReclaimers != 0
+      case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers != 0
+    }
+  }
+
+  /**
+   * Drain a snapshot of matching owners under ordinary admission's shared 
gate, outside all
+   * this manager's and the registry's monitors. Owners may run under the 
requesting task's monitor
+   * and must follow the registration's lock-order contract. They must 
synchronously cancel pure
+   * I/O and release exact credits, without dropping readers/sessions or 
awaiting task cleanup.
+   * Continue draining other owners after a non-fatal failure, then propagate 
it without inventing
+   * freed credit. Registrations remain live so a failed drain may be retried 
safely.
+   */
+  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 {
+        case (callback, (_, mode)) if memoryMode.forall(_ == mode) => callback
+      }.toList
+    }
+    var failure: Throwable = null
+    callbacks.foreach { callback =>
+      try {
+        callback.run()
+      } catch {
+        case NonFatal(error) =>
+          if (failure == null) failure = error else if (failure ne error) {
+            failure.addSuppressed(error)
+          }
+      }
+    }
+    if (failure != null) throw failure
+  }
+
+  /**
+   * Mark an operation that can hold this monitor while evicting blocks or 
waiting for capacity.
+   *
+   * MemoryStore uses this before its atomic unroll/storage transfers take the 
monitor, preserving
+   * marker-before-monitor ordering when they call back into ordinary 
allocation. An outermost
+   * MemoryStore operation drains optional owners before taking the monitor: a 
preflight outside
+   * that monitor could otherwise race ordinary allocations and require a 
callback inside it.
+   * Nested operations reuse the outer drain. Failures propagate and the 
marker is always released.
+   * Release-only storage cleanup logs non-fatal drain failures and continues: 
aborting removal
+   * could leave a cached entry behind after BlockManager deletes its 
metadata. Failed owners keep
+   * their memory charges and registrations; failures from the cleanup body 
still propagate.
+   */
+  private[spark] final def withMemoryReclamation[T](
+      body: => T,
+      releaseOnly: Boolean = false): T = {
+    val gate = optionalAdmissionGate.readLock()
+    gate.lock()
+    try {
+      if ((onHeapOptionalReclaimers != 0 || offHeapOptionalReclaimers != 0) &&
+          optionalAdmissionGate.getReadHoldCount == 1) {
+        try {
+          reclaimOptionalMemory(None)
+        } catch {
+          case NonFatal(error) if releaseOnly =>

Review Comment:
   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.



##########
core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala:
##########
@@ -134,7 +180,75 @@ private[spark] class UnifiedMemoryManager(
   override private[memory] def acquireExecutionMemory(
       numBytes: Long,
       taskAttemptId: Long,
-      memoryMode: MemoryMode): Long = synchronized {
+      memoryMode: MemoryMode): Long = {
+    val gate = optionalAdmissionGate.readLock()
+    gate.lock()

Review Comment:
   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.



##########
core/src/main/scala/org/apache/spark/memory/MemoryManager.scala:
##########
@@ -44,6 +48,127 @@ private[spark] abstract class MemoryManager(
 
   require(onHeapExecutionMemory > 0, "onHeapExecutionMemory must be > 0")
 
+  // Acquire the marker before this manager's monitor. Shared ownership never 
excludes ordinary
+  // operations, including capacity waiters; optional admission only tries the 
exclusive side.
+  protected val optionalAdmissionGate = new ReentrantReadWriteLock()
+
+  // This lock protects only registrations. Callbacks never run while it or 
this manager's
+  // monitor is held, and registration does not wait behind ordinary capacity 
waiters.
+  private val optionalReclaimers = new mutable.LinkedHashMap[Runnable, (Long, 
MemoryMode)]()
+  @volatile private var onHeapOptionalReclaimers = 0
+  @volatile private var offHeapOptionalReclaimers = 0
+
+  /**
+   * Register a task-owned, release-only callback before its first optional 
admission.
+   * Returns an idempotent unregister action; callers must drain the owner 
before unregistering.
+   * Callbacks may run concurrently, repeatedly, or after unregistering and 
must release each
+   * reservation exactly once. They may take a short owner-state lock, but 
must not acquire a
+   * TaskMemoryManager monitor, allocate execution memory, or wait for I/O or 
task cleanup.
+   *
+   * Never hold a lock needed by a reclaimer while requesting ordinary memory 
or invoking another
+   * operation that may reclaim optional memory, including storage cleanup. 
Otherwise two tasks
+   * 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(
+      taskAttemptId: Long,
+      memoryMode: MemoryMode,
+      reclaimer: Runnable): Runnable = {
+    // A distinct forwarding object gives each registration identity even if 
callbacks are reused.
+    val registered = new Runnable {
+      /** Release this owner's optional bytes without allocating or destroying 
a whole reader. */
+      override def run(): Unit = reclaimer.run()
+    }
+    optionalReclaimers.synchronized {
+      optionalReclaimers(registered) = (taskAttemptId, memoryMode)
+      memoryMode match {
+        case MemoryMode.ON_HEAP => onHeapOptionalReclaimers += 1
+        case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers += 1
+      }
+    }
+    new Runnable {
+      /** Remove this registration only; an already-captured callback remains 
safe to invoke. */
+      override def run(): Unit = optionalReclaimers.synchronized {
+        if (optionalReclaimers.remove(registered).isDefined) {
+          memoryMode match {
+            case MemoryMode.ON_HEAP => onHeapOptionalReclaimers -= 1
+            case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers -= 1
+          }
+        }
+      }
+    }
+  }
+
+  /** Check for eligible owners without invoking callbacks or inspecting 
native state. */
+  protected final def hasOptionalMemoryReclaimers(memoryMode: MemoryMode): 
Boolean = {
+    memoryMode match {
+      case MemoryMode.ON_HEAP => onHeapOptionalReclaimers != 0
+      case MemoryMode.OFF_HEAP => offHeapOptionalReclaimers != 0
+    }
+  }
+
+  /**
+   * Drain a snapshot of matching owners under ordinary admission's shared 
gate, outside all
+   * this manager's and the registry's monitors. Owners may run under the 
requesting task's monitor
+   * and must follow the registration's lock-order contract. They must 
synchronously cancel pure
+   * I/O and release exact credits, without dropping readers/sessions or 
awaiting task cleanup.
+   * Continue draining other owners after a non-fatal failure, then propagate 
it without inventing
+   * freed credit. Registrations remain live so a failed drain may be retried 
safely.
+   */
+  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 {
+        case (callback, (_, mode)) if memoryMode.forall(_ == mode) => callback
+      }.toList
+    }
+    var failure: Throwable = null
+    callbacks.foreach { callback =>
+      try {
+        callback.run()
+      } catch {
+        case NonFatal(error) =>
+          if (failure == null) failure = error else if (failure ne error) {
+            failure.addSuppressed(error)
+          }
+      }
+    }
+    if (failure != null) throw failure
+  }
+
+  /**
+   * Mark an operation that can hold this monitor while evicting blocks or 
waiting for capacity.
+   *
+   * MemoryStore uses this before its atomic unroll/storage transfers take the 
monitor, preserving
+   * marker-before-monitor ordering when they call back into ordinary 
allocation. An outermost
+   * MemoryStore operation drains optional owners before taking the monitor: a 
preflight outside
+   * that monitor could otherwise race ordinary allocations and require a 
callback inside it.
+   * Nested operations reuse the outer drain. Failures propagate and the 
marker is always released.
+   * Release-only storage cleanup logs non-fatal drain failures and continues: 
aborting removal
+   * could leave a cached entry behind after BlockManager deletes its 
metadata. Failed owners keep
+   * their memory charges and registrations; failures from the cleanup body 
still propagate.
+   */
+  private[spark] final def withMemoryReclamation[T](
+      body: => T,
+      releaseOnly: Boolean = false): T = {
+    val gate = optionalAdmissionGate.readLock()
+    gate.lock()
+    try {
+      if ((onHeapOptionalReclaimers != 0 || offHeapOptionalReclaimers != 0) &&
+          optionalAdmissionGate.getReadHoldCount == 1) {
+        try {
+          reclaimOptionalMemory(None)

Review Comment:
   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.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to