This is an automated email from the ASF dual-hosted git repository.
sunchao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new a6e760750bca [SPARK-57467][SCHEDULER] Reuse identical resource
profiles when available
a6e760750bca is described below
commit a6e760750bca8dce0a2ee5814437d6574dc2ca36
Author: PA Savalle <[email protected]>
AuthorDate: Sat Aug 29 09:25:55 2026 -0700
[SPARK-57467][SCHEDULER] Reuse identical resource profiles when available
### What changes were proposed in this pull request?
When registering a resource profile, check if one with the same requests
already exists. If so, return it, so that the same executors can be reused.
`ResourceProfileManager` has a method `getEquivalentProfile`, but it is
only called in `DAGScheduler.mergeResourceProfilesForStage` when
`stageResourceProfiles.size > 1`. Even there, it seems it may be prone to a
race condition since it does not acquire the lock for both steps (checking for
a profile, adding one if needed).
### Why are the changes needed?
Currently, every time a profile is registered, new executors will need to
spin up -- even if the resource requests are the same. This is especially
problematic with Spark Connect, where different isolated sessions may try and
register the same profile, but all end up with isolated executors.
We could also consider putting this behavior behind a configuration flag,
if it is desirable to retain the previous behavior by default.
### Does this PR introduce _any_ user-facing change?
Previously, equivalent resource profiles would get different profile IDs,
and separate executors.
With this change, equivalent resource profiles will get the same ID, and
share executors.
### How was this patch tested?
Added tests
### Was this patch authored or co-authored using generative AI tooling?
Yes, modified initial changes provided by Claude.
`Generated-by: 2.1.159 (Claude Code)`
Closes #56516 from psavalle/pa/resource-profile-get-or-add.
Authored-by: PA Savalle <[email protected]>
Signed-off-by: Chao Sun <[email protected]>
---
core/src/main/scala/org/apache/spark/rdd/RDD.scala | 5 +-
.../spark/resource/ResourceProfileManager.scala | 115 ++++++++++++-----
.../org/apache/spark/scheduler/DAGScheduler.scala | 21 +--
.../resource/ResourceProfileManagerSuite.scala | 143 +++++++++++++++++----
.../apache/spark/scheduler/DAGSchedulerSuite.scala | 21 +++
.../sql/connect/planner/SparkConnectPlanner.scala | 9 +-
6 files changed, 239 insertions(+), 75 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/rdd/RDD.scala
b/core/src/main/scala/org/apache/spark/rdd/RDD.scala
index e4cbbe6302af..8b3ecb943518 100644
--- a/core/src/main/scala/org/apache/spark/rdd/RDD.scala
+++ b/core/src/main/scala/org/apache/spark/rdd/RDD.scala
@@ -1934,8 +1934,9 @@ abstract class RDD[T: ClassTag](
@Experimental
@Since("3.1.0")
def withResources(rp: ResourceProfile): this.type = {
- resourceProfile = Option(rp)
- sc.resourceProfileManager.addResourceProfile(resourceProfile.get)
+ // Reuse an already-registered profile with equal resources if one exists
so that equivalent
+ // profiles share a single id, allowing executors to be reused instead of
newly allocated.
+ resourceProfile =
Option(sc.resourceProfileManager.getOrAddEquivalentProfile(rp))
this
}
diff --git
a/core/src/main/scala/org/apache/spark/resource/ResourceProfileManager.scala
b/core/src/main/scala/org/apache/spark/resource/ResourceProfileManager.scala
index fed5129bcf9d..b4f1a29d7c14 100644
--- a/core/src/main/scala/org/apache/spark/resource/ResourceProfileManager.scala
+++ b/core/src/main/scala/org/apache/spark/resource/ResourceProfileManager.scala
@@ -125,41 +125,93 @@ private[spark] class ResourceProfileManager(sparkConf:
SparkConf,
taskRpId == executorRpId || (!dynamicEnabled &&
taskRp.isInstanceOf[TaskResourceProfile])
}
+ /**
+ * Register the given ResourceProfile unless its id is already registered.
Unlike
+ * [[getOrAddEquivalentProfile]], this method does not reuse a profile with
equal resources.
+ */
def addResourceProfile(rp: ResourceProfile): Unit = {
isSupported(rp)
- // Validate before inserting so a malformed profile never enters the
registry, where it
- // would stay visible to the whole application. The cpus amount is checked
under the map
- // key -- the identity scheduling uses -- with the same rule as the
request entry points:
- // construction stays lenient (deserialization of persisted data must
accept anything an
- // earlier release wrote), so registration is the enforcement point for
every live path,
- // including raw profile construction and Spark Connect.
- rp.taskResources.get(ResourceProfile.CPUS).foreach { treq =>
- require(!treq.amount.isNaN && !treq.amount.isInfinity &&
-
CpuAmount.isInRange(CpuAmount.normalize(BigDecimal(treq.amount.toString))),
- s"The cpus amount ${treq.amount} must be at least 1e-9 and at most
${Int.MaxValue}.")
+ registerProfileIfAbsent(rp, findProfileById)
+ }
+
+ /**
+ * Get the registered ResourceProfile whose resources are equal to the given
one, registering
+ * the given profile first if no equivalent one exists yet.
+ */
+ def getOrAddEquivalentProfile(rp: ResourceProfile): ResourceProfile = {
+ isSupported(rp)
+ registerProfileIfAbsent(rp, findEquivalentOrSameIdProfile)
+ }
+
+ private def registerProfileIfAbsent(
+ rp: ResourceProfile,
+ findRegistered: ResourceProfile => Option[ResourceProfile]):
ResourceProfile = {
+ val existingProfile = {
+ readLock.lock()
+ try {
+ findRegistered(rp)
+ } finally {
+ readLock.unlock()
+ }
}
- // Force the computation of maxTasks and limitingResource now so we don't
have cost later;
- // doing it before the insert also surfaces any other malformed shape
(e.g. a task
- // resource without a matching executor resource) before registration
instead of after.
- // The result is cached in the profile, so re-adding an existing profile
stays cheap.
- rp.limitingResource(sparkConf)
- var putNewProfile = false
- writeLock.lock()
- try {
- if (!resourceProfileIdToResourceProfile.contains(rp.id)) {
- val prev = resourceProfileIdToResourceProfile.put(rp.id, rp)
- if (prev.isEmpty) putNewProfile = true
+ existingProfile.getOrElse {
+ // Validate before inserting so a malformed profile never enters the
registry, where it
+ // would stay visible to the whole application. The cpus amount is
checked under the map
+ // key -- the identity scheduling uses -- with the same rule as the
request entry points:
+ // construction stays lenient (deserialization of persisted data must
accept anything an
+ // earlier release wrote), so registration is the enforcement point for
every live path,
+ // including raw profile construction and Spark Connect.
+ rp.taskResources.get(ResourceProfile.CPUS).foreach { treq =>
+ require(!treq.amount.isNaN && !treq.amount.isInfinity &&
+
CpuAmount.isInRange(CpuAmount.normalize(BigDecimal(treq.amount.toString))),
+ s"The cpus amount ${treq.amount} must be at least 1e-9 and at most
${Int.MaxValue}.")
}
- } finally {
- writeLock.unlock()
+ // Force the computation of maxTasks and limitingResource now so we
don't have cost later;
+ // doing it before the insert also surfaces any other malformed shape
(e.g. a task
+ // resource without a matching executor resource) before registration
instead of after.
+ rp.limitingResource(sparkConf)
+ var addedProfile: Option[ResourceProfile] = None
+ val resolvedProfile = {
+ writeLock.lock()
+ try {
+ // Another thread may have added this profile after the read-lock
fast path, so check
+ // again while holding the write lock.
+ findRegistered(rp).getOrElse {
+ resourceProfileIdToResourceProfile.put(rp.id, rp)
+ addedProfile = Some(rp)
+ rp
+ }
+ } finally {
+ writeLock.unlock()
+ }
+ }
+ // do this outside the write lock only when we add a new profile
+ addedProfile.foreach(onProfileAdded)
+ resolvedProfile
}
- // do this outside the write lock only when we add a new profile
- if (putNewProfile) {
- logInfo(log"Added ResourceProfile id: ${MDC(LogKeys.RESOURCE_PROFILE_ID,
rp.id)}")
- listenerBus.post(SparkListenerResourceProfileAdded(rp))
+ }
+
+ private def findProfileById(rp: ResourceProfile): Option[ResourceProfile] = {
+ resourceProfileIdToResourceProfile.get(rp.id)
+ }
+
+ private def findEquivalentOrSameIdProfile(rp: ResourceProfile):
Option[ResourceProfile] = {
+
resourceProfileIdToResourceProfile.get(rp.id).orElse(findEquivalentProfile(rp))
+ }
+
+ private def findEquivalentProfile(rp: ResourceProfile):
Option[ResourceProfile] = {
+ resourceProfileIdToResourceProfile.values.find { existing =>
+ val sameDefaultStatus =
+ (existing eq defaultProfile) == (rp eq defaultProfile)
+ sameDefaultStatus && existing.resourcesEqual(rp)
}
}
+ private def onProfileAdded(rp: ResourceProfile): Unit = {
+ logInfo(log"Added ResourceProfile id: ${MDC(LogKeys.RESOURCE_PROFILE_ID,
rp.id)}")
+ listenerBus.post(SparkListenerResourceProfileAdded(rp))
+ }
+
/*
* Gets the ResourceProfile associated with the id, if a profile doesn't
exist
* it returns the default ResourceProfile created from the application level
configs.
@@ -176,15 +228,14 @@ private[spark] class ResourceProfileManager(sparkConf:
SparkConf,
}
/*
- * If the ResourceProfile passed in is equivalent to an existing one, return
the
- * existing one, other return None
+ * If the ResourceProfile passed in is equivalent to an existing one, return
the existing one.
+ * The actual default profile is kept distinct because cluster managers give
its id special
+ * semantics that do not apply to explicit profiles with otherwise equal
resources.
*/
def getEquivalentProfile(rp: ResourceProfile): Option[ResourceProfile] = {
readLock.lock()
try {
- resourceProfileIdToResourceProfile.find { case (_, rpEntry) =>
- rpEntry.resourcesEqual(rp)
- }.map(_._2)
+ findEquivalentProfile(rp)
} finally {
readLock.unlock()
}
diff --git a/core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala
b/core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala
index fa2ab84c64e7..67b7823091e2 100644
--- a/core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala
+++ b/core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala
@@ -889,16 +889,17 @@ private[spark] class DAGScheduler(
val startResourceProfile = stageResourceProfiles.head
val mergedProfile = stageResourceProfiles.drop(1)
.foldLeft(startResourceProfile)((a, b) => mergeResourceProfiles(a,
b))
- // compared merged profile with existing ones so we don't add it over
and over again
- // if the user runs the same operation multiple times
- val resProfile =
sc.resourceProfileManager.getEquivalentProfile(mergedProfile)
- resProfile match {
- case Some(existingRp) => existingRp
- case None =>
- // this ResourceProfile could be different if it was merged so we
have to add it to
- // our ResourceProfileManager
- sc.resourceProfileManager.addResourceProfile(mergedProfile)
- mergedProfile
+ val defaultProfile = sc.resourceProfileManager.defaultResourceProfile
+ if (stageResourceProfiles.exists(_ eq defaultProfile) &&
+ defaultProfile.resourcesEqual(mergedProfile)) {
+ // The default profile id has special meaning to cluster managers.
Preserve it when the
+ // actual default was an input and the merge did not add any
requirements.
+ defaultProfile
+ } else {
+ // Compare the merged profile with existing ones so we don't add it
over and over again
+ // if the user runs the same operation multiple times. The merged
ResourceProfile could
+ // be different from any existing one, in which case it is
registered here.
+ sc.resourceProfileManager.getOrAddEquivalentProfile(mergedProfile)
}
} else {
throw new IllegalArgumentException("Multiple ResourceProfiles
specified in the RDDs for " +
diff --git
a/core/src/test/scala/org/apache/spark/resource/ResourceProfileManagerSuite.scala
b/core/src/test/scala/org/apache/spark/resource/ResourceProfileManagerSuite.scala
index ce57f053a3e7..799e04f2c939 100644
---
a/core/src/test/scala/org/apache/spark/resource/ResourceProfileManagerSuite.scala
+++
b/core/src/test/scala/org/apache/spark/resource/ResourceProfileManagerSuite.scala
@@ -17,12 +17,17 @@
package org.apache.spark.resource
+import org.mockito.ArgumentMatchers.isA
+import org.mockito.Mockito.{never, reset, times, verify}
+import org.scalatestplus.mockito.MockitoSugar
+
import org.apache.spark.{SparkConf, SparkException, SparkFunSuite}
import org.apache.spark.internal.config._
import org.apache.spark.internal.config.Tests._
-import org.apache.spark.scheduler.LiveListenerBus
+import org.apache.spark.scheduler.{LiveListenerBus,
SparkListenerResourceProfileAdded}
+import org.apache.spark.util.ThreadUtils
-class ResourceProfileManagerSuite extends SparkFunSuite {
+class ResourceProfileManagerSuite extends SparkFunSuite with MockitoSugar {
override def beforeAll(): Unit = {
try {
@@ -251,36 +256,118 @@ class ResourceProfileManagerSuite extends SparkFunSuite {
" with dynamic allocation"))
}
- test("ResourceProfileManager has equivalent profile") {
+ test("getOrAddEquivalentProfile reuses an equivalent profile") {
val conf = new SparkConf().set(EXECUTOR_CORES, 4)
- val rpmanager = new ResourceProfileManager(conf, listenerBus)
- var rpAlreadyExist: Option[ResourceProfile] = None
- val checkId = 500
- for (i <- 1 to 1000) {
+ val mockListenerBus = mock[LiveListenerBus]
+ val rpmanager = new ResourceProfileManager(conf, mockListenerBus)
+ reset(mockListenerBus)
+
+ def buildProfile(cores: Int): ResourceProfile = {
val rprofBuilder = new ResourceProfileBuilder()
val ereqs = new ExecutorResourceRequests()
- ereqs.cores(i).memory("4g").memoryOverhead("2000m")
+ ereqs.cores(cores).memory("4g").memoryOverhead("2000m")
val treqs = new TaskResourceRequests()
- treqs.cpus(i)
- rprofBuilder.require(ereqs).require(treqs)
- val rprof = rprofBuilder.build()
- rpmanager.addResourceProfile(rprof)
- if (i == checkId) rpAlreadyExist = Some(rprof)
+ treqs.cpus(1)
+ rprofBuilder.require(ereqs).require(treqs).build()
}
- val rpNotMatch = new ResourceProfileBuilder().build()
- assert(rpmanager.getEquivalentProfile(rpNotMatch).isEmpty,
- s"resourceProfile should not have existed")
-
- val rprofBuilder = new ResourceProfileBuilder()
- val ereqs = new ExecutorResourceRequests()
- ereqs.cores(checkId).memory("4g").memoryOverhead("2000m")
- val treqs = new TaskResourceRequests()
- treqs.cpus(checkId)
- rprofBuilder.require(ereqs).require(treqs)
- val rpShouldMatch = rprofBuilder.build()
-
- val equivProf = rpmanager.getEquivalentProfile(rpShouldMatch)
- assert(equivProf.nonEmpty)
- assert(equivProf.get.id == rpAlreadyExist.get.id, s"resourceProfile should
have existed")
+
+ val first = buildProfile(8)
+ val registered = rpmanager.getOrAddEquivalentProfile(first)
+ // A brand-new profile is registered and returned as-is.
+ assert(registered.id == first.id)
+
+ // A distinct profile object with equal resources resolves to the
already-registered one,
+ // so they share a single id and can therefore reuse the same executors.
+ val equivalent = buildProfile(8)
+ assert(equivalent.id != first.id, "the new profile object should have a
different id")
+ val resolved = rpmanager.getOrAddEquivalentProfile(equivalent)
+ assert(resolved.id == first.id, "equivalent profile should resolve to the
existing id")
+
+ // A profile with different resources is registered under its own id.
+ val different = buildProfile(16)
+ val resolvedDifferent = rpmanager.getOrAddEquivalentProfile(different)
+ assert(resolvedDifferent.id == different.id)
+
+ verify(mockListenerBus,
times(2)).post(isA(classOf[SparkListenerResourceProfileAdded]))
+ }
+
+ test("getOrAddEquivalentProfile atomically registers equivalent profiles") {
+ val conf = new SparkConf().set(EXECUTOR_CORES, 4)
+ val mockListenerBus = mock[LiveListenerBus]
+ val rpmanager = new ResourceProfileManager(conf, mockListenerBus)
+ reset(mockListenerBus)
+
+ val profiles = (1 to 16).map { _ =>
+ val ereqs = new ExecutorResourceRequests().cores(8)
+ val treqs = new TaskResourceRequests().cpus(1)
+ new ResourceProfileBuilder().require(ereqs).require(treqs).build()
+ }
+ val resolved = ThreadUtils.parmap(profiles, "register-resource-profiles",
profiles.size) {
+ rpmanager.getOrAddEquivalentProfile
+ }
+
+ assert(resolved.map(_.id).toSet.size === 1)
+ verify(mockListenerBus,
times(1)).post(isA(classOf[SparkListenerResourceProfileAdded]))
+ }
+
+ test("getOrAddEquivalentProfile validates before registering") {
+ val conf = new SparkConf().setMaster("yarn").set(EXECUTOR_CORES, 4)
+ .set(DYN_ALLOCATION_ENABLED, true)
+ val mockListenerBus = mock[LiveListenerBus]
+ val rpmanager = new ResourceProfileManager(conf, mockListenerBus)
+ reset(mockListenerBus)
+
+ def invalidProfile(): ResourceProfile = {
+ val ereqs = new ExecutorResourceRequests().resource("gpu", 1,
"discoveryScript")
+ val treqs = new TaskResourceRequests().resource("gpu", 2)
+ new ResourceProfileBuilder().require(ereqs).require(treqs).build()
+ }
+
+ val first = invalidProfile()
+ val equivalent = invalidProfile()
+ Seq(first, equivalent).foreach { profile =>
+ val error = intercept[SparkException] {
+ rpmanager.getOrAddEquivalentProfile(profile)
+ }
+ assert(error.getMessage.contains("needs to be >= the task resource
request amount"))
+ intercept[SparkException] {
+ rpmanager.resourceProfileFromId(profile.id)
+ }
+ }
+ assert(rpmanager.getEquivalentProfile(first).isEmpty)
+ verify(mockListenerBus,
never()).post(isA(classOf[SparkListenerResourceProfileAdded]))
+ }
+
+ test("getOrAddEquivalentProfile keeps explicit profiles distinct from the
default") {
+ Seq("yarn", "k8s://test").foreach { master =>
+ ResourceProfile.clearDefaultProfile()
+ val conf = new SparkConf().setMaster(master).set(EXECUTOR_CORES, 4)
+ .set(DYN_ALLOCATION_ENABLED, true)
+ val rpmanager = new ResourceProfileManager(conf, listenerBus)
+ val defaultProfile = rpmanager.defaultResourceProfile
+ val explicitProfile = new ResourceProfile(
+ defaultProfile.executorResources, defaultProfile.taskResources)
+
+ val resolved = rpmanager.getOrAddEquivalentProfile(explicitProfile)
+ assert(resolved eq explicitProfile)
+ assert(resolved.id !== ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID)
+
assert(rpmanager.getEquivalentProfile(explicitProfile).contains(explicitProfile))
+ }
+ }
+
+ test("getOrAddEquivalentProfile preserves an existing id mapping") {
+ val conf = new SparkConf().set(EXECUTOR_CORES, 4)
+ val rpmanager = new ResourceProfileManager(conf, listenerBus)
+ val defaultProfile = rpmanager.defaultResourceProfile
+ val collidingProfile = new ResourceProfileBuilder()
+ .require(new ExecutorResourceRequests().cores(8))
+ .require(new TaskResourceRequests().cpus(1))
+ .build()
+ collidingProfile.setToDefaultProfile()
+
+ val resolved = rpmanager.getOrAddEquivalentProfile(collidingProfile)
+ assert(resolved eq defaultProfile)
+
assert(rpmanager.resourceProfileFromId(ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID)
eq
+ defaultProfile)
}
}
diff --git
a/core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala
b/core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala
index a44ed77b6511..518347b1b090 100644
--- a/core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala
+++ b/core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala
@@ -4843,6 +4843,27 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
assert(mergedRp.getExecutorCores.get == 4)
}
+ test("merge of default and no-op task-only resource profiles uses default
profile") {
+ conf.set(config.RESOURCE_PROFILE_MERGE_CONFLICTS.key, "true")
+ conf.set(config.DYN_ALLOCATION_ENABLED, false)
+
+ val defaultProfile = sc.resourceProfileManager.defaultResourceProfile
+ val taskOnlyProfile = new ResourceProfileBuilder()
+ .require(new TaskResourceRequests().cpus(1))
+ .build()
+ val rdd = sc.parallelize(1 to 10, 2)
+ .withResources(defaultProfile)
+ .map(x => (x, x))
+ .withResources(taskOnlyProfile)
+
+ submit(rdd, Array(0, 1))
+
+ assert(taskSets.length === 1)
+ assert(taskSets.head.resourceProfileId ===
ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID)
+
assert(sc.resourceProfileManager.resourceProfileFromId(taskSets.head.resourceProfileId)
eq
+ defaultProfile)
+ }
+
test("test multiple resource profiles created from merging use same rp") {
conf.set(config.RESOURCE_PROFILE_MERGE_CONFLICTS.key, "true")
diff --git
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
index 78562e8ebf92..f0ca4154c154 100644
---
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
+++
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
@@ -3957,13 +3957,16 @@ class SparkConnectPlanner(
name -> new TaskResourceRequest(res.getResourceName, res.getAmount)
}.toMap
- // Create ResourceProfile add add it to ResourceProfileManager
- val profile = if (ereqs.isEmpty) {
+ // Create the ResourceProfile and register it, reusing an
already-registered profile with
+ // equal resources if one exists so that equivalent profiles share a
single id (and thus
+ // can reuse the same executors instead of triggering new allocations).
+ val newProfile = if (ereqs.isEmpty) {
new TaskResourceProfile(treqs)
} else {
new ResourceProfile(ereqs, treqs)
}
- session.sparkContext.resourceProfileManager.addResourceProfile(profile)
+ val profile =
+
session.sparkContext.resourceProfileManager.getOrAddEquivalentProfile(newProfile)
executeHolder.eventsManager.postFinished()
responseObserver.onNext(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]