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


##########
core/src/main/scala/org/apache/spark/memory/ExecutionMemoryPool.scala:
##########
@@ -164,15 +202,16 @@ private[memory] class ExecutionMemoryPool(
     }
     if (memoryForTask.contains(taskAttemptId)) {
       memoryForTask(taskAttemptId) -= memoryToFree
-      if (memoryForTask(taskAttemptId) <= 0) {
+      if (memoryForTask(taskAttemptId) <= 0 && 
!hasWaitingAcquisition(taskAttemptId)) {

Review Comment:
   **Finding 2.** This line is the whole lifecycle change - a task at zero 
bytes stays registered while an acquisition waits. `releaseMemory`'s scaladoc 
at `:190` is unchanged, while the two callers that only delegate to it both got 
the note. The rule should be stated where it is enforced:
   
   ```scala
     /**
      * Release `numBytes` of memory acquired by the given task. The task is 
deregistered once it
      * has neither reserved bytes nor a waiting acquisition.
      */
   ```
   
   Two more places still describe the old lifecycle:
   
   - `memoryForTask` at 
`core/src/main/scala/org/apache/spark/memory/ExecutionMemoryPool.scala:54` - 
"Map from taskAttemptId -> memory consumption in bytes" does not say an entry 
can be a zero-byte placeholder for a waiter.
   - the class scaladoc at `:34`, "we keep track of the set of active tasks", 
which is the set `numActiveTasks` divides by.
   
   Same area as 
[r3997983841](https://github.com/apache/spark/pull/58747#discussion_r3997983841),
 so it is probably one edit.
   



##########
core/src/test/scala/org/apache/spark/memory/ExecutionMemoryPoolSuite.scala:
##########
@@ -0,0 +1,242 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.memory
+
+import java.util.concurrent.{CompletableFuture, ExecutionException, TimeUnit}
+import java.util.concurrent.atomic.AtomicBoolean
+
+import scala.collection.mutable.ArrayBuffer
+import scala.util.control.NonFatal
+
+import org.scalatest.concurrent.Eventually
+import org.scalatest.time.SpanSugar._
+
+import org.apache.spark.{SparkConf, SparkFunSuite}
+
+class ExecutionMemoryPoolSuite extends SparkFunSuite with Eventually {
+  private class WaitingAcquire(acquire: () => Long) {
+    val result = new CompletableFuture[Long]()
+    val thread = new Thread("execution-memory-waiter") {
+      override def run(): Unit = {
+        try {
+          result.complete(acquire())
+        } catch {
+          case e: InterruptedException => result.completeExceptionally(e)
+          case NonFatal(e) => result.completeExceptionally(e)
+        }
+      }
+    }
+    thread.setDaemon(true)
+
+    def awaitWaiting(): Unit = eventually(timeout(10.seconds)) {
+      assert(!result.isDone, "acquisition completed instead of waiting")
+      assert(thread.getState == Thread.State.WAITING)
+      assert(thread.getStackTrace.exists { frame =>
+        frame.getClassName == classOf[ExecutionMemoryPool].getName &&
+          frame.getMethodName == "acquireMemory"
+      })
+    }
+
+    def acquired(): Long = result.get(10, TimeUnit.SECONDS)
+
+    def failure(): Throwable = {
+      val error = intercept[ExecutionException] {
+        result.get(10, TimeUnit.SECONDS)
+      }
+      error.getCause
+    }
+
+    def interrupt(): Unit = {
+      thread.interrupt()
+      assert(failure().isInstanceOf[InterruptedException])
+    }
+  }
+
+  private val waiters = new ArrayBuffer[WaitingAcquire]()
+
+  override protected def afterEach(): Unit = {
+    try {
+      waiters.foreach(_.thread.interrupt())
+      waiters.foreach(_.thread.join(10000))
+      assert(waiters.forall(!_.thread.isAlive), "memory acquisition thread did 
not terminate")
+    } finally {
+      waiters.clear()
+      super.afterEach()
+    }
+  }
+
+  private def acquireAsync(
+      pool: ExecutionMemoryPool,
+      bytes: Long,
+      maybeGrowPool: Long => Unit = _ => ()): WaitingAcquire = {
+    acquireAsync(pool.acquireMemory(bytes, 1L, maybeGrowPool))
+  }
+
+  private def acquireAsync(acquire: => Long): WaitingAcquire = {
+    val waiter = new WaitingAcquire(() => acquire)
+    waiters += waiter
+    waiter.thread.start()
+    waiter.awaitWaiting()
+    waiter
+  }
+
+  private def newPool(mode: MemoryMode): ExecutionMemoryPool = {
+    val pool = new ExecutionMemoryPool(new Object, mode)
+    pool.incrementPoolSize(1000L)
+    assert(pool.acquireMemory(900L, 2L) == 900L)
+    pool
+  }
+
+  for (mode <- Seq(MemoryMode.ON_HEAP, MemoryMode.OFF_HEAP)) {
+    test(s"retain task registration across two consumers of one 
TaskMemoryManager ($mode)") {
+      val conf = new SparkConf(false)
+        .set("spark.memory.offHeap.enabled", "true")
+        .set("spark.memory.offHeap.size", "1000")
+      val memory = new UnifiedMemoryManager(conf, 1000L, 500L, 1)
+      val task = new TaskMemoryManager(memory, 1L)
+      val peerTask = new TaskMemoryManager(memory, 2L)
+      val owner = new TestMemoryConsumer(task, mode)
+      val requester = new TestMemoryConsumer(task, mode)
+      val peer = new TestMemoryConsumer(peerTask, mode)
+      assert(peer.acquireMemory(900L) == 900L)
+      assert(owner.acquireMemory(100L) == 100L)
+      val waiter = acquireAsync(requester.acquireMemory(300L))
+
+      // Release through another consumer of the waiting task, not through the 
pool directly.
+      owner.freeMemory(100L)
+      peer.freeMemory(300L)
+      assert(waiter.acquired() == 300L)
+      assert(owner.getUsed() == 0L)
+      assert(requester.getUsed() == 300L)
+      assert(task.getMemoryConsumptionForThisTask() == 300L)
+      assert(peerTask.getMemoryConsumptionForThisTask() == 600L)
+      assert(memory.executionMemoryUsed == 900L)
+
+      requester.freeMemory(300L)
+      peer.freeMemory(600L)
+      assert(task.cleanUpAllAllocatedMemory() == 0L)
+      assert(peerTask.cleanUpAllAllocatedMemory() == 0L)
+      assert(memory.executionMemoryUsed == 0L)
+    }
+
+    for (releaseAll <- Seq(false, true)) {
+      test(s"retain a waiting task after its last release ($mode, 
releaseAll=$releaseAll)") {
+        val pool = newPool(mode)
+        assert(pool.acquireMemory(100L, 1L) == 100L)
+        val waiter = acquireAsync(pool, 300L)
+
+        if (releaseAll) {
+          assert(pool.releaseAllMemoryForTask(1L) == 100L)
+        } else {
+          pool.releaseMemory(100L, 1L)
+        }
+        pool.releaseMemory(300L, 2L)
+
+        assert(waiter.acquired() == 300L)
+        assert(pool.getMemoryUsageForTask(1L) == 300L)
+        assert(pool.memoryUsed == 900L)

Review Comment:
   **Finding 1.** The suite pins the *retain* half of the waiter bookkeeping 
thoroughly and never checks the *release* half. Nothing asserts that a task 
which waited is fully deregistered once it has no waiter and no reservation 
left.
   
   I measured the gap rather than guessing. Leaking the waiter count on the 
success path only:
   
   ```scala
       var registeredWaiter = false
       var succeeded = false
       ...
             memoryForTask(taskAttemptId) += toGrant
             succeeded = true
             return toGrant
       ...
       } finally {
         if (registeredWaiter && !succeeded) {
   ```
   
   All 18 new cases still pass. The leak is permanent: `waitingAcquisitions` 
keeps the entry forever, so `releaseMemory` never removes the task, and a 
phantom zero-byte participant stays in `memoryForTask` for the life of the JVM. 
Every other task's `maxMemoryPerTask` is halved from then on.
   
   Three lines at the end of this test close it:
   
   ```suggestion
           assert(pool.memoryUsed == 900L)
   
           // The waiter is gone, so releasing the reservation must deregister 
the task.
           pool.releaseMemory(300L, 1L)
           // numActiveTasks == 1 caps task 2 at the whole pool. A phantom 
entry would cap it
           // at 500 < 600 and this would return 0.
           assert(pool.acquireMemory(100L, 2L) == 100L)
   ```
   
   I ran that check as a standalone case: it passes on the PR as-is and fails 
on the leak above.
   
   The same hole exists across tasks. All 18 cases only ever have task 1 
waiting, so nothing covers task 1's last waiter leaving while task 2 still has 
one - the `waitingAcquisitions.isEmpty` / `null` reset path. Worth a case if 
the waiter map survives 
[r3998051507](https://github.com/apache/spark/pull/58747#discussion_r3998051507).
   



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