peter-toth commented on code in PR #58763:
URL: https://github.com/apache/spark/pull/58763#discussion_r3999303060


##########
core/src/main/scala/org/apache/spark/memory/MemoryManager.scala:
##########
@@ -128,6 +258,24 @@ private[spark] abstract class MemoryManager(
       taskAttemptId: Long,
       memoryMode: MemoryMode): Long
 
+  /**
+   * Reserve all `numBytes` for optional task work without waiting for 
capacity or reclaiming
+   * memory.
+   *
+   * Managers must opt in to this policy; the default rejects the request 
without changing their
+   * accounting. Implementations may still contend on bookkeeping locks. A 
successful reservation
+   * must be task-attributed and released through the existing 
execution-memory release methods.
+   *
+   * @return `numBytes` on success, or zero with no reservation on denial
+   */
+  private[memory] def tryAcquireExecutionMemory(

Review Comment:
   **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](https://github.com/apache/spark/pull/58763#discussion_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.
   



##########
core/src/main/scala/org/apache/spark/storage/memory/MemoryStore.scala:
##########
@@ -414,20 +438,47 @@ private[spark] class MemoryStore(
   def freeMemoryEntry[T <: MemoryEntry[_]](entry: T): Unit = {
     entry match {
       case SerializedMemoryEntry(buffer, _, _) => buffer.dispose()
-      case e: DeserializedMemoryEntry[_] => e.value.foreach {
-        case o: AutoCloseable =>
-          try {
-            o.close()
-          } catch {
-            case NonFatal(e) =>
-              logWarning("Fail to close a memory entry", e)
-          }
-        case _ =>
-      }
+      case e: DeserializedMemoryEntry[_] => freeValues(e.value.iterator)
     }
   }
 
-  def remove(blockId: BlockId): Boolean = memoryManager.synchronized {
+  private def freeValues(values: Iterator[_]): Unit = values.foreach {
+    case o: AutoCloseable =>
+      try {
+        o.close()
+      } catch {
+        case NonFatal(e) =>
+          logWarning("Fail to close a memory entry", e)
+      }
+    case _ =>
+  }
+
+  /** Dispose only values consumed by this unroll operation. */
+  private def freeUnrolledValues(valuesHolder: ValuesHolder[_]): Unit = 
valuesHolder match {
+    case holder: DeserializedValuesHolder[_] =>
+      // Final sizing has already moved the vector into arrayValues; do not 
build it again.
+      freeValues(if (holder.vector != null) holder.vector.iterator else 
holder.arrayValues.iterator)
+    case holder: SerializedValuesHolder[_] =>
+      Utils.tryWithSafeFinally {
+        // As in PartiallySerializedBlock.discard, closing must not allocate 
or flush more data.
+        
holder.redirectableStream.setOutputStream(OutputStream.nullOutputStream())
+        holder.serializationStream.close()
+      } {
+        holder.bbos.dispose()
+      }
+  }
+
+  /**
+   * Remove a block and release its storage charge; optional admission skips 
object close callbacks.
+   */
+  def remove(blockId: BlockId): Boolean = {

Review Comment:
   **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](https://github.com/apache/spark/pull/58763#discussion_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.
   



##########
core/src/main/scala/org/apache/spark/storage/memory/MemoryStore.scala:
##########
@@ -589,17 +659,23 @@ private[spark] class MemoryStore(
       blockId: BlockId,
       memory: Long,
       memoryMode: MemoryMode): Boolean = {
-    memoryManager.synchronized {
-      val success = memoryManager.acquireUnrollMemory(blockId, memory, 
memoryMode)
-      if (success) {
-        val taskAttemptId = currentTaskAttemptId()
-        val unrollMemoryMap = memoryMode match {
-          case MemoryMode.ON_HEAP => onHeapUnrollMemoryMap
-          case MemoryMode.OFF_HEAP => offHeapUnrollMemoryMap
+    if (memoryManager.isStorageMemoryRequestTooLarge(memory, memoryMode)) {

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



##########
core/src/main/scala/org/apache/spark/storage/memory/MemoryStore.scala:
##########
@@ -414,20 +438,47 @@ private[spark] class MemoryStore(
   def freeMemoryEntry[T <: MemoryEntry[_]](entry: T): Unit = {
     entry match {
       case SerializedMemoryEntry(buffer, _, _) => buffer.dispose()
-      case e: DeserializedMemoryEntry[_] => e.value.foreach {
-        case o: AutoCloseable =>
-          try {
-            o.close()
-          } catch {
-            case NonFatal(e) =>
-              logWarning("Fail to close a memory entry", e)
-          }
-        case _ =>
-      }
+      case e: DeserializedMemoryEntry[_] => freeValues(e.value.iterator)
     }
   }
 
-  def remove(blockId: BlockId): Boolean = memoryManager.synchronized {
+  private def freeValues(values: Iterator[_]): Unit = values.foreach {
+    case o: AutoCloseable =>
+      try {
+        o.close()
+      } catch {
+        case NonFatal(e) =>
+          logWarning("Fail to close a memory entry", e)
+      }
+    case _ =>
+  }
+
+  /** Dispose only values consumed by this unroll operation. */
+  private def freeUnrolledValues(valuesHolder: ValuesHolder[_]): Unit = 
valuesHolder match {

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



##########
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()
+    try {
+      // A first registration can race this check, but cannot admit optional 
bytes while this
+      // read gate is held. Keep the no-owner path free of reentrant-monitor 
checks/preflight.
+      if (!hasOptionalMemoryReclaimers(memoryMode)) {
+        return synchronized {
+          acquireExecutionMemoryInternal(numBytes, taskAttemptId, memoryMode)
+        }
+      }
+      val enteredWithMonitor = Thread.holdsLock(this)

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



##########
core/src/main/scala/org/apache/spark/util/io/ChunkedByteBufferOutputStream.scala:
##########
@@ -53,6 +53,16 @@ private[spark] class ChunkedByteBufferOutputStream(
 
   def size: Long = _size
 
+  /** Release untransferred chunks without allocating a compact final chunk. */
+  def dispose(): Unit = {

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



-- 
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