This is an automated email from the ASF dual-hosted git repository.
SteNicholas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/celeborn.git
The following commit(s) were added to refs/heads/main by this push:
new 759e7b547 [CELEBORN-2331] Parallelize batch open stream client creation
759e7b547 is described below
commit 759e7b547641139576f21ddecbf1db5b42f83b01
Author: Chao Sun <[email protected]>
AuthorDate: Tue May 26 10:21:44 2026 +0800
[CELEBORN-2331] Parallelize batch open stream client creation
## Why are the changes needed?
`CelebornShuffleReader` batches stream-open requests by worker, but it
previously created the data client for each worker serially before sending
those already-parallel batch requests. When a reducer reads from multiple
workers, connection setup for a slow or unavailable worker can delay useful
work against the remaining healthy workers.
Parallelizing this setup removes the worker-by-worker wait from the normal
path. Because this changes task-side connection scheduling, the optimization
also needs an operational fallback that restores the prior behavior without
requiring a code rollback.
## What changes were proposed in this PR?
The reader now first gathers pending stream-open locations by worker
address, then creates one data client per distinct worker concurrently using
the existing stream-creator pool. Once client setup completes, it sends the
existing `BATCH_OPEN_STREAM` requests only for workers with an available
client, allowing healthy workers to proceed even if another worker fails during
setup.
The client-creation phase preserves the prior retry behavior for later
locations on the same worker when an earlier client attempt fails. It also
handles task cancellation explicitly: if the waiting Spark task is interrupted,
it restores the interrupt status and cancels unfinished setup work; worker-side
interruption is propagated rather than treated as an ordinary retryable failure.
This optimization is controlled by
`celeborn.client.spark.batch.openStream.parallelClientCreation.enabled`, which
defaults to `true`. Setting it to `false` selects the original serial
client-creation and request-building flow, giving deployments a targeted
rollback switch if parallel connection setup causes unexpected operational
behavior.
## How was this PR tested?
- Unit tests for parallel client setup, failure/retry handling,
cancellation on interruption, and the new configuration default and override.
- Configuration documentation generation validation for the new client
setting.
- Spotless formatting validation.
Closes #3692 from sunchao/dev/chao/codex/port-pr72-to-oss-main.
Authored-by: Chao Sun <[email protected]>
Signed-off-by: SteNicholas <[email protected]>
---
.../shuffle/celeborn/CelebornShuffleReader.scala | 99 +++++++++++-
.../celeborn/CelebornShuffleReaderSuite.scala | 167 ++++++++++++++++++++-
.../org/apache/celeborn/common/CelebornConf.scala | 11 ++
.../apache/celeborn/common/CelebornConfSuite.scala | 9 ++
docs/configuration/client.md | 1 +
5 files changed, 285 insertions(+), 2 deletions(-)
diff --git
a/client-spark/spark-3/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReader.scala
b/client-spark/spark-3/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReader.scala
index 55e036155..155fc0886 100644
---
a/client-spark/spark-3/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReader.scala
+++
b/client-spark/spark-3/src/main/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReader.scala
@@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicReference
import java.util.function.BiFunction
import scala.collection.JavaConverters._
+import scala.collection.mutable
import com.google.common.annotations.VisibleForTesting
import org.apache.commons.lang3.tuple.Pair
@@ -205,6 +206,10 @@ class CelebornShuffleReader[K, C](
val partitionIdList = List.range(startPartition, endPartition).filter(p =>
fileGroups.partitionGroups.containsKey(p))
+ val parallelClientCreationEnabled =
conf.batchOpenStreamParallelClientCreationEnabled
+ val locationsByHostPort =
+ new mutable.LinkedHashMap[String, JArrayList[PartitionLocation]]()
+
def makeOpenStreamList(locations: JSet[PartitionLocation]): Unit = {
locations.asScala.foreach { location =>
partCnt += 1
@@ -241,6 +246,16 @@ class CelebornShuffleReader[K, C](
}
}
+ def groupOpenStreamLocations(locations: JSet[PartitionLocation]): Unit = {
+ locations.asScala.foreach { location =>
+ partCnt += 1
+ val hostPort = location.hostAndFetchPort
+ locationsByHostPort
+ .getOrElseUpdate(hostPort, new JArrayList[PartitionLocation]())
+ .add(location)
+ }
+ }
+
partitionIdList.foreach { partitionId =>
if (fileGroups.partitionGroups.containsKey(partitionId)) {
// CELEBORN-2032. For the first time of open stream and
@@ -272,7 +287,43 @@ class CelebornShuffleReader[K, C](
locations = filterLocations.asJava
}
partitionId2PartitionLocations.put(partitionId, locations)
- makeOpenStreamList(locations)
+ if (parallelClientCreationEnabled) {
+ groupOpenStreamLocations(locations)
+ } else {
+ makeOpenStreamList(locations)
+ }
+ }
+ }
+
+ if (parallelClientCreationEnabled) {
+ val clientsByHostPort = CelebornShuffleReader.createClientsInParallel(
+ locationsByHostPort.map { case (hostPort, locations) =>
+ (hostPort, locations.asScala.toSeq)
+ }.toSeq,
+ streamCreatorPool,
+ location =>
+ shuffleClient.getDataClientFactory().createClient(
+ location.getHost,
+ location.getFetchPort),
+ (hostPort, location, ex) => {
+ shuffleClient.excludeFailedFetchLocation(hostPort, ex)
+ logWarning(
+ s"Failed to create client for $shuffleKey-${location.getId} from
host: ${hostPort}. " +
+ s"Shuffle reader will try its replica if exists.")
+ })
+
+ clientsByHostPort.foreach { case (hostPort, client) =>
+ val locArr = locationsByHostPort(hostPort)
+ val pbOpenStreamList = PbOpenStreamList.newBuilder()
+ pbOpenStreamList.setShuffleKey(shuffleKey)
+ locArr.asScala.foreach { location =>
+ pbOpenStreamList.addFileName(location.getFileName)
+ .addStartIndex(startMapIndex)
+ .addEndIndex(endMapIndex)
+ pbOpenStreamList.addReadLocalShuffle(
+ localFetchEnabled && location.getHost.equals(localHostAddress))
+ }
+ workerRequestMap.put(hostPort, (client, locArr, pbOpenStreamList))
}
}
@@ -548,6 +599,52 @@ class CelebornShuffleReader[K, C](
object CelebornShuffleReader {
var streamCreatorPool: ThreadPoolExecutor = null
+
+ @VisibleForTesting
+ private[celeborn] def createClientsInParallel(
+ locationsByHostPort: Seq[(String, Seq[PartitionLocation])],
+ streamCreatorPool: ThreadPoolExecutor,
+ createClient: PartitionLocation => TransportClient,
+ onClientCreateFailure: (String, PartitionLocation, Exception) => Unit)
+ : Map[String, TransportClient] = {
+ val clientsByHostPort = JavaUtils.newConcurrentHashMap[String,
TransportClient]()
+ val futures = locationsByHostPort.map { case (hostPort, locations) =>
+ streamCreatorPool.submit(new Runnable {
+ override def run(): Unit = {
+ val locationsIterator = locations.iterator
+ var clientCreated = false
+ while (!clientCreated && locationsIterator.hasNext) {
+ val location = locationsIterator.next()
+ try {
+ clientsByHostPort.put(hostPort, createClient(location))
+ clientCreated = true
+ } catch {
+ case ex: InterruptedException =>
+ Thread.currentThread().interrupt()
+ throw ex
+ case ex: Exception =>
+ onClientCreateFailure(hostPort, location, ex)
+ }
+ }
+ }
+ })
+ }
+ var waitCompleted = false
+ try {
+ futures.foreach(_.get())
+ waitCompleted = true
+ } catch {
+ case ex: InterruptedException =>
+ Thread.currentThread().interrupt()
+ throw ex
+ } finally {
+ if (!waitCompleted) {
+ futures.foreach(_.cancel(true))
+ }
+ }
+ clientsByHostPort.asScala.toMap
+ }
+
// Register the deserializer for GetReducerFileGroupResponse broadcast
ShuffleClient.registerDeserializeReducerFileGroupResponseFunction(new
BiFunction[
Integer,
diff --git
a/client-spark/spark-3/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReaderSuite.scala
b/client-spark/spark-3/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReaderSuite.scala
index 29878fd76..d2cec3abf 100644
---
a/client-spark/spark-3/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReaderSuite.scala
+++
b/client-spark/spark-3/src/test/scala/org/apache/spark/shuffle/celeborn/CelebornShuffleReaderSuite.scala
@@ -17,8 +17,10 @@
package org.apache.spark.shuffle.celeborn
+import java.io.IOException
import java.nio.file.Files
-import java.util.concurrent.TimeoutException
+import java.util.concurrent.{CountDownLatch, TimeoutException, TimeUnit}
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference}
import org.apache.spark.{Dependency, ShuffleDependency, TaskContext}
import org.apache.spark.shuffle.ShuffleReadMetricsReporter
@@ -31,6 +33,9 @@ import org.apache.celeborn.client.{DummyShuffleClient,
ShuffleClient}
import org.apache.celeborn.common.CelebornConf
import org.apache.celeborn.common.exception.CelebornIOException
import org.apache.celeborn.common.identity.UserIdentifier
+import org.apache.celeborn.common.network.client.TransportClient
+import org.apache.celeborn.common.protocol.PartitionLocation
+import org.apache.celeborn.common.util.ThreadUtils
class CelebornShuffleReaderSuite extends AnyFunSuite {
@@ -92,4 +97,164 @@ class CelebornShuffleReaderSuite extends AnyFunSuite {
shuffleReader.shuffleClient.asInstanceOf[DummyShuffleClient].fetchFailureCount.get()
=== 2)
}
+
+ test("create batch open stream clients in parallel per worker") {
+ val worker0 = newLocation(0, "worker-0", 19098)
+ val worker1 = newLocation(0, "worker-1", 19098)
+ val worker0Client = Mockito.mock(classOf[TransportClient])
+ val worker1Client = Mockito.mock(classOf[TransportClient])
+ val streamCreatorPool =
ThreadUtils.newDaemonCachedThreadPool("test-create-client", 2, 60)
+ val started = new CountDownLatch(2)
+ val release = new CountDownLatch(1)
+
+ try {
+ val clientsFuture = scala.concurrent.Future {
+ CelebornShuffleReader.createClientsInParallel(
+ Seq(
+ worker0.hostAndFetchPort -> Seq(worker0),
+ worker1.hostAndFetchPort -> Seq(worker1)),
+ streamCreatorPool,
+ location => {
+ started.countDown()
+ assert(started.await(5, TimeUnit.SECONDS))
+ assert(release.await(5, TimeUnit.SECONDS))
+ if (location eq worker0) worker0Client else worker1Client
+ },
+ (_, _, ex) => fail("Unexpected client creation failure", ex))
+ }(scala.concurrent.ExecutionContext.global)
+
+ assert(started.await(5, TimeUnit.SECONDS))
+ release.countDown()
+ val clients =
+ scala.concurrent.Await.result(
+ clientsFuture,
+ scala.concurrent.duration.Duration(5, "seconds"))
+
+ assert(clients(worker0.hostAndFetchPort) eq worker0Client)
+ assert(clients(worker1.hostAndFetchPort) eq worker1Client)
+ } finally {
+ streamCreatorPool.shutdownNow()
+ }
+ }
+
+ test("skip failed batch open stream client creation while keeping healthy
workers") {
+ val failedWorker = newLocation(0, "worker-0", 19098)
+ val healthyWorker = newLocation(0, "worker-1", 19098)
+ val healthyClient = Mockito.mock(classOf[TransportClient])
+ val streamCreatorPool =
ThreadUtils.newDaemonCachedThreadPool("test-create-client", 2, 60)
+ var failedHostPort: String = null
+
+ try {
+ val clients = CelebornShuffleReader.createClientsInParallel(
+ Seq(
+ failedWorker.hostAndFetchPort -> Seq(failedWorker),
+ healthyWorker.hostAndFetchPort -> Seq(healthyWorker)),
+ streamCreatorPool,
+ location => {
+ if (location eq failedWorker) throw new IOException("boom")
+ healthyClient
+ },
+ (hostPort, _, _) => failedHostPort = hostPort)
+
+ assert(failedHostPort === failedWorker.hostAndFetchPort)
+ assert(!clients.contains(failedWorker.hostAndFetchPort))
+ assert(clients(healthyWorker.hostAndFetchPort) eq healthyClient)
+ } finally {
+ streamCreatorPool.shutdownNow()
+ }
+ }
+
+ test("retry failed batch open stream client creation for the same worker") {
+ val failedLocation = newLocation(0, "worker-0", 19098)
+ val retryLocation = newLocation(1, "worker-0", 19098)
+ val client = Mockito.mock(classOf[TransportClient])
+ val streamCreatorPool =
ThreadUtils.newDaemonCachedThreadPool("test-create-client", 1, 60)
+ var failureCount = 0
+
+ try {
+ val clients = CelebornShuffleReader.createClientsInParallel(
+ Seq(failedLocation.hostAndFetchPort -> Seq(failedLocation,
retryLocation)),
+ streamCreatorPool,
+ location => {
+ if (location eq failedLocation) throw new IOException("boom")
+ client
+ },
+ (_, _, _) => failureCount += 1)
+
+ assert(failureCount === 1)
+ assert(clients(failedLocation.hostAndFetchPort) eq client)
+ } finally {
+ streamCreatorPool.shutdownNow()
+ }
+ }
+
+ test("cancel batch open stream client creation when waiting thread is
interrupted") {
+ val blockedLocation = newLocation(0, "worker-0", 19098)
+ val retryLocation = newLocation(1, "worker-0", 19098)
+ val retryClient = Mockito.mock(classOf[TransportClient])
+ val streamCreatorPool =
ThreadUtils.newDaemonCachedThreadPool("test-create-client", 1, 60)
+ val clientStarted = new CountDownLatch(1)
+ val releaseClient = new CountDownLatch(1)
+ val clientInterrupted = new CountDownLatch(1)
+ val retried = new AtomicBoolean(false)
+ val failureReported = new AtomicBoolean(false)
+ val callerInterrupted = new AtomicBoolean(false)
+ val callerFailure = new AtomicReference[Throwable]()
+
+ val caller = new Thread(new Runnable {
+ override def run(): Unit = {
+ try {
+ CelebornShuffleReader.createClientsInParallel(
+ Seq(blockedLocation.hostAndFetchPort -> Seq(blockedLocation,
retryLocation)),
+ streamCreatorPool,
+ location => {
+ if (location eq blockedLocation) {
+ clientStarted.countDown()
+ try {
+ releaseClient.await()
+ retryClient
+ } catch {
+ case ex: InterruptedException =>
+ clientInterrupted.countDown()
+ throw ex
+ }
+ } else {
+ retried.set(true)
+ retryClient
+ }
+ },
+ (_, _, _) => failureReported.set(true))
+ callerFailure.set(new AssertionError("Expected waiting thread to be
interrupted"))
+ } catch {
+ case _: InterruptedException =>
+ callerInterrupted.set(Thread.currentThread().isInterrupted)
+ case ex: Throwable =>
+ callerFailure.set(ex)
+ }
+ }
+ })
+
+ try {
+ caller.start()
+ assert(clientStarted.await(5, TimeUnit.SECONDS))
+ caller.interrupt()
+ caller.join(TimeUnit.SECONDS.toMillis(5))
+ assert(!caller.isAlive)
+ assert(clientInterrupted.await(5, TimeUnit.SECONDS))
+ streamCreatorPool.shutdown()
+ assert(streamCreatorPool.awaitTermination(5, TimeUnit.SECONDS))
+ assert(callerFailure.get() == null)
+ assert(callerInterrupted.get())
+ assert(!retried.get())
+ assert(!failureReported.get())
+ } finally {
+ releaseClient.countDown()
+ streamCreatorPool.shutdownNow()
+ caller.interrupt()
+ caller.join(TimeUnit.SECONDS.toMillis(5))
+ }
+ }
+
+ private def newLocation(id: Int, host: String, fetchPort: Int):
PartitionLocation =
+ new PartitionLocation(id, 0, host, 0, 0, fetchPort, 0,
PartitionLocation.Mode.PRIMARY)
}
diff --git
a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
index 40d06617f..32c4eb73d 100644
--- a/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
+++ b/common/src/main/scala/org/apache/celeborn/common/CelebornConf.scala
@@ -1174,6 +1174,8 @@ class CelebornConf(loadDefaults: Boolean) extends
Cloneable with Logging with Se
def enableReadLocalShuffleFile: Boolean = get(READ_LOCAL_SHUFFLE_FILE)
def readLocalShuffleThreads: Int = get(READ_LOCAL_SHUFFLE_THREADS)
def readStreamCreatorPoolThreads: Int = get(READ_STREAM_CREATOR_POOL_THREADS)
+ def batchOpenStreamParallelClientCreationEnabled: Boolean =
+ get(CLIENT_SPARK_BATCH_OPEN_STREAM_PARALLEL_CLIENT_CREATION_ENABLED)
def registerShuffleFilterExcludedWorkerEnabled: Boolean =
get(REGISTER_SHUFFLE_FILTER_EXCLUDED_WORKER_ENABLED)
@@ -6335,6 +6337,15 @@ object CelebornConf extends Logging {
.intConf
.createWithDefault(32)
+ val CLIENT_SPARK_BATCH_OPEN_STREAM_PARALLEL_CLIENT_CREATION_ENABLED:
ConfigEntry[Boolean] =
+
buildConf("celeborn.client.spark.batch.openStream.parallelClientCreation.enabled")
+ .categories("client")
+ .version("0.6.3")
+ .doc("Whether to create data clients in parallel before sending Spark
batch open-stream requests. " +
+ "When false, data clients are created serially.")
+ .booleanConf
+ .createWithDefault(true)
+
val CLIENT_CHUNK_PREFETCH_ENABLED: ConfigEntry[Boolean] =
buildConf("celeborn.client.chunk.prefetch.enabled")
.categories("client")
diff --git
a/common/src/test/scala/org/apache/celeborn/common/CelebornConfSuite.scala
b/common/src/test/scala/org/apache/celeborn/common/CelebornConfSuite.scala
index 953255cde..5c0bf32df 100644
--- a/common/src/test/scala/org/apache/celeborn/common/CelebornConfSuite.scala
+++ b/common/src/test/scala/org/apache/celeborn/common/CelebornConfSuite.scala
@@ -492,4 +492,13 @@ class CelebornConfSuite extends CelebornFunSuite {
}
}
+ test("parallel Spark batch open stream client creation can be disabled") {
+ val conf = new CelebornConf()
+
+ assert(conf.batchOpenStreamParallelClientCreationEnabled)
+
+
conf.set(CLIENT_SPARK_BATCH_OPEN_STREAM_PARALLEL_CLIENT_CREATION_ENABLED.key,
"false")
+ assert(!conf.batchOpenStreamParallelClientCreationEnabled)
+ }
+
}
diff --git a/docs/configuration/client.md b/docs/configuration/client.md
index ece9503bd..52d3d984e 100644
--- a/docs/configuration/client.md
+++ b/docs/configuration/client.md
@@ -124,6 +124,7 @@ license: |
| celeborn.client.shuffle.reviseLostShuffles.enabled | false | false | Whether
to revise lost shuffles. | 0.6.0 | |
| celeborn.client.shuffleDataLostOnUnknownWorker.enabled | false | false |
Whether to mark shuffle data lost when unknown worker is detected. | 0.6.3 | |
| celeborn.client.slot.assign.maxWorkers | 10000 | false | Max workers that
slots of one shuffle can be allocated on. Will choose the smaller positive one
from Master side and Client side, see `celeborn.master.slot.assign.maxWorkers`.
| 0.3.1 | |
+| celeborn.client.spark.batch.openStream.parallelClientCreation.enabled | true
| false | Whether to create data clients in parallel before sending Spark batch
open-stream requests. When false, data clients are created serially. | 0.6.3 |
|
| celeborn.client.spark.fetch.cleanFailedShuffle | false | false | whether to
clean those disk space occupied by shuffles which cannot be fetched | 0.6.0 |
|
| celeborn.client.spark.fetch.cleanFailedShuffleInterval | 1s | false | the
interval to clean the failed-to-fetch shuffle files, only valid when
celeborn.client.spark.fetch.cleanFailedShuffle is enabled | 0.6.0 | |
| celeborn.client.spark.push.dynamicWriteMode.enabled | false | false |
Whether to dynamically switch push write mode based on conditions.If true,
shuffle mode will be only determined by partition count | 0.5.0 | |