cloud-fan commented on code in PR #58763:
URL: https://github.com/apache/spark/pull/58763#discussion_r4048046585
##########
core/src/main/scala/org/apache/spark/memory/ExecutionMemoryPool.scala:
##########
@@ -131,7 +227,8 @@ private[memory] class ExecutionMemoryPool(
// How much we can grant this task; keep its share within 0 <= X <= 1 /
numActiveTasks
val maxToGrant = math.min(numBytes, math.max(0, maxMemoryPerTask -
curMem))
// Only give it as much memory as is free, which might be none if it
reached 1 / numTasks
- val toGrant = math.min(maxToGrant, memoryFree)
+ val available = if (computeMemoryFree == null) memoryFree else
computeMemoryFree()
Review Comment:
**Blocking (P1):** A failed optional reclaimer can make this wait permanent.
With a 1000-byte pool, 600 optional bytes still charged, and the only ordinary
task requesting 700, `computeMemoryFree` returns 400 while `minMemoryPerTask`
remains 500. This branch waits, but there is no ordinary holder that can
release memory or notify it; the reclamation pass has already finished.
**Recommended change:** Have the 1/2N wait test use the effective ordinary
execution capacity when a physical cap is active.
**Why this works:** Alongside free bytes, derive effective ordinary capacity
as current ordinary charges plus physically grantable execution capacity, and
use that value for the minimum-share floor. Keep the original pool-size floor
when no optional physical cap is active.
**Scope:** Unify the optional-enabled grant and liveness capacity domains
without changing ordinary allocator policy when the feature is disabled or no
optional bytes compete.
**Compatibility:** Ordinary 1/N ceilings, storage borrowing, and
disabled-feature allocation behavior remain unchanged.
**Risks:** An effective-capacity calculation that excludes current ordinary
charges would make the floor too small. The disabled/no-optional path must
retain existing Spark fairness semantics.
**Constraints:** Callbacks must remain outside the memory-manager monitor.
Nonfatal reclaimer failures must retain their outstanding charge.
**Success:** The sole ordinary task receives the physically available
partial grant after a failed optional drain instead of waiting forever.
Multiple ordinary tasks still wait only for a floor that can be reached by
ordinary releases. The no-optional allocator keeps its existing fair-share
behavior.
##########
core/src/main/scala/org/apache/spark/internal/config/package.scala:
##########
@@ -483,6 +483,17 @@ package object config {
.checkValue(_ >= 0, "The off-heap memory size must not be negative")
.createWithDefault(0)
+ private[spark] val MEMORY_OPTIONAL_ENABLED =
ConfigBuilder("spark.memory.optional.enabled")
+ .internal()
+ .doc("Enable optional execution-memory admission and reclamation. Set
before the memory " +
+ "manager is created; changing it later has no effect. Disabled managers
reject optional " +
+ "requests and skip admission coordination. Consumers opt in to
revocation of unused " +
+ "speculative work for ordinary allocations, including allocations from
other tasks.")
+ .version("5.0.0")
Review Comment:
**Non-blocking (P2):** This should use `4.4.0`. For a normal non-breaking PR
against master, Spark's branch policy uses the latest `branch-N.x` version
where the change first ships; the pinned `branch-4.x` version is
`4.4.0-SNAPSHOT`, not 5.0.0.
##########
core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala:
##########
@@ -193,20 +364,98 @@ private[spark] class UnifiedMemoryManager(
* when unmanaged components are consuming significant memory.
*/
def computeMaxExecutionPoolSize(): Long = {
- val unmanagedMemory = getUnmanagedMemoryUsed(memoryMode)
- val availableMemory = maxMemory - math.min(storagePool.memoryUsed,
storageRegionSize)
- // Reduce available memory by unmanaged memory usage to prevent
over-allocation
- math.max(0L, availableMemory - unmanagedMemory)
+ val unmanagedMemory =
unmanagedMemorySnapshot.getOrElse(getUnmanagedMemoryUsed(memoryMode))
+ maxExecutionMemory(storagePool, storageRegionSize, maxMemory,
unmanagedMemory)
}
+ // The gate prevents new optional grants, including while acquireMemory
waits. Avoid creating
+ // a capacity callback when no optional credit can compete with this
ordinary allocation.
+ val optionalCapacity = if (!optionalMemoryEnabled ||
+ executionPool.optionalMemoryUsed == 0L) {
+ null
+ } else {
+ () => {
+ val unmanaged =
unmanagedMemorySnapshot.getOrElse(getUnmanagedMemoryUsed(memoryMode))
+ availableExecutionMemory(memoryMode, unmanaged, borrowStorage = false)
+ }
+ }
executionPool.acquireMemory(
- numBytes, taskAttemptId, maybeGrowExecutionPool, () =>
computeMaxExecutionPoolSize())
+ numBytes, taskAttemptId, maybeGrowExecutionPool, () =>
computeMaxExecutionPoolSize(),
+ optionalCapacity)
}
override def acquireStorageMemory(
blockId: BlockId,
numBytes: Long,
- memoryMode: MemoryMode): Boolean = synchronized {
+ memoryMode: MemoryMode): Boolean = {
+ if (!optionalMemoryEnabled || isStorageMemoryRequestTooLarge(numBytes,
memoryMode)) {
+ return synchronized { acquireStorageMemoryInternal(blockId, numBytes,
memoryMode) }
+ }
+ withReclamation(memoryMode) {
+ val unmanaged = getUnmanagedMemoryUsed(memoryMode)
+ if (canAcquireStorageMemory(numBytes, memoryMode, unmanaged)) {
+ Some(acquireStorageMemoryInternal(blockId, numBytes, memoryMode,
Some(unmanaged)))
+ } else if (numBytes > maxStorageMemoryAfterReclamation(memoryMode,
unmanaged)) {
+ Some(acquireStorageMemoryInternal(blockId, numBytes, memoryMode,
Some(unmanaged)))
+ } else {
+ None
+ }
+ } {
+ acquireStorageMemoryInternal(blockId, numBytes, memoryMode)
+ }
+ }
+
+ private def maxStorageMemoryAfterReclamation(
+ memoryMode: MemoryMode,
+ unmanaged: Long): Long = {
+ val (executionPool, _, _, maxMemory) = poolsFor(memoryMode)
+ math.max(0L, maxMemory - executionPool.ordinaryMemoryUsed - unmanaged)
+ }
+
+ override private[spark] def withStorageMemoryReclamation[T](
+ numBytes: Long,
+ memoryMode: MemoryMode)(body: => T): T = {
+ require(numBytes >= 0L)
+ if (!optionalMemoryEnabled) return synchronized { body }
+ withReclamation(memoryMode) {
+ val unmanaged = getUnmanagedMemoryUsed(memoryMode)
+ if (canAcquireStorageMemory(numBytes, memoryMode, unmanaged) ||
+ numBytes > maxStorageMemoryAfterReclamation(memoryMode, unmanaged)) {
+ Some(body)
+ } else {
+ None
+ }
+ } {
+ body
+ }
+ }
+
+ /** Check storage capacity under the monitor without moving pool boundaries
or evicting blocks. */
+ private def canAcquireStorageMemory(
+ numBytes: Long,
+ memoryMode: MemoryMode,
+ unmanagedMemory: Long): Boolean = {
+ assert(Thread.holdsLock(this))
+ val (executionPool, storagePool, maxMemory) = memoryMode match {
+ case MemoryMode.ON_HEAP =>
+ (onHeapExecutionMemoryPool, onHeapStorageMemoryPool,
maxOnHeapStorageMemory)
+ case MemoryMode.OFF_HEAP =>
+ (offHeapExecutionMemoryPool, offHeapStorageMemoryPool,
maxOffHeapStorageMemory)
+ }
+ numBytes <= math.max(0L, maxMemory - unmanagedMemory) &&
Review Comment:
**Blocking (P1):** This preflight needs to enforce aggregate same-mode
occupancy, not just compare `numBytes` with the unmanaged-adjusted ceiling. For
example, with max memory 1000, existing storage 700, optional execution 100,
unmanaged usage 200, and a 100-byte request, both predicates pass; admission
leaves 800 storage + 100 optional + 200 unmanaged = 1100 bytes.
##########
core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala:
##########
@@ -122,6 +122,69 @@ private[spark] class UnifiedMemoryManager(
maxOffHeapMemory - offHeapExecutionMemoryPool.memoryUsed
}
+ override private[spark] def isStorageMemoryRequestTooLarge(
+ numBytes: Long,
+ memoryMode: MemoryMode): Boolean = memoryMode match {
+ case MemoryMode.ON_HEAP => numBytes > maxHeapMemory
+ case MemoryMode.OFF_HEAP => numBytes > maxOffHeapMemory
+ }
+
+ /** Pool selection and the fairness ceiling are shared by optional and
ordinary admission. */
+ private def poolsFor(memoryMode: MemoryMode):
+ (ExecutionMemoryPool, StorageMemoryPool, Long, Long) = memoryMode match {
+ case MemoryMode.ON_HEAP =>
+ (onHeapExecutionMemoryPool, onHeapStorageMemoryPool,
onHeapStorageRegionSize, maxHeapMemory)
+ case MemoryMode.OFF_HEAP =>
+ (offHeapExecutionMemoryPool, offHeapStorageMemoryPool,
offHeapStorageMemory, maxOffHeapMemory)
+ }
+
+ private def maxExecutionMemory(memoryMode: MemoryMode, unmanagedMemory:
Long): Long = {
+ val (_, storagePool, regionSize, maxMemory) = poolsFor(memoryMode)
+ maxExecutionMemory(storagePool, regionSize, maxMemory, unmanagedMemory)
+ }
+
+ private def maxExecutionMemory(
+ storagePool: StorageMemoryPool,
+ regionSize: Long,
+ maxMemory: Long,
+ unmanagedMemory: Long): Long = {
+ math.max(0L, maxMemory - math.min(storagePool.memoryUsed, regionSize) -
unmanagedMemory)
+ }
+
+ /**
+ * Reserve all requested bytes from existing free execution memory for
optional task work.
+ *
+ * Unlike ordinary admission, this does not borrow storage memory, evict
blocks, or wait for
+ * capacity. The fair-share ceiling is the same as ordinary admission,
including storage that
Review Comment:
**Non-blocking (P2):** The fair-share ceiling is not the same as ordinary
admission. `tryAcquireMemory` includes optional-only tasks in the divisor and
subtracts the requester's ordinary plus optional bytes; ordinary
`acquireMemory` considers only ordinary participants and ordinary bytes. Please
describe this as the intentionally more conservative optional ceiling instead
of claiming equality.
##########
core/src/test/java/org/apache/spark/memory/TaskMemoryManagerSuite.java:
##########
@@ -187,6 +194,349 @@ public long spill(long size, MemoryConsumer trigger) {
}
}
+ /** One Comet-like consumer owns both ordinary and optional bytes through
the same task. */
+ private static final class OptionalMemoryConsumer extends MemoryConsumer {
+ private final AtomicLong optionalBytes = new AtomicLong();
+
+ OptionalMemoryConsumer(TaskMemoryManager manager) {
+ super(manager, 0L, MemoryMode.OFF_HEAP);
Review Comment:
**Nit (P3):** Because this fixture hard-codes `OFF_HEAP`, none of the
optional-memory tests exercises `TaskMemoryManager`'s new on-heap current/peak
accounting. Please parameterize the consumer by mode and assert current and
peak usage for mixed ordinary/optional acquisition and release in both modes.
##########
core/src/test/java/org/apache/spark/memory/TaskMemoryManagerSuite.java:
##########
@@ -187,6 +194,349 @@ public long spill(long size, MemoryConsumer trigger) {
}
}
+ /** One Comet-like consumer owns both ordinary and optional bytes through
the same task. */
+ private static final class OptionalMemoryConsumer extends MemoryConsumer {
+ private final AtomicLong optionalBytes = new AtomicLong();
+
+ OptionalMemoryConsumer(TaskMemoryManager manager) {
+ super(manager, 0L, MemoryMode.OFF_HEAP);
+ }
+
+ synchronized long reserveOptional(long size) {
+ long got = taskMemoryManager.tryAcquireOptionalExecutionMemory(size,
this);
+ optionalBytes.addAndGet(got);
+ used.addAndGet(got);
+ return got;
+ }
+
+ synchronized long acquireOrdinary(long size) {
+ long got = taskMemoryManager.acquireExecutionMemory(size, this);
+ used.addAndGet(got);
+ return got;
+ }
+
+ synchronized void releaseOrdinary(long size) {
+ taskMemoryManager.releaseExecutionMemory(size, this);
+ used.addAndGet(-size);
+ }
+
+ synchronized void reclaimOptional() {
+ long size = optionalBytes.get();
+ if (size > 0) {
+ taskMemoryManager.releaseOptionalExecutionMemory(size, this);
+ optionalBytes.addAndGet(-size);
+ used.addAndGet(-size);
+ }
+ }
+
+ @Override
+ public long spill(long size, MemoryConsumer trigger) {
+ return 0L;
+ }
+ }
+
+ private static SparkConf optionalTestConf(boolean enabled) {
+ return new SparkConf(false)
+ .set("spark.memory.optional.enabled", Boolean.toString(enabled))
+ .set(package$.MODULE$.MEMORY_OFFHEAP_ENABLED(), true)
+ .set(package$.MODULE$.MEMORY_OFFHEAP_SIZE(), 1000L)
+ .set(package$.MODULE$.MEMORY_STORAGE_FRACTION(), 0.0);
+ }
+
+ private UnifiedMemoryManager optionalTestPool(boolean enabled) {
+ return new UnifiedMemoryManager(optionalTestConf(enabled), 1000L, 0L, 1);
+ }
+
+ @Test
+ public void optionalAdmissionCanReclaimAnotherTasksReadAhead() throws
Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ TaskMemoryManager requester = new TaskMemoryManager(pool, 2);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ TestMemoryConsumer ordinary = new TestMemoryConsumer(requester,
MemoryMode.OFF_HEAP);
+
+ Assertions.assertEquals(0, readAhead.reserveOptional(1)); // Must register
first.
+ AutoCloseable registration =
+ owner.registerOptionalMemoryReclaimer(readAhead,
readAhead::reclaimOptional);
+ Assertions.assertNotNull(registration);
+ Assertions.assertEquals(400, readAhead.reserveOptional(400));
+ Assertions.assertEquals(400, owner.getMemoryConsumptionForThisTask());
+ String breakdown = owner.getMemoryConsumptionBreakdown();
+ Assertions.assertTrue(breakdown.contains(readAhead.toString() + ": "),
breakdown);
+ Assertions.assertFalse(breakdown.contains("(not attributed to a specific
consumer)"),
+ breakdown);
+
+ // The owner has no ordinary grant. Its optional bytes must not shrink the
peer's share.
+ ordinary.use(700);
+ Assertions.assertEquals(700, ordinary.getUsed());
+ Assertions.assertEquals(0, readAhead.getUsed());
+ Assertions.assertEquals(0, owner.getMemoryConsumptionForThisTask());
+ Assertions.assertEquals(700, pool.executionMemoryUsed());
+
+ registration.close();
+ registration.close();
+ ordinary.free(700);
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ Assertions.assertEquals(0, requester.cleanUpAllAllocatedMemory());
+ }
+
+ @Test
+ public void optionalAndOrdinaryBytesOfOneConsumerHaveSeparateReleasePaths()
throws Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ AutoCloseable registration =
+ owner.registerOptionalMemoryReclaimer(readAhead,
readAhead::reclaimOptional);
+
+ Assertions.assertEquals(100, readAhead.reserveOptional(100));
+ Assertions.assertEquals(200, readAhead.acquireOrdinary(200));
+ Assertions.assertEquals(300, readAhead.getUsed());
+ readAhead.reclaimOptional();
+ Assertions.assertEquals(200, owner.getMemoryConsumptionForThisTask());
+ Assertions.assertEquals(200, readAhead.getUsed());
+ readAhead.releaseOrdinary(200);
+ registration.close();
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ Assertions.assertEquals(300, owner.getPeakOffHeapExecutionMemory());
+ }
+
+ @Test
+ public void optionalReleaseCannotSpendAnotherConsumersCredit() throws
Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ OptionalMemoryConsumer first = new OptionalMemoryConsumer(owner);
+ OptionalMemoryConsumer second = new OptionalMemoryConsumer(owner);
+ OptionalMemoryConsumer unregistered = new OptionalMemoryConsumer(owner);
+ AutoCloseable firstRegistration =
+ owner.registerOptionalMemoryReclaimer(first, first::reclaimOptional);
+ AutoCloseable secondRegistration =
+ owner.registerOptionalMemoryReclaimer(second, second::reclaimOptional);
+
+ Assertions.assertEquals(300, first.reserveOptional(300));
+ Assertions.assertEquals(300, second.reserveOptional(300));
+ // The task-level pool has 600 bytes; neither the 400-byte first release
nor an unrelated
+ // consumer's 100-byte release may spend credit owned by the second
consumer.
+ Assertions.assertThrows(AssertionError.class,
+ () -> owner.releaseOptionalExecutionMemory(400, first));
+ Assertions.assertThrows(AssertionError.class,
+ () -> owner.releaseOptionalExecutionMemory(100, unregistered));
+ Assertions.assertEquals(600, owner.getMemoryConsumptionForThisTask());
+ Assertions.assertEquals(300, first.optionalBytes.get());
+ Assertions.assertEquals(300, second.optionalBytes.get());
+
+ first.reclaimOptional();
+ Assertions.assertEquals(300, owner.getMemoryConsumptionForThisTask());
+ second.reclaimOptional();
+ firstRegistration.close();
+ secondRegistration.close();
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ }
+
+ @Test
+ public void skipsDrainedOwnerWhenAnotherConsumerInTaskHoldsOptionalBytes()
throws Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ TaskMemoryManager peer = new TaskMemoryManager(pool, 2);
+ OptionalMemoryConsumer idle = new OptionalMemoryConsumer(owner);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ TestMemoryConsumer ordinary = new TestMemoryConsumer(peer,
MemoryMode.OFF_HEAP);
+ AtomicInteger idleCalls = new AtomicInteger();
+ AutoCloseable idleRegistration =
+ owner.registerOptionalMemoryReclaimer(idle, () ->
idleCalls.incrementAndGet());
+ AutoCloseable activeRegistration =
+ owner.registerOptionalMemoryReclaimer(readAhead,
readAhead::reclaimOptional);
+
+ Assertions.assertEquals(400, readAhead.reserveOptional(400));
+ ordinary.use(700);
+ Assertions.assertEquals(700, ordinary.getUsed());
+ Assertions.assertEquals(0, idleCalls.get());
+ Assertions.assertEquals(0, readAhead.optionalBytes.get());
+
+ idleRegistration.close();
+ activeRegistration.close();
+ ordinary.free(700);
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ Assertions.assertEquals(0, peer.cleanUpAllAllocatedMemory());
+ }
+
+ @Test
+ public void reclamationSeesGrantBeforeItsOwnerCreditIsPublished() throws
Exception {
+ CountDownLatch grantReady = new CountDownLatch(1);
+ CountDownLatch allowPublication = new CountDownLatch(1);
+ CountDownLatch callbackStarted = new CountDownLatch(1);
+ UnifiedMemoryManager pool = new
UnifiedMemoryManager(optionalTestConf(true), 1000L, 0L, 1) {
+ @Override
+ public long tryAcquireExecutionMemory(
+ long size, long taskAttemptId, MemoryMode mode) {
+ long granted = super.tryAcquireExecutionMemory(size, taskAttemptId,
mode);
+ if (taskAttemptId == 1L && granted > 0L) {
+ grantReady.countDown();
+ try {
+ if (!allowPublication.await(10, TimeUnit.SECONDS)) {
+ // Invariants must propagate even when JVM assertions are
disabled.
+ // checkstyle.off: RegexpSinglelineJava
+ throw new AssertionError("optional grant was not allowed to
publish its credit");
+ // checkstyle.on: RegexpSinglelineJava
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // Invariants must propagate even when JVM assertions are disabled.
+ // checkstyle.off: RegexpSinglelineJava
+ throw new AssertionError(e);
+ // checkstyle.on: RegexpSinglelineJava
+ }
+ }
+ return granted;
+ }
+ };
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ TaskMemoryManager peer = new TaskMemoryManager(pool, 2);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ TestMemoryConsumer ordinary = new TestMemoryConsumer(peer,
MemoryMode.OFF_HEAP);
+ AutoCloseable registration =
owner.registerOptionalMemoryReclaimer(readAhead, () -> {
+ callbackStarted.countDown();
+ readAhead.reclaimOptional();
+ });
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ Future<?> peerAcquisition = null;
+ try {
+ Future<Long> optionalAcquisition = executor.submit(() ->
readAhead.reserveOptional(400));
+ Assertions.assertTrue(grantReady.await(10, TimeUnit.SECONDS));
+ Assertions.assertEquals(400, pool.executionMemoryUsed());
+ // The shared grant is visible, but the owner has not yet published its
local credit.
+ peerAcquisition = executor.submit(() -> ordinary.use(700));
+ Assertions.assertTrue(callbackStarted.await(10, TimeUnit.SECONDS));
+ Assertions.assertFalse(peerAcquisition.isDone());
+ allowPublication.countDown();
+ Assertions.assertEquals(400L, optionalAcquisition.get(10,
TimeUnit.SECONDS).longValue());
+ peerAcquisition.get(10, TimeUnit.SECONDS);
+ Assertions.assertEquals(700, ordinary.getUsed());
+ Assertions.assertEquals(0, readAhead.optionalBytes.get());
+ registration.close();
+ ordinary.free(700);
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ Assertions.assertEquals(0, peer.cleanUpAllAllocatedMemory());
+ } finally {
+ allowPublication.countDown();
+ if (peerAcquisition != null) peerAcquisition.cancel(true);
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void incompleteOptionalDrainKeepsRegistrationAndCredit() throws
Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ AutoCloseable registration =
owner.registerOptionalMemoryReclaimer(readAhead, () -> { });
+
+ Assertions.assertEquals(400, readAhead.reserveOptional(400));
+ Assertions.assertThrows(AssertionError.class, registration::close);
+ Assertions.assertEquals(400, owner.getMemoryConsumptionForThisTask());
+ Assertions.assertEquals(0, readAhead.reserveOptional(1));
+ // Task cleanup must fail visibly rather than silently drop the unread
native reservation.
+ Assertions.assertThrows(AssertionError.class,
owner::cleanUpAllAllocatedMemory);
+ Assertions.assertEquals(400, owner.getMemoryConsumptionForThisTask());
+ Assertions.assertEquals(400, readAhead.optionalBytes.get());
+ readAhead.reclaimOptional();
+ registration.close();
+ Assertions.assertEquals(0, owner.cleanUpAllAllocatedMemory());
+ }
+
+ @Test
+ public void cleanupClosesOptionalAdmissionBeforeDrainingOwner() throws
Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
+ TaskMemoryManager owner = new TaskMemoryManager(pool, 1);
+ OptionalMemoryConsumer readAhead = new OptionalMemoryConsumer(owner);
+ CountDownLatch callbackStarted = new CountDownLatch(1);
+ CountDownLatch allowRelease = new CountDownLatch(1);
+ AutoCloseable registration =
owner.registerOptionalMemoryReclaimer(readAhead, () -> {
+ Assertions.assertFalse(Thread.holdsLock(owner));
+ Assertions.assertFalse(Thread.holdsLock(pool));
+ callbackStarted.countDown();
+ try {
+ if (!allowRelease.await(5, TimeUnit.SECONDS)) {
+ // Invariants must propagate even when JVM assertions are disabled.
+ // checkstyle.off: RegexpSinglelineJava
+ throw new AssertionError("timed out before optional cleanup could
release memory");
+ // checkstyle.on: RegexpSinglelineJava
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // Invariants must propagate even when JVM assertions are disabled.
+ // checkstyle.off: RegexpSinglelineJava
+ throw new AssertionError(e);
+ // checkstyle.on: RegexpSinglelineJava
+ }
+ readAhead.reclaimOptional();
+ });
+ Assertions.assertEquals(400, readAhead.reserveOptional(400));
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ Future<Long> cleanup = executor.submit(owner::cleanUpAllAllocatedMemory);
+ Assertions.assertTrue(callbackStarted.await(5, TimeUnit.SECONDS));
+ Assertions.assertEquals(400, pool.executionMemoryUsed());
+ // This call must fail immediately rather than wait behind the pending
callback.
+ Assertions.assertEquals(0, owner.tryAcquireOptionalExecutionMemory(1,
readAhead));
+ allowRelease.countDown();
+ Assertions.assertEquals(0L, cleanup.get(5,
TimeUnit.SECONDS).longValue());
+ Assertions.assertEquals(0, pool.executionMemoryUsed());
+ Assertions.assertNull(owner.registerOptionalMemoryReclaimer(readAhead,
() -> { }));
+ Assertions.assertEquals(0, readAhead.reserveOptional(1));
+ registration.close();
+ } finally {
+ allowRelease.countDown();
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void failedOwnerCleanupRetainsItsOptionalCharge() throws Exception {
+ UnifiedMemoryManager pool = optionalTestPool(true);
Review Comment:
**Nit (P3):** This failure test uses only one optional owner, so it cannot
detect regressions in the loop's two multi-owner guarantees. Please add a
failing owner followed by a healthy owner and verify the later reservation is
drained, plus a case with two nonfatal failures that checks the second is
suppressed onto the first.
##########
core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala:
##########
@@ -134,21 +197,129 @@ private[spark] class UnifiedMemoryManager(
override private[memory] def acquireExecutionMemory(
numBytes: Long,
taskAttemptId: Long,
- memoryMode: MemoryMode): Long = synchronized {
+ memoryMode: MemoryMode): Long = {
+ if (!optionalMemoryEnabled) {
+ return synchronized {
+ acquireExecutionMemoryInternal(numBytes, taskAttemptId, memoryMode)
+ }
+ }
+ require(!Thread.holdsLock(this),
+ "ordinary execution admission cannot start under the memory-manager
monitor")
+ withReclamation(memoryMode) {
+ tryAcquireExecutionMemoryWithoutReclamation(numBytes, taskAttemptId,
memoryMode)
+ } {
+ acquireExecutionMemoryInternal(numBytes, taskAttemptId, memoryMode)
+ }
+ }
+
+ /**
+ * Share the gate, atomic preflight, outside-monitor drain and retry for
both allocation paths.
+ * Nested storage calls reuse an outer boundary. Release-only markers never
trigger a drain.
+ */
+ private def withReclamation[T](memoryMode: MemoryMode)(
+ tryWithoutReclamation: => Option[T])(allocate: => T): T = {
+ if (!optionalMemoryEnabled) return synchronized { allocate }
+ val gate = acquireOptionalAdmissionReadLock()
+ try {
+ if (Thread.holdsLock(this)) return allocate
+ synchronized {
+ if (!hasOptionalExecutionMemory(memoryMode)) return allocate
+ val immediate = tryWithoutReclamation
+ if (immediate.isDefined) return immediate.get
+ }
+ var result: Option[T] = None
+ reclaimOptionalMemory(memoryMode, () => synchronized {
+ result = tryWithoutReclamation
+ result.isDefined
+ })
+ result.getOrElse(synchronized { allocate })
+ } finally {
+ gate.unlock()
+ }
+ }
+
+ /** Grant immediately when reclamation cannot improve the result, preserving
optional buffers. */
+ private def tryAcquireExecutionMemoryWithoutReclamation(
+ numBytes: Long,
+ taskAttemptId: Long,
+ memoryMode: MemoryMode): Option[Long] = {
+ assert(Thread.holdsLock(this))
+ val unmanaged = getUnmanagedMemoryUsed(memoryMode)
+ if (canAcquireExecutionMemory(numBytes, taskAttemptId, memoryMode,
unmanaged)) {
+ return Some(acquireExecutionMemoryInternal(
+ numBytes, taskAttemptId, memoryMode, Some(unmanaged)))
+ }
+ val (executionPool, storagePool, regionSize, maxMemory) =
poolsFor(memoryMode)
+ val storageProtected = storagePool.memoryUsed <= regionSize
+ // Whole-block eviction can raise the share ceiling. Only bypass it when
releasing optional
+ // bytes would make the original request fit without eviction, or storage
is protected.
+ val needsEviction = !storageProtected &&
+ numBytes > maxMemory - storagePool.memoryUsed -
executionPool.ordinaryMemoryUsed
+ val maxExecution = if (needsEviction) math.max(0L, maxMemory - unmanaged)
+ else maxExecutionMemory(memoryMode, unmanaged)
+ val headroom = executionPool.absoluteMemoryHeadroom(taskAttemptId,
maxExecution)
+ val available = availableExecutionMemory(memoryMode, unmanaged,
borrowStorage = true)
+ val poolAfterGrowth = executionPool.poolSize +
+ math.min(math.max(0L, numBytes - executionPool.memoryFree),
storagePool.memoryFree)
+ if (headroom >= 0L && headroom < numBytes && headroom <= available &&
!needsEviction &&
+ executionPool.canGrantWithoutWaiting(
+ taskAttemptId, numBytes, headroom, poolAfterGrowth)) {
+ // Keep normal protected-storage growth. With borrowed storage, avoid
evicting a block
+ // for bytes that cannot be granted even after every optional owner has
released.
+ val request = if (storageProtected) numBytes else headroom
+ if (request == 0L) Some(0L) else Some(acquireExecutionMemoryInternal(
+ request, taskAttemptId, memoryMode, Some(unmanaged)))
+ } else {
+ None
+ }
+ }
+
+ /**
+ * Test full ordinary admission against current free capacity and the
prospective task share.
+ * Called under this monitor before any task registration, pool growth,
eviction, or wait.
+ * Free storage can be borrowed without a drain; occupied storage is not
optimistically evicted.
+ */
+ private def canAcquireExecutionMemory(
+ numBytes: Long,
+ taskAttemptId: Long,
+ memoryMode: MemoryMode,
+ unmanagedMemory: Long): Boolean = {
+ assert(Thread.holdsLock(this))
+ val executionPool = executionPoolFor(memoryMode)
+ executionPool.canAcquireMemory(numBytes, taskAttemptId,
+ maxExecutionMemory(memoryMode, unmanagedMemory),
+ availableExecutionMemory(memoryMode, unmanagedMemory, borrowStorage =
true))
+ }
+
+ /**
+ * Optional bytes count against physical capacity after unmanaged usage,
independently of the
+ * ordinary share ceiling. Without optional credit, preserve the existing
allocator's policy.
+ */
+ private def availableExecutionMemory(
+ memoryMode: MemoryMode,
+ unmanaged: Long,
+ borrowStorage: Boolean): Long = {
+ val (executionPool, storagePool, _, maxMemory) = poolsFor(memoryMode)
+ val free = executionPool.memoryFree + (if (borrowStorage)
storagePool.memoryFree else 0L)
Review Comment:
**Non-blocking (P2):** With optional bytes present, this hot path traverses
all active-task accounting several times under the shared monitor: `memoryFree`
calls `memoryUsed`, this method calls `memoryUsed` again, the share-bound path
can call `ordinaryMemoryUsed`, and the capacity callback repeats the
calculation on wake-up. Please maintain the ordinary-byte and
optional-only-participant aggregates alongside the maps so these checks stay
O(1).
##########
core/src/main/java/org/apache/spark/memory/TaskMemoryManager.java:
##########
@@ -118,6 +124,86 @@ public class TaskMemoryManager {
@GuardedBy("this")
private final HashSet<MemoryConsumer> consumers;
+ private final ReentrantLock optionalAdmissionLock = new ReentrantLock();
+
+ @GuardedBy("optionalAdmissionLock")
+ private boolean optionalAdmissionClosed;
+
+ @GuardedBy("optionalAdmissionLock")
+ private final Map<MemoryConsumer, OptionalMemoryReclaimerRegistration>
optionalReclaimers =
+ new IdentityHashMap<>();
+
+ // Diagnostics and typed releases cannot acquire the admission lock. A
closed registration is
+ // removed only after all of its owner's optional bytes have been released.
+ private final CopyOnWriteArrayList<OptionalMemoryReclaimerRegistration>
optionalRegistrations =
+ new CopyOnWriteArrayList<>();
+
+ /**
+ * A task-owned registration. Closing first drains its owner, then removes
the shared callback.
+ * A callback already captured by another allocator can run after close and
must be idempotent.
+ */
+ private final class OptionalMemoryReclaimerRegistration implements
AutoCloseable {
+ private final MemoryConsumer consumer;
+ private final Runnable reclaimer;
+ private final Runnable unregister;
+ private final AtomicLong optionalBytes = new AtomicLong();
+ private final AtomicBoolean admissionInProgress = new AtomicBoolean();
+ private final AtomicReference<Runnable> unregisterAfterReclaim = new
AtomicReference<>();
+
+ @GuardedBy("optionalAdmissionLock")
+ private boolean admissionClosed;
+
+ @GuardedBy("optionalAdmissionLock")
+ private boolean closed;
+
+ private OptionalMemoryReclaimerRegistration(MemoryConsumer consumer,
Runnable reclaimer) {
+ this.consumer = consumer;
+ this.reclaimer = reclaimer;
+ // The shared callback cannot capture this registration or take the
admission lock.
+ AtomicReference<Runnable> afterReclaim = unregisterAfterReclaim;
+ AtomicBoolean admitting = admissionInProgress;
+ AtomicLong credits = optionalBytes;
+ this.unregister = memoryManager.registerOptionalMemoryReclaimer(
+ taskAttemptId, consumer.getMode(), () -> {
+ // Read the flag first: a grant may precede credit publication, and
clearing the flag
+ // after publication makes the subsequent credit read see the
completed grant.
+ if (admitting.get() || credits.get() > 0L) {
+ reclaimer.run();
+ }
+ Runnable retire = afterReclaim.get();
+ if (retire != null && !admitting.get() && credits.get() == 0L)
retire.run();
+ });
+ }
+
+ @Override
+ public void close() {
+ optionalAdmissionLock.lock();
+ try {
+ if (!closed) {
+ admissionClosed = true;
+ // A failed or incomplete drain remains registered and charged for a
later retry.
+ reclaimer.run();
Review Comment:
**Non-blocking (P2):** If the callback releases its final optional
reservation and then throws a nonfatal error, this exits before
`unregister.run()` and before removing the local registration. Cleanup
propagates the error, while future shared reclamation skips this task because
its optional usage is now zero, so the deferred unregister can never run. The
zero-credit exceptional path should retire the registration while still
rethrowing the callback failure; positive-credit failures must remain
registered.
**Recommended change:** Retire a registration when its callback exits
exceptionally after reducing its exact optional credit to zero, while
preserving and rethrowing the callback failure.
**Why this works:** Capture callback failure, then under the registration's
existing synchronization check published admission and exact owner credit. If
fully drained, run unregister and remove the local registration exactly once
before rethrowing; if bytes remain, keep the current charged/retryable state.
**Scope:** Close the zero-credit exceptional lifecycle gap without
unregistering live buffers or swallowing cleanup failures.
**Compatibility:** Positive-credit failures remain visible, charged, and
available for a later retry.
**Risks:** Retiring before the final release is published could lose live
backing-memory ownership. The callback exception must remain the task-visible
failure.
**Constraints:** Incomplete or failed drains with positive credit stay
registered and charged. Retirement and local collection removal remain
idempotent under explicit-close and cleanup races.
**Success:** A release-then-throw callback leaves no global callback or
local consumer registration once its credit is zero. A callback that throws
with positive credit remains registered and retryable. The original callback
failure still propagates.
##########
core/src/main/scala/org/apache/spark/storage/memory/MemoryStore.scala:
##########
@@ -414,20 +440,52 @@ 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)
+ }
+ }
+
+ private def disposeUnstoredEntry(entry: MemoryEntry[_]): Unit = entry match {
+ case SerializedMemoryEntry(buffer, _, _) => buffer.dispose()
+ // Deserialized values still belong to the caller until published in the
store.
+ case _: DeserializedMemoryEntry[_] => ()
+ }
+
+ 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 Spark's temporary serialized buffers after a failed unroll. */
+ private def discardUnstoredValues(valuesHolder: ValuesHolder[_]): Unit =
valuesHolder match {
+ // Deserialized values remain caller-owned when a put fails.
+ case _: DeserializedValuesHolder[_] => ()
+ case holder: SerializedValuesHolder[_] =>
Review Comment:
**Nit (P3):** The added exceptional-put regression uses
`putIteratorAsValues`, so it selects the deserialized branch and never
exercises this serialized cleanup. Please add a serialized put whose unroll
reservation throws and verify that MemoryStore closes the stream without
further flushing and disposes the temporary chunk buffers; the isolated
`dispose()` test cannot catch a missing handoff here.
--
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]