sunchao commented on code in PR #58763:
URL: https://github.com/apache/spark/pull/58763#discussion_r4010944081
##########
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:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Changed nonfatal callback failures to log task/mode attribution and retain
the outstanding charge while continuing ordinary admission. Other owners can
still release enough capacity for the requester. Fatal errors and accounting
AssertionErrors propagate; allocation and lock-order failures are outside the
callback catch. Execution and real unroll tests cover both a successful grant
after another owner's failure and honest denial when capacity remains
unavailable.
##########
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:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Separated the admission marker from reclamation. Unroll now uses a
same-mode, capacity-aware boundary before the manager monitor; transfers,
remove and clear take only the marker. MemoryStore/BlockManager tests cover
ample-capacity puts, cross-mode isolation, real unroll pressure,
removal/clearing and transfer without discarding optional buffers.
##########
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:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Added a no-reclamation path for an immediately available share-bound partial
grant, including your 400 / (100 ordinary + 100 optional) example: the request
returns 100, then zero at the cap, without invoking the owner. Ordinary and
optional ledgers are now separate, so optional-only participants and the
requester's own optional bytes do not lower ordinary shares; their bytes still
count against physical capacity. The preflight remains conservative where
whole-block storage eviction can raise the grant.
##########
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:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Yes, retained deliberately as a conservative opt-in policy. The PR
description and gate documentation now state that a fairness/capacity waiter
denies optional admission executor-wide, including the other memory mode. A
regression places a thread in ExecutionMemoryPool's actual wait loop, verifies
same-mode and cross-mode denial, then verifies ordinary progress and optional
admission after release. The startup setting defaults false and bypasses the
gate when disabled; this change does not promise optional progress during a
sustained ordinary allocation stream.
##########
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:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Moved marker-before-monitor validation ahead of gate acquisition, outside
the callback-failure catch. Correctly marked nested storage operations remain
allowed; unmarked monitor-holding calls fail immediately. Added assertions for
invalid ordering and propagation of an accounting AssertionError, and updated
MemoryStore's class-level lock-order documentation.
##########
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 {
Review Comment:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Added typed optional-byte accounting per mode/task and exact owner
accounting in TaskMemoryManager. The zero-outstanding path avoids
snapshotting/draining; drained registrations skip owner callbacks, and
reclamation stops once admission can proceed. Two new consumer tests cover an
empty owner beside an active owner in the same task and the
grant-before-credit-publication race, so skipping an empty owner cannot lose a
newly admitted reservation. Optional admission can still occur between
independent ordinary operations; sustained-pressure prefetch backoff remains a
consumer scheduling concern.
##########
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()
Review Comment:
I measured the ordinary on-heap allocation paths with no optional owners
registered. The comparison uses the same JDK, dependency classpath, and harness
in separate JVMs, changing only the allocator classes and the candidate's
startup setting. Each operation acquires and releases 64 or 128 bytes; every
grant and final memory balance is checked.
Revisions:
- Before this PR: `5d388249c3cf28f5ba11cdd5275d1fe5ae9cc1c1`.
- Original PR: `9d965e4be4be060c7c99bd68569cc44d7616d7cd`.
- Updated PR: `79b3464d7889fcc66282e47804706178f6a2ec42`; tested with
`spark.memory.optional.enabled=false` and `true`.
Environment: Linux x86-64; AMD EPYC-Milan (32 available CPUs); OpenJDK
17.0.20.1+1; Scala 2.13.18; `-Xms512m -Xmx512m`. Three JVM forks per case with
variant order rotated across forks; three warm-up rounds and seven measured
rounds. The single-thread case uses 500,000 pairs per round; four-thread cases
use 50,000 pairs per worker. Timing covers the concurrent round, so four-thread
ns/pair expresses aggregate throughput rather than individual request latency.
Allocation counts come from the JVM's per-thread allocated-byte counters around
the worker loop; these are cumulative allocations, not retained heap size.
Median ns per acquire/release pair; brackets show the minimum–maximum across
all 21 measured rounds:
| Path / threads | Pre-PR | Original PR | Updated, disabled | Updated,
enabled |
|---|---:|---:|---:|---:|
| Execution / 1 | 205.3 [193.9–324.9] | 211.3 [196.0–732.8] | 206.9
[192.2–448.7] | 289.0 [243.5–593.4] |
| Execution / 4 | 514.7 [275.8–1251.5] | 1226.3 [757.6–2025.7] | 996.6
[549.7–1755.8] | 1440.0 [1053.7–1997.8] |
| Storage / 1 | 110.1 [68.9–590.6] | 89.3 [75.4–132.0] | 114.2 [72.9–116.3]
| 151.4 [110.3–199.3] |
| Storage / 4 | 335.6 [292.6–405.0] | 542.2 [430.8–1371.7] | 321.2
[261.7–894.1] | 666.0 [460.1–879.5] |
Median allocated bytes per pair:
| Path / threads | Pre-PR | Original PR | Updated, disabled | Updated,
enabled |
|---|---:|---:|---:|---:|
| Execution / 1 | 256.0 | 264.0 | 264.0 | 346.3 |
| Execution / 4 | 245.2 | 260.5 | 240.8 | 357.5 |
| Storage / 1 | 48.0 | 48.0 | 48.0 | 48.0 |
| Storage / 4 | 48.0 | 75.3 | 48.0 | 78.6 |
All 48 JVM runs passed grant, class-origin, and final-accounting checks. The
updated disabled execution path overlaps the controls. The enabled path has
measurable coordination/allocation cost; disabled storage was about 25 ns/pair
above the original PR in this run. Four-thread timing varied substantially, and
the pre-PR single-thread storage fork medians were 180.9, 73.8, and 84.0
ns/pair, so these data do not establish precise small differences or contention
speedups.
The startup setting `spark.memory.optional.enabled` defaults to `false`. The
disabled cases measure the ordinary path with that feature gate off; enabled
cases include its coordination cost even with no optional owners.
This is a bounded allocator microbenchmark, not a SQL query, storage
eviction, optional-owner contention, or I/O-performance result. It measures
ordinary allocations with ample free capacity and no reclaimers. The variants
share unchanged runtime dependencies; the pre-PR and original allocator
implementations are compiled unchanged into separate directories and placed
first on their JVM classpaths. The harness prints the origins of the three
allocator classes; the run script checks they match the requested variant.
<details>
<summary>Full standalone harness and reproduction commands</summary>
Save the following as `bench/NoOwnerAllocationBenchmark.java`:
```java
package org.apache.spark.memory;
import java.lang.management.ManagementFactory;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.spark.SparkConf;
import org.apache.spark.storage.RDDBlockId;
/** Isolated no-owner allocation probe. One operation is an acquire/release
pair. */
public final class NoOwnerAllocationBenchmark {
private static volatile long sink;
private static final com.sun.management.ThreadMXBean ALLOC =
(com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
public static void main(String[] args) throws Exception {
String label = args[0];
boolean enabled = Boolean.parseBoolean(args[1]);
String scenario = args[2];
int threads = Integer.parseInt(args[3]);
int iterations = Integer.parseInt(args[4]);
int warmups = Integer.parseInt(args[5]);
int measurements = Integer.parseInt(args[6]);
SparkConf conf = new
SparkConf(false).set("spark.memory.optional.enabled", "" + enabled);
UnifiedMemoryManager manager = new UnifiedMemoryManager(conf, 67108864L,
33554432L, threads);
if (ALLOC.isThreadAllocatedMemorySupported() &&
!ALLOC.isThreadAllocatedMemoryEnabled()) {
ALLOC.setThreadAllocatedMemoryEnabled(true);
}
System.out.println("ORIGIN " +
MemoryManager.class.getProtectionDomain().getCodeSource().getLocation());
System.out.println("ORIGIN " +
UnifiedMemoryManager.class.getProtectionDomain().getCodeSource().getLocation());
System.out.println("ORIGIN " +
ExecutionMemoryPool.class.getProtectionDomain().getCodeSource().getLocation());
ExecutorService executor = Executors.newFixedThreadPool(threads);
try {
for (int round = -warmups; round < measurements; round++) {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
AtomicReference<Throwable> failure = new AtomicReference<>();
AtomicLong allocated = new AtomicLong();
AtomicLong checksum = new AtomicLong();
for (int worker = 0; worker < threads; worker++) {
final long taskId = worker + 1;
executor.submit(() -> {
boolean signaled = false;
try {
RDDBlockId block = new RDDBlockId(1, (int) taskId);
if (scenario.equals("execution") &&
manager.acquireExecutionMemory(64L, taskId,
MemoryMode.ON_HEAP) != 64L) {
throw new AssertionError("anchor refused");
}
ready.countDown();
signaled = true;
start.await();
long threadId = Thread.currentThread().getId();
long before = ALLOC.getThreadAllocatedBytes(threadId);
long sum = 0L;
for (int i = 0; i < iterations; i++) {
long bytes = (i & 1) == 0 ? 64L : 128L;
if (scenario.equals("execution")) {
long got = manager.acquireExecutionMemory(bytes, taskId,
MemoryMode.ON_HEAP);
if (got != bytes) throw new AssertionError("partial
ordinary grant " + got);
manager.releaseExecutionMemory(got, taskId,
MemoryMode.ON_HEAP);
sum += got;
} else {
if (!manager.acquireStorageMemory(block, bytes,
MemoryMode.ON_HEAP)) {
throw new AssertionError("storage grant refused");
}
manager.releaseStorageMemory(bytes, MemoryMode.ON_HEAP);
sum += bytes;
}
}
allocated.addAndGet(ALLOC.getThreadAllocatedBytes(threadId) -
before);
checksum.addAndGet(sum);
if (scenario.equals("execution")) {
manager.releaseExecutionMemory(64L, taskId,
MemoryMode.ON_HEAP);
}
} catch (Throwable error) {
failure.compareAndSet(null, error);
} finally {
if (!signaled) ready.countDown();
done.countDown();
}
});
}
if (!ready.await(10L, TimeUnit.SECONDS)) throw new
AssertionError("worker startup timeout");
long begin = System.nanoTime();
start.countDown();
if (!done.await(30L, TimeUnit.SECONDS)) throw new
AssertionError("round timeout");
long elapsed = System.nanoTime() - begin;
if (failure.get() != null) throw new AssertionError("worker failed",
failure.get());
if (manager.onHeapExecutionMemoryUsed() != 0L ||
manager.onHeapStorageMemoryUsed() != 0L) {
throw new AssertionError("memory credit leaked");
}
sink = checksum.get();
if (round >= 0) {
long operations = (long) threads * iterations;
System.out.printf(java.util.Locale.ROOT,
"RESULT,%s,%s,%d,%d,%d,%d,%.3f,%.3f,%d%n", label, scenario,
threads, round,
operations, elapsed, (double) elapsed / operations,
(double) allocated.get() / operations, sink);
}
}
} finally {
executor.shutdownNow();
}
}
}
```
From the updated PR checkout, build its core test classpath once. This
recipe requires JDK 17 on `PATH`; run from the checkout root in a Unix shell.
```sh
mkdir -p bench/harness-classes bench/base-src bench/base-classes \
bench/original-src bench/original-classes bench/results
build/sbt 'core/Test/compile' 'export core/Test/fullClasspath' >
bench/classpath-export.log 2>&1
python3 - <<'PYCP'
from pathlib import Path
lines = Path('bench/classpath-export.log').read_text().splitlines()
cp = [line for line in lines if line.startswith('/') and '/core/target/' in
line and ':/' in line][-1]
Path('bench/candidate-classpath.txt').write_text(cp + '\n')
PYCP
for variant in base original; do
if [ "$variant" = base ]; then
revision=5d388249c3cf28f5ba11cdd5275d1fe5ae9cc1c1
else
revision=9d965e4be4be060c7c99bd68569cc44d7616d7cd
fi
for name in MemoryManager UnifiedMemoryManager ExecutionMemoryPool; do
git show
"$revision:core/src/main/scala/org/apache/spark/memory/$name.scala" >
"bench/$variant-src/$name.scala"
done
done
python3 - <<'PYBUILD'
from pathlib import Path
import subprocess
cp = Path('bench/candidate-classpath.txt').read_text().strip()
compiler = ':'.join(path for path in cp.split(':') if any(
key in path for key in ('/scala-compiler/', '/scala-library/',
'/scala-reflect/')))
for variant in ('base', 'original'):
subprocess.run(['java', '-Xmx2g', '-cp', compiler,
'scala.tools.nsc.Main',
'-classpath', cp, '-d', f'bench/{variant}-classes'] +
[f'bench/{variant}-src/{name}.scala' for name in
('MemoryManager', 'UnifiedMemoryManager',
'ExecutionMemoryPool')], check=True)
subprocess.run(['javac', '-cp', cp, '-d', 'bench/harness-classes',
'bench/NoOwnerAllocationBenchmark.java'], check=True)
PYBUILD
python3 - <<'PYRUN'
from pathlib import Path
import subprocess
cp = Path('bench/candidate-classpath.txt').read_text().strip()
variants = [('base', False), ('original', False),
('candidate_disabled', False), ('candidate_enabled', True)]
for fork in range(3):
order = variants[fork:] + variants[:fork]
for scenario in ('execution', 'storage'):
for threads in (1, 4):
for label, enabled in order:
prefix = ['bench/harness-classes']
if label in ('base', 'original'):
prefix.append(f'bench/{label}-classes')
classpath = ':'.join(prefix + [cp])
iterations = 500000 if threads == 1 else 50000
args = ['java', '-Xms512m', '-Xmx512m',
'--add-opens=java.base/sun.nio.ch=ALL-UNNAMED',
'-cp', classpath,
'org.apache.spark.memory.NoOwnerAllocationBenchmark',
label, str(enabled).lower(), scenario, str(threads),
str(iterations), '3', '7']
log =
Path(f'bench/results/{fork}-{scenario}-{threads}-{label}.log')
with log.open('w') as output:
subprocess.run(args, stdout=output,
stderr=subprocess.STDOUT,
timeout=120, check=True)
origins = [line for line in log.read_text().splitlines()
if line.startswith('ORIGIN ')]
expected = str(Path(f'bench/{label}-classes').resolve()) \
if label in ('base', 'original') else str(Path.cwd())
assert len(origins) == 3 and all(expected in line for line
in origins)
PYRUN
```
Each `RESULT` line reports variant, path, threads, round, pair count,
elapsed nanoseconds, ns/pair, allocated bytes/pair, and checksum. Compare
medians across the 21 measured rounds per case; retain individual fork results
to see JVM and host variation.
</details>
##########
core/src/main/scala/org/apache/spark/memory/UnifiedMemoryManager.scala:
##########
@@ -206,7 +320,65 @@ private[spark] class UnifiedMemoryManager(
override def acquireStorageMemory(
blockId: BlockId,
numBytes: Long,
- memoryMode: MemoryMode): Boolean = synchronized {
+ memoryMode: MemoryMode): Boolean = {
+ if (isStorageMemoryRequestTooLarge(numBytes, memoryMode)) {
+ return synchronized { acquireStorageMemoryInternal(blockId, numBytes,
memoryMode) }
+ }
+ val gate = optionalAdmissionGate.readLock()
Review Comment:
Updated in `79b3464d7889fcc66282e47804706178f6a2ec42`.
Factored gate/preflight/outside-monitor-reclaim/retry into one
withReclamation helper, with shared pool selection and execution-ceiling
helpers. Removed the unsupported entered-with-monitor execution path and added
the ordering assertion. Serialized failure disposal remains a small helper with
the same redirect-before-close contract as PartiallySerializedBlock.discard; it
owns pre-builder state rather than a constructed PartiallySerializedBlock.
--
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]