dongjoon-hyun commented on code in PR #59523:
URL: https://github.com/apache/spark/pull/59523#discussion_r4349530202
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1516,6 +1515,29 @@ private[spark] class DAGScheduler(
checkPipelinedStageCreation(finalRDD, partitions, jobId)
}
+ /** Reject persistence that could bypass a native pipelined shuffle reader.
*/
+ private def checkPipelinedCacheSupported(finalRDD: RDD[_]): Unit = {
+ val persisted = new HashSet[RDD[_]]
+ traverseRDDGraph(finalRDD) { (rdd, enqueue) =>
+ if (rdd.getStorageLevel != StorageLevel.NONE ||
+
rdd.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]])) {
+ persisted += rdd
+ }
+ rdd.dependencies.foreach(dep => enqueue(dep.rdd))
+ }
+ // Cache locations cannot establish a stable cut: eviction can revive an
upstream producer,
+ // and a partial cache hit skips a reader whose producer still needs its
termination ACK.
+ // Check storage intent instead, including pending local checkpoints and
narrow descendants.
+ val visited = new HashSet[RDD[_]]
+ if (persisted.exists(rdd => rddChainReadsPipelinedShuffle(rdd, visited))) {
Review Comment:
**Spark SQL persists a pipelined consumer by itself, so this rejection now
breaks pandas-on-Spark's default index, with advice the user can't follow.**
With cache enabled, which is the default for the `distributed-sequence` index
type ([config.py
L177-L196](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/python/pyspark/pandas/config.py#L177-L196)),
`AttachDistributedSequenceExec` persists its child's RDD, or local-checkpoints
it when `compute.default_index_cache=LOCAL_CHECKPOINT`
([AttachDistributedSequenceExec.scala
L77-L96](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/sql/core/src/main/scala/org/apache/spark/sql/execution/python/AttachDistributedSequenceExec.scala#L77-L96)),
and then calls `zipWithIndex()` on it
([L100-L107](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/sql/core/src/main/scala/org/apache/spark/sql/execution/python/AttachDistributedSequenceExec.scala#L100-L107)).
`Zippe
dWithIndexRDD` stops at the persisted ancestor and runs a counting job on it
([ZippedWithIndexRDD.scala
L51](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/ZippedWithIndexRDD.scala#L51),
[L76-L79](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/ZippedWithIndexRDD.scala#L76-L79)).
`PipelinedShuffleEligibility` has this operator neither in
`isUnsupportedConsumer` nor in `hasUnsupportedBoundary`
([L49-L52](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleEligibility.scala#L49-L52),
[L105-L110](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleEligibility.scala#L105-L110)),
so the child exchange becomes pipelined, and the counting jo
b's final RDD is exactly what this check rejects.
For example, with the settings of `test_pipelined_shuffle.py`
([L28-L40](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/python/pyspark/sql/tests/test_pipelined_shuffle.py#L28-L40)),
`spark.range(0, 4000, 1, 2).repartition(4).pandas_api().to_pandas()` should
now fail with `PIPELINED_SHUFFLE_UNSUPPORTED`, with AQE on or off. I traced
this statically and didn't run it. Before this PR, the `LOCAL_CHECKPOINT` mode
worked because `doCheckpoint` cut the lineage, and the default mode worked
until a channel queue filled up, at which point the partial cache hit this PR
describes made it hang. The message asks to "unpersist cached RDDs", but this
RDD is created inside Spark, so the only workarounds are
`compute.default_index_cache=NONE` or turning the flag off.
`CacheManager` already keeps cache construction on regular shuffles for the
same reason ([CacheManager.scala
L701-L704](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/sql/core/src/main/scala/org/apache/spark/sql/execution/CacheManager.scala#L701-L704)).
Could `AttachDistributedSequenceExec` with `cache = true` be treated as an
unsupported boundary in `PipelinedShuffleEligibility`, in this PR or under a
separate JIRA, so that this plan stays regular?
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1516,6 +1515,29 @@ private[spark] class DAGScheduler(
checkPipelinedStageCreation(finalRDD, partitions, jobId)
}
+ /** Reject persistence that could bypass a native pipelined shuffle reader.
*/
+ private def checkPipelinedCacheSupported(finalRDD: RDD[_]): Unit = {
+ val persisted = new HashSet[RDD[_]]
+ traverseRDDGraph(finalRDD) { (rdd, enqueue) =>
+ if (rdd.getStorageLevel != StorageLevel.NONE ||
+
rdd.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]])) {
+ persisted += rdd
+ }
+ rdd.dependencies.foreach(dep => enqueue(dep.rdd))
+ }
+ // Cache locations cannot establish a stable cut: eviction can revive an
upstream producer,
+ // and a partial cache hit skips a reader whose producer still needs its
termination ACK.
+ // Check storage intent instead, including pending local checkpoints and
narrow descendants.
+ val visited = new HashSet[RDD[_]]
+ if (persisted.exists(rdd => rddChainReadsPipelinedShuffle(rdd, visited))) {
+ throw pipelinedUnsupportedError(
+ "cache/persist/localCheckpoint in a pipelined consumer's within-stage
chain " +
Review Comment:
**The error doesn't say which RDD has to change.** `persisted.exists(...)`
drops the RDD that matched, and the warning at L2756 only logs the job id. When
the same job also caches a producer input, which this PR explicitly allows, the
user has to work out by hand which cached RDD sits inside a consumer stage, and
following "unpersist cached RDDs" literally can also remove the caches that are
fine. `toDebugString` doesn't help much: it prints the root RDD's storage level
on every line ([RDD.scala
L2072](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L2072))
and draws pipelined and regular shuffle boundaries the same way, and
`getRDDStorageInfo` skips caches that are not populated yet
([SparkContext.scala
L2399](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/SparkContext.scala#L2399)).
The repeated-consumption reason at L1598-L1601 alr
eady names the shuffle and the partition. Could this use `find` and include
the offending RDD, e.g. `rdd.toString`, which has the name, class, id and
creation site? With the shared `visited` set, only the first match is reliable,
but that is enough for the message.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1516,6 +1515,29 @@ private[spark] class DAGScheduler(
checkPipelinedStageCreation(finalRDD, partitions, jobId)
}
+ /** Reject persistence that could bypass a native pipelined shuffle reader.
*/
+ private def checkPipelinedCacheSupported(finalRDD: RDD[_]): Unit = {
+ val persisted = new HashSet[RDD[_]]
+ traverseRDDGraph(finalRDD) { (rdd, enqueue) =>
+ if (rdd.getStorageLevel != StorageLevel.NONE ||
Review Comment:
**A cold `localCheckpoint()` on a pipelined consumer is now rejected,
although the base allowed it and, as far as I can tell, handled it safely.**
`localCheckpoint()` persists the RDD ([RDD.scala
L1803](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L1803)),
so this check rejects a consumer even when none of its blocks exist yet. The
base comment said "Cache / .persist() / local checkpoint are whole-partition
and ephemeral and are not rejected" ([L1488-L1489 at the
base](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala#L1488-L1489)).
On a cold run, each reader opens once, because pipelined task sets get a
single attempt ([TaskSetManager.scala
L80](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala#L80))
and repeat
ed reads are rejected. Then `doCheckpoint` runs a job only for the missing
partitions and replaces the lineage with a `LocalCheckpointRDD`
([LocalRDDCheckpointData.scala
L42-L66](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/LocalRDDCheckpointData.scala#L42-L66)),
so later jobs have no pipelined edge. For example, `c.localCheckpoint();
c.count(); c.map(f).collect()` used to work and now fails at `count()`.
Since persist and reliable checkpoint are rejected too, an RDD-level
pipelined consumer can no longer truncate its lineage inside Spark: an
iterative job re-runs every earlier producer, and its gang demand grows each
iteration. The real hazard needs blocks that already exist at submission, for
example left by a failed run. A narrower rule, such as rejecting only when the
storage level is `NONE` or blocks already exist for the partitions being read,
is possible, but it needs a fresh block-location query and still leaves races,
so I understand keeping the intent rule. If that's the choice, could the
description also say that this removes the in-Spark way to truncate such
lineage?
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -9470,6 +9471,216 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
assertDataStructuresEmpty()
}
+ for {
+ (cacheState, cachedPartitions) <- Seq(
+ "cold" -> Seq.empty[Int], "partial" -> Seq(0), "full" -> Seq(0, 1),
+ "evicted" -> Seq(0, 1))
+ slots <- Seq(3, 4)
+ } {
+ test(s"pipelined cache preflight: reject a persisted consumer before
admission " +
+ s"(cache=$cacheState, slots=$slots)") {
+ val producer = new MyRDD(sc, 2, Nil)
+ val dependency = new PipelinedShuffleDependency(producer, new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).cache()
+ cachedPartitions.foreach { partition =>
+ cacheLocations(consumer.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ if (cacheState == "evicted") {
Review Comment:
**The "evicted" case doesn't test a stale cache snapshot, and `slots = 4`
adds nothing over `slots = 3`.** With this PR, all eight cases throw from
`checkPipelinedCacheSupported` (`DAGScheduler` L2693) before admission and
before anything reads `cacheLocs`, so the primed memo is never consulted. On
the base, `handleJobSubmitted` called `clearCacheLocs()` before
`getMissingParentStages`, so the stale snapshot was dropped there too, and
"evicted" behaved like "cold". So the case can't catch an implementation that
"cold" and "full" miss, and its comment ("Keep a stale driver snapshot after
the blocks disappear ...") suggests coverage that isn't there. `slots = 3` is
useful because it pins that the cache check runs before admission. `slots = 4`
only catches a missing check, which "reject persisted consumers with slot
checking disabled" (L9501) also catches. Could we drop "evicted" and `slots =
4`, or reword the comment? That would also save five SparkContext-backed tests.
##########
core/src/test/scala/org/apache/spark/shuffle/local/pipelined/PipelinedChannelShuffleSuite.scala:
##########
@@ -658,6 +659,52 @@ class PipelinedChannelShuffleSuite extends SparkFunSuite {
}
}
+ for (cachedPartitions <- Seq(0, 1, 2)) {
+ test(s"consumer cache is rejected before channel tasks start
(cached=$cachedPartitions)") {
+ withPipelinedSparkContext(cores = 4) { sc =>
+ val input = sc.parallelize(0 until 1000, 2).map(i => (i, i))
+ val consumer = new PipelinedShuffledRDD[Int, Int, Int](input, new
HashPartitioner(2))
+ .cache()
+ val blocks = (0 until cachedPartitions).map(RDDBlockId(consumer.id, _))
+ val blockManager = sc.env.blockManager
+ // Populate blocks without running the shuffle so a partial cache
cannot leave a
+ // producer waiting for a reader. Admission must reject before
starting either stage.
+ blocks.foreach { block =>
+ assert(blockManager.putIterator(
+ block, Iterator((block.splitIndex, block.splitIndex)),
+ StorageLevel.MEMORY_ONLY, tellMaster = true))
+ }
+
assert(blockManager.master.getLocations(blocks.toArray[BlockId]).forall(_.nonEmpty))
+
+ val error = intercept[SparkException](consumer.collect())
+ assert(error.getCondition === "PIPELINED_SHUFFLE_UNSUPPORTED")
+ assert(error.getMessage.contains("unpersist cached RDDs"))
+ assert(sc.statusTracker.getActiveStageIds().isEmpty)
Review Comment:
**These assertions would still pass if the stages had started and the job
had failed later.** `getActiveStageIds()` reads the status store, which the
listener bus updates asynchronously ([SparkStatusTracker.scala
L71-L72](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/SparkStatusTracker.scala#L71-L72)),
and it is called without `sc.listenerBus.waitUntilEmpty()`. A stage that
started and then failed is not `ACTIVE` either. `numQueuesForTesting` is 0
after any failed run, since `cleanupStateForJobAndIndependentStages` calls
`endRun` (`DAGScheduler` L1817-L1818) before `jobFailed` (`DAGScheduler`
L5311-L5312). And `abortStage` surfaces a user-facing `SparkThrowable`
unwrapped (`DAGScheduler` L5236-L5241), so the condition and message checks
match a late rejection too. A regression that moved the check into
`submitMissingTasks` or into the tasks, with the same reason, would pass this
test. The suite already has `JobEv
ents` (L89-L98) and uses it with `waitUntilEmpty()`. Registering it before
`collect()` and asserting that `taskStarts` and `stageEnds` are empty would
prove what the test name says.
Also, the comment at L670-L671 says "Admission must reject before starting
either stage", but `checkPipelinedCacheSupported` rejects before admission
runs, and a demand of 2 + 2 = 4 tasks fits `local[4]` anyway. For `cached=0`,
the `getLocations(...).forall(_.nonEmpty)` precondition at L677 is vacuous.
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -9730,7 +9941,17 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
// Both zip inputs evaluate the same consumer partition. Persistence
cannot guarantee
// that its block survives, and a pending local checkpoint has not
replaced its lineage.
val result = consumer.zipPartitions(consumer)((left, right) => left ++
right)
- assertRepeatedPipelinedConsumptionRejected(result, dependency.shuffleId)
+ if (storage == "uncached") {
+ assertRepeatedPipelinedConsumptionRejected(result,
dependency.shuffleId)
+ } else {
+ assertPipelinedRejectedBeforeStageRegistration(
Review Comment:
**The persisted and local-checkpoint variants of this test no longer test
repeated consumption.** `checkPipelinedCacheSupported` now rejects them before
`checkPipelinedShuffleRepeatedReads` runs, so they only re-check the cache
rejection that "reject a persisted consumer before admission (cache=cold, ...)"
and "reject a pending local checkpoint in a consumer" already cover. Meanwhile
the test name and the comment above ("Both zip inputs evaluate the same
consumer partition ...") still describe the repeated-read check. Could we drop
these two variants, or rename them to say that the cache preflight runs before
the repeated-read check?
Relatedly, the Scaladoc of `checkPipelinedShuffleRepeatedReads`
(`DAGScheduler` L1547, "A cache declaration is not a boundary: evicted or
unstored partitions still compute lineage.") now describes input that function
can no longer receive, because every persisted RDD in `relevantRDDs` is
rejected earlier. It could point to `checkPipelinedCacheSupported` instead, as
the PR already did for the comment at L1488 of that file.
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -10113,6 +10334,20 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
}
}
+ private def assertPersistedPipelinedConsumerRejected(
Review Comment:
**This helper is a weaker copy of
`assertPipelinedRejectedBeforeStageRegistration` (L10288), and its
storage-level check is vacuous for two callers.** The existing helper pins the
exact reason with `checkError`, including sqlState `0A000`, submits twice to
show that the rejection leaves no state behind, and checks `results`,
`submittedStageInfos` and `sc.isStopped`. This one regex-matches part of the
reason and submits once without those checks, yet 12 tests use it, while the
self-zip variants at L9947 already use the stronger helper for the same
rejection. Also, in "reject a persisted narrow descendant inside a consumer
stage" (L9517) and "reject a persisted stage that consumes and produces
shuffles" (L9529), the persisted RDD is `cached` or `middle`, not `finalRdd`,
so `finalRdd.getStorageLevel === storageLevel` compares `NONE` with `NONE`.
How about one reason constant in the suite, with this helper as a thin
wrapper like `assertRepeatedPipelinedConsumptionRejected` (L10276): delegate to
the existing helper, then check the storage level of the RDD that was actually
persisted? Today the reason is copied verbatim at L9949-L9952 and pinned by
different substrings at L9551, L9553, L9643, L10341-L10344 and in
`PipelinedChannelShuffleSuite` L681. That last one also checks the condition by
hand instead of using `checkError`, unlike L249-L255 of the same suite.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1516,6 +1515,29 @@ private[spark] class DAGScheduler(
checkPipelinedStageCreation(finalRDD, partitions, jobId)
}
+ /** Reject persistence that could bypass a native pipelined shuffle reader.
*/
+ private def checkPipelinedCacheSupported(finalRDD: RDD[_]): Unit = {
+ val persisted = new HashSet[RDD[_]]
+ traverseRDDGraph(finalRDD) { (rdd, enqueue) =>
+ if (rdd.getStorageLevel != StorageLevel.NONE ||
+
rdd.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]])) {
+ persisted += rdd
+ }
+ rdd.dependencies.foreach(dep => enqueue(dep.rdd))
Review Comment:
**This walk also descends into the materialized prefix, where a pipelined
read can't exist.** `handleJobSubmitted` has already rejected a pipelined
shuffle below a regular boundary (`isUnsupportedMix`, L2677) when this runs, so
no RDD that is reachable only through a regular `ShuffleDependency` can have a
within-stage pipelined read. The walk still visits the whole prefix and then
searches from every persisted RDD in it. In an RDD job with a materialized
prefix, that prefix is often the long, cached part of an iterative program, and
this runs on the event loop for every pipelined submission. The updated tests
show the extra walk: 2 -> 3 and 3/4 -> 4/5 visits per edge. Stopping at regular
boundaries keeps the result and drops that cost:
```scala
rdd.dependencies.foreach {
case pd: PipelinedShuffleDependency[_, _, _] => enqueue(pd.rdd)
case _: ShuffleDependency[_, _, _] => // materialized prefix, excluded by
isUnsupportedMix
case dep => enqueue(dep.rdd)
}
```
A larger option is to collect the persisted RDDs in
`checkPipelinedJobSupported`'s walk (L1440-L1469), moved ahead of admission,
and test membership in `relevantRDDs` (L1555-L1563), the narrow-descendant
closure of the pipelined readers. The demand walk can't host it, because it is
skipped when the slot check is off.
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -9470,6 +9471,216 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
assertDataStructuresEmpty()
}
+ for {
+ (cacheState, cachedPartitions) <- Seq(
+ "cold" -> Seq.empty[Int], "partial" -> Seq(0), "full" -> Seq(0, 1),
+ "evicted" -> Seq(0, 1))
+ slots <- Seq(3, 4)
+ } {
+ test(s"pipelined cache preflight: reject a persisted consumer before
admission " +
+ s"(cache=$cacheState, slots=$slots)") {
+ val producer = new MyRDD(sc, 2, Nil)
+ val dependency = new PipelinedShuffleDependency(producer, new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).cache()
+ cachedPartitions.foreach { partition =>
+ cacheLocations(consumer.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ if (cacheState == "evicted") {
+ // Keep a stale driver snapshot after the blocks disappear. Cache
presence cannot
+ // authorize execution of a transient shuffle whose readers can be
bypassed.
+ assert(scheduler.getCacheLocs(consumer).forall(_.nonEmpty))
+ cacheLocations.clear()
+ assert(scheduler.getCacheLocs(consumer).forall(_.nonEmpty))
+ }
+ scheduler.asInstanceOf[MyDAGScheduler].maxConcurrentTasksForTest = slots
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+ }
+
+ test("pipelined cache preflight: reject persisted consumers with slot
checking disabled") {
+ conf.set(config.PIPELINED_GROUP_SLOT_CHECK_ENABLED, false)
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2,
List(dependency)).persist(StorageLevel.MEMORY_AND_DISK)
+ cacheLocations(consumer.id -> 0) = Seq(makeBlockManagerId("hostA"))
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a persisted narrow descendant inside
a consumer stage") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val reads = new MyRDD(sc, 2, List(dependency))
+ val cached = new MyRDD(sc, 2, List(new OneToOneDependency(reads))).cache()
+ val result = new MyRDD(sc, 2, List(new OneToOneDependency(cached)))
+ cacheLocations(cached.id -> 0) = Seq(makeBlockManagerId("hostA"))
+
+ assertPersistedPipelinedConsumerRejected(result, Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a persisted stage that consumes and
produces shuffles") {
+ val first = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val middle = new MyRDD(sc, 2, List(first)).cache()
+ val second = new PipelinedShuffleDependency(middle, new HashPartitioner(2))
+ val result = new MyRDD(sc, 2, List(second))
+ for (partition <- 0 until 2) {
+ cacheLocations(middle.id -> partition) = Seq(makeBlockManagerId("hostA"))
+ }
+
+ assertPersistedPipelinedConsumerRejected(result, Seq(first.shuffleId,
second.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a pending local checkpoint in a
consumer") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).localCheckpoint()
+ assert(!consumer.isCheckpointed)
+ assert(consumer.getStorageLevel != StorageLevel.NONE)
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: unpersisting a pending local checkpoint
still requires " +
+ "rebuilding the consumer") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).localCheckpoint()
+ consumer.unpersist(blocking = true)
+ assert(consumer.getStorageLevel === StorageLevel.NONE)
+
assert(consumer.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]]))
+ assert(!consumer.isCheckpointed)
+
+ val exception = submitAndCaptureFailure(consumer, Array(0, 1))
+ assertPipelinedUnsupported(exception, "cache/persist/localCheckpoint in a
pipelined consumer")
+ assert(exception.getMessage.contains(
+ "rebuild locally checkpointed consumers without localCheckpoint"))
+ assert(consumer.getStorageLevel === StorageLevel.NONE)
+ assertNoPipelinedStageRegistration(Seq(dependency.shuffleId))
+ assert(taskSets.isEmpty)
+ assertDataStructuresEmpty()
+ }
+
+ for (cachedProducerRoot <- Seq(false, true)) {
+ test(s"pipelined cache preflight: allow cached producer input " +
+ s"(producerRoot=$cachedProducerRoot)") {
+ val input = new MyRDD(sc, 2, Nil)
+ val producer = new MyRDD(sc, 2, List(new OneToOneDependency(input)))
+ val cached = if (cachedProducerRoot) producer else input
+ cached.cache()
+ for (partition <- 0 until 2) {
+ cacheLocations(cached.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ val dependency = new PipelinedShuffleDependency(producer, new
HashPartitioner(2))
+ val result = new MyRDD(sc, 2, List(dependency))
+ scheduler.asInstanceOf[MyDAGScheduler].maxConcurrentTasksForTest = 4
+
+ submit(result, Array(0, 1))
+ assert(failure === null)
+ assert(taskSets.size === 2)
+ assert(taskSets.forall(_.isPipelined))
+ completeShuffleMapStageSuccessfully(taskSets.head.stageId, 0, 2)
+ complete(taskSets.last, Seq((Success, 42), (Success, 43)))
+ assert(results === Map(0 -> 42, 1 -> 43))
+ assertDataStructuresEmpty()
+ }
+ }
+
+ test("pipelined cache preflight: allow unrelated cached branches with shared
ancestry") {
+ val edgeReads = Seq.fill(8)(new AtomicInteger)
+ var shared: RDD[(Int, Int)] = new MyRDD(sc, 2, Nil)
+ edgeReads.foreach { counter =>
+ val dependency = new OneToOneDependency(shared) {
+ override def rdd: RDD[(Int, Int)] = {
+ counter.incrementAndGet()
+ super.rdd
+ }
+ }
+ shared = new MyRDD(sc, 2, List(dependency))
+ }
+ val cachedBranches = (0 until 32).map { _ =>
+ val cached = new MyRDD(sc, 2, List(new
OneToOneDependency(shared))).cache()
+ cacheLocations(cached.id -> 0) = Seq(makeBlockManagerId("hostA"))
+ cached
+ }
+ val pipelined = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val reads = new MyRDD(sc, 2, List(pipelined))
+ val result = new MyRDD(sc, 2,
+ (reads +: cachedBranches).map(new OneToOneDependency(_)).toList)
+ var preflightEdgeReads = Seq.empty[Int]
+ doAnswer { invocation =>
+ if (preflightEdgeReads.isEmpty) {
+ preflightEdgeReads = edgeReads.map(_.get())
+ }
+ invocation.callRealMethod()
+ }.when(scheduler).getShuffleDependenciesAndResourceProfiles(result)
+ edgeReads.foreach(_.set(0))
+
+ submit(result, Array(0, 1))
+ assert(failure === null)
+ // Every cached branch shares a safe ancestor. Searching each branch
independently would
+ // visit each edge at least 32 times before stage creation.
+ assert(preflightEdgeReads.nonEmpty)
+ assert(preflightEdgeReads.forall(_ <= 6), s"repeated shared traversal:
$preflightEdgeReads")
Review Comment:
**`<= 6` is looser than the actual count, which is exactly 5.** Before the
first `getShuffleDependenciesAndResourceProfiles(result)` call, each counted
edge is read by the classification walk, the cache collection walk, the shared
cache search, the admission demand walk and `checkPipelinedJobSupported`'s
walk. So a regression that adds one more persisted-only search with its own
`visited` set (5 -> 6) still passes, and the nearby test that pins exact counts
(L9797-L9798) has no persisted RDD. The setup also repeats about 20 lines of
"pipelined checkpoint preflight: shared ancestry is visited once" (L9746).
Could this be a `"cache"` value of that test's `roots` dimension, with
`expected = 5`, so the count is pinned exactly?
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1516,6 +1515,29 @@ private[spark] class DAGScheduler(
checkPipelinedStageCreation(finalRDD, partitions, jobId)
}
+ /** Reject persistence that could bypass a native pipelined shuffle reader.
*/
+ private def checkPipelinedCacheSupported(finalRDD: RDD[_]): Unit = {
+ val persisted = new HashSet[RDD[_]]
+ traverseRDDGraph(finalRDD) { (rdd, enqueue) =>
+ if (rdd.getStorageLevel != StorageLevel.NONE ||
+
rdd.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]])) {
Review Comment:
**nit: `RDD.isLocallyCheckpointed` already implements this predicate.** It
is the check that `persist` and `unpersist` use for "marked for local
checkpointing" ([RDD.scala
L1849-L1854](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L1849-L1854),
used at
[L187](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L187)
and
[L214](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L214)),
and like this expression it is true for both pending and materialized local
checkpoints. Widening it from `private[rdd]` to `private[spark]`, as
`isCheckpointedAndMaterialized` and `isReliablyCheckpointed` already are
([L1842](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L1842),
[L185
9](https://github.com/apache/spark/blob/39ebf2923484b045ef4b4f864db444431503ccd0/core/src/main/scala/org/apache/spark/rdd/RDD.scala#L1859)),
would keep a single definition, and both new `LocalRDDCheckpointData` imports
could go away (the suite uses it only at L9547). The reliable check at L1441
has to stay spelled out, since `isReliablyCheckpointed` is true only after
materialization.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2665,19 +2687,17 @@ private[spark] class DAGScheduler(
return
}
- // Gang admission for a pipelined job: the runnable stage graph is one
pipelined group (a
- // materialized prefix's stages never run), so check up front (before any
stage is created)
- // that the cluster can run the entire group concurrently. If it cannot
fit, fail the job now
- // -- no partial scheduler state, and no member ever left running while a
sibling waits on
- // slots (true all-or-nothing gang admission). Inert for a regular job (no
pipelined
- // dependency).
- if (hasPipelined && rejectUnadmittablePipelinedGroup(jobId, finalRDD,
partitions, listener)) {
- // Clear the slot-check retry count an earlier deferral may have left.
- barrierJobIdToNumTasksCheckFailures.remove(jobId)
- return
- }
var finalStage: ResultStage = null
try {
+ if (hasPipelined) {
+ checkPipelinedCacheSupported(finalRDD)
+ // Validate cache semantics before counting tasks: a cached consumer
could suppress
Review Comment:
**nit: this comment explains the call above it, and the two `if
(hasPipelined)` blocks could be one.** Since it sits between
`checkPipelinedCacheSupported(finalRDD)` and the admission call, it reads as a
comment on admission. The moved
`barrierJobIdToNumTasksCheckFailures.remove(jobId)` also lost its "Clear the
slot-check retry count an earlier deferral may have left." comment, so it is
the only one of the six without it. Merging the blocks (cache check, admission,
`checkPipelinedJobSupported`) and moving this comment above the cache check
would read better.
Moving admission into the `try` also changes behavior a little. A failure
inside admission that isn't an idiom rejection, such as an invalid
`spark.scheduler.pipelinedGroup.slotCheck.enabled` value, now fails only this
job through the generic `case e: Exception` arm, instead of reaching `onError`
and stopping the SparkContext. That looks like an improvement. Could the
description mention it?
##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -9470,6 +9471,216 @@ class DAGSchedulerSuite extends SparkFunSuite with
TempLocalSparkContext with Ti
assertDataStructuresEmpty()
}
+ for {
+ (cacheState, cachedPartitions) <- Seq(
+ "cold" -> Seq.empty[Int], "partial" -> Seq(0), "full" -> Seq(0, 1),
+ "evicted" -> Seq(0, 1))
+ slots <- Seq(3, 4)
+ } {
+ test(s"pipelined cache preflight: reject a persisted consumer before
admission " +
+ s"(cache=$cacheState, slots=$slots)") {
+ val producer = new MyRDD(sc, 2, Nil)
+ val dependency = new PipelinedShuffleDependency(producer, new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).cache()
+ cachedPartitions.foreach { partition =>
+ cacheLocations(consumer.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ if (cacheState == "evicted") {
+ // Keep a stale driver snapshot after the blocks disappear. Cache
presence cannot
+ // authorize execution of a transient shuffle whose readers can be
bypassed.
+ assert(scheduler.getCacheLocs(consumer).forall(_.nonEmpty))
+ cacheLocations.clear()
+ assert(scheduler.getCacheLocs(consumer).forall(_.nonEmpty))
+ }
+ scheduler.asInstanceOf[MyDAGScheduler].maxConcurrentTasksForTest = slots
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+ }
+
+ test("pipelined cache preflight: reject persisted consumers with slot
checking disabled") {
+ conf.set(config.PIPELINED_GROUP_SLOT_CHECK_ENABLED, false)
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2,
List(dependency)).persist(StorageLevel.MEMORY_AND_DISK)
+ cacheLocations(consumer.id -> 0) = Seq(makeBlockManagerId("hostA"))
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a persisted narrow descendant inside
a consumer stage") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val reads = new MyRDD(sc, 2, List(dependency))
+ val cached = new MyRDD(sc, 2, List(new OneToOneDependency(reads))).cache()
+ val result = new MyRDD(sc, 2, List(new OneToOneDependency(cached)))
+ cacheLocations(cached.id -> 0) = Seq(makeBlockManagerId("hostA"))
+
+ assertPersistedPipelinedConsumerRejected(result, Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a persisted stage that consumes and
produces shuffles") {
+ val first = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val middle = new MyRDD(sc, 2, List(first)).cache()
+ val second = new PipelinedShuffleDependency(middle, new HashPartitioner(2))
+ val result = new MyRDD(sc, 2, List(second))
+ for (partition <- 0 until 2) {
+ cacheLocations(middle.id -> partition) = Seq(makeBlockManagerId("hostA"))
+ }
+
+ assertPersistedPipelinedConsumerRejected(result, Seq(first.shuffleId,
second.shuffleId))
+ }
+
+ test("pipelined cache preflight: reject a pending local checkpoint in a
consumer") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).localCheckpoint()
+ assert(!consumer.isCheckpointed)
+ assert(consumer.getStorageLevel != StorageLevel.NONE)
+
+ assertPersistedPipelinedConsumerRejected(consumer,
Seq(dependency.shuffleId))
+ }
+
+ test("pipelined cache preflight: unpersisting a pending local checkpoint
still requires " +
+ "rebuilding the consumer") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency)).localCheckpoint()
+ consumer.unpersist(blocking = true)
+ assert(consumer.getStorageLevel === StorageLevel.NONE)
+
assert(consumer.checkpointData.exists(_.isInstanceOf[LocalRDDCheckpointData[_]]))
+ assert(!consumer.isCheckpointed)
+
+ val exception = submitAndCaptureFailure(consumer, Array(0, 1))
+ assertPipelinedUnsupported(exception, "cache/persist/localCheckpoint in a
pipelined consumer")
+ assert(exception.getMessage.contains(
+ "rebuild locally checkpointed consumers without localCheckpoint"))
+ assert(consumer.getStorageLevel === StorageLevel.NONE)
+ assertNoPipelinedStageRegistration(Seq(dependency.shuffleId))
+ assert(taskSets.isEmpty)
+ assertDataStructuresEmpty()
+ }
+
+ for (cachedProducerRoot <- Seq(false, true)) {
+ test(s"pipelined cache preflight: allow cached producer input " +
+ s"(producerRoot=$cachedProducerRoot)") {
+ val input = new MyRDD(sc, 2, Nil)
+ val producer = new MyRDD(sc, 2, List(new OneToOneDependency(input)))
+ val cached = if (cachedProducerRoot) producer else input
+ cached.cache()
+ for (partition <- 0 until 2) {
+ cacheLocations(cached.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ val dependency = new PipelinedShuffleDependency(producer, new
HashPartitioner(2))
+ val result = new MyRDD(sc, 2, List(dependency))
+ scheduler.asInstanceOf[MyDAGScheduler].maxConcurrentTasksForTest = 4
+
+ submit(result, Array(0, 1))
+ assert(failure === null)
+ assert(taskSets.size === 2)
+ assert(taskSets.forall(_.isPipelined))
+ completeShuffleMapStageSuccessfully(taskSets.head.stageId, 0, 2)
+ complete(taskSets.last, Seq((Success, 42), (Success, 43)))
+ assert(results === Map(0 -> 42, 1 -> 43))
+ assertDataStructuresEmpty()
+ }
+ }
+
+ test("pipelined cache preflight: allow unrelated cached branches with shared
ancestry") {
+ val edgeReads = Seq.fill(8)(new AtomicInteger)
+ var shared: RDD[(Int, Int)] = new MyRDD(sc, 2, Nil)
+ edgeReads.foreach { counter =>
+ val dependency = new OneToOneDependency(shared) {
+ override def rdd: RDD[(Int, Int)] = {
+ counter.incrementAndGet()
+ super.rdd
+ }
+ }
+ shared = new MyRDD(sc, 2, List(dependency))
+ }
+ val cachedBranches = (0 until 32).map { _ =>
+ val cached = new MyRDD(sc, 2, List(new
OneToOneDependency(shared))).cache()
+ cacheLocations(cached.id -> 0) = Seq(makeBlockManagerId("hostA"))
+ cached
+ }
+ val pipelined = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val reads = new MyRDD(sc, 2, List(pipelined))
+ val result = new MyRDD(sc, 2,
+ (reads +: cachedBranches).map(new OneToOneDependency(_)).toList)
+ var preflightEdgeReads = Seq.empty[Int]
+ doAnswer { invocation =>
+ if (preflightEdgeReads.isEmpty) {
+ preflightEdgeReads = edgeReads.map(_.get())
+ }
+ invocation.callRealMethod()
+ }.when(scheduler).getShuffleDependenciesAndResourceProfiles(result)
+ edgeReads.foreach(_.set(0))
+
+ submit(result, Array(0, 1))
+ assert(failure === null)
+ // Every cached branch shares a safe ancestor. Searching each branch
independently would
+ // visit each edge at least 32 times before stage creation.
+ assert(preflightEdgeReads.nonEmpty)
+ assert(preflightEdgeReads.forall(_ <= 6), s"repeated shared traversal:
$preflightEdgeReads")
+ assert(taskSets.size === 2)
+ completeShuffleMapStageSuccessfully(taskSets.head.stageId, 0, 2)
+ complete(taskSets.last, Seq((Success, 42), (Success, 43)))
+ assert(results === Map(0 -> 42, 1 -> 43))
+ assertDataStructuresEmpty()
+ }
+
+ test("pipelined cache preflight: persistence changes are checked on every
submission") {
+ val dependency = new PipelinedShuffleDependency(new MyRDD(sc, 2, Nil), new
HashPartitioner(2))
+ val consumer = new MyRDD(sc, 2, List(dependency))
+ submit(consumer, Array(0, 1))
+ completeShuffleMapStageSuccessfully(taskSets.head.stageId, 0, 2)
+ complete(taskSets.last, Seq((Success, 42), (Success, 43)))
+ assertDataStructuresEmpty()
+
+ consumer.persist(StorageLevel.MEMORY_AND_DISK)
+ for (partition <- 0 until 2) {
+ cacheLocations(consumer.id -> partition) =
Seq(makeBlockManagerId("hostA"))
+ }
+ clearInvocations(scheduler, mapOutputTracker)
+ assertPipelinedUnsupported(
+ submitAndCaptureFailure(consumer, Array(0, 1)),
+ "cache/persist/localCheckpoint in a pipelined consumer")
+ assertNoPipelinedStageRegistration(Nil)
+ assert(taskSets.size === 2, "the rejected submission must launch no
additional tasks")
+ assertDataStructuresEmpty()
+
+ consumer.unpersist(blocking = true)
+ assert(consumer.getStorageLevel === StorageLevel.NONE)
+ results.clear()
+ submit(consumer, Array(0, 1))
+ assert(taskSets.size === 4)
+ assert(failure === null)
+ completeShuffleMapStageSuccessfully(taskSets(2).stageId, 0, 2)
+ complete(taskSets.last, Seq((Success, 44), (Success, 45)))
+ assert(results === Map(0 -> 44, 1 -> 45))
+ assertDataStructuresEmpty()
+ }
+
+ for (cachedPartitions <- Seq(Seq.empty[Int], Seq(0), Seq(0, 1))) {
Review Comment:
**nit: these three tests can't fail because of this PR.** They use a regular
`ShuffleDependency`, so `hasPipelined` is false and the new check never runs.
Even without that gate, `rddChainReadsPipelinedShuffle` stops at the regular
boundary, and admission would see a demand of 2 against
`maxConcurrentTasksForTest = 1000`. Cache pruning for regular jobs is already
covered by "getMissingParentStages should consider all ancestor RDDs' cache
statuses" (L899) and "SPARK-53564: Resubmit missing parent when failed to get
cache locations" (L6748), and the `hasPipelined` gate is pinned by "ordinary
job preflight traverses once" (L7226). Could we drop them, or keep just one?
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -5554,12 +5574,13 @@ private[spark] object DAGScheduler {
private[spark] case class CancelledJobInfo(jobId: Int, properties: Properties)
/**
- * Thrown when a job uses a pipelined-shuffle idiom that is not supported
(fan-out, a barrier /
- * indeterminate / checksum-retry / push-merge producer, a reliable checkpoint
in a member's chain,
+ * Thrown when a job uses a pipelined-shuffle idiom that is not supported
(fan-out, dynamic
Review Comment:
**nit: "dynamic allocation" is listed here, but a result job with dynamic
allocation gets a plain `SparkException` without this condition.**
`classifyJobShuffleShapeAndCheckFeatures` rejects it first (L1370-L1373), and
`DAGSchedulerSuite` L7394 pins that message, so the typed branch at L861-L862
is reached only through the map-stage path. Meanwhile, repeated consumption
(L1598-L1601) throws this type but is still not listed. Could the list qualify
or drop the dynamic-allocation item and add repeated consumption?
--
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]