dongjoon-hyun commented on code in PR #59523:
URL: https://github.com/apache/spark/pull/59523#discussion_r4426582640


##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -1520,13 +1519,39 @@ 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.isLocallyCheckpointed) {
+        persisted += rdd
+      }
+      rdd.dependencies.foreach {
+        case pd: PipelinedShuffleDependency[_, _, _] => enqueue(pd.rdd)
+        case _: ShuffleDependency[_, _, _] =>
+          // Shape validation excludes pipelined readers below a regular 
boundary.
+        case 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[_]]
+    persisted.find(rdd => rddChainReadsPipelinedShuffle(rdd, visited)).foreach 
{ rdd =>

Review Comment:
   **Follow-up to my item 2: when several persisted RDDs read the pipelined 
shuffle, which one the error names can change from run to run.** `persisted` is 
a `HashSet[RDD[_]]` (L1524), and `RDD` doesn't override `hashCode`, so `find` 
checks the roots in an order based on identity hash codes, which differ between 
runs. For example, with `val c = consumer.cache()`, where `consumer` reads the 
pipelined shuffle, and `val d = c.map(identity).cache()`, `d.count()` can name 
either `c` or `d`. The same job can then log different errors across runs, and 
a test with two offenders can't pin the name. `traverseRDDGraph` already calls 
the visitor once per RDD, so a buffer would hold no duplicates and keep the 
traversal order. Could `persisted` be a `ListBuffer`, which is already 
imported, so the error always names the first offender in that order?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleEligibility.scala:
##########
@@ -49,6 +50,9 @@ private[sql] object PipelinedShuffleEligibility extends 
Logging {
   def isUnsupportedConsumer(plan: SparkPlan): Boolean = plan match {
     case _: CoalesceExec | _: CartesianProductExec | _: 
DeserializeToObjectExec |
         _: CollectLimitExec | _: CollectTailExec | _: 
TakeOrderedAndProjectExec => true
+    // Caching can persist a shuffle reader before zipWithIndex submits its 
counting job.

Review Comment:
   **The Scaladoc at L49 and the class doc's lead-in at 
`EnablePipelinedShuffle.scala:51-53` don't cover this new reason.** L49 still 
describes these as "Operators whose consumers cannot safely drain a bounded, 
single-reader channel", and `EnablePipelinedShuffle.scala:51-53` describes an 
unsupported consumer as one that drains a shuffle in a way the channel 
transport cannot serve, or builds its own hidden regular shuffle. A cached 
`AttachDistributedSequenceExec` does neither. It is unsupported because 
persisting its child can skip channel readers, as this comment and the new 
bullet at `EnablePipelinedShuffle.scala:75-78` say. Both places are outside 
this diff, so I left this here. Could they mention persistence too, e.g. "... 
or that can persist a channel reader"?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleSqlSuite.scala:
##########
@@ -213,7 +218,89 @@ class PipelinedShuffleSqlSuite extends SparkFunSuite
     }
   }
 
+  private def distributedSequence(

Review Comment:
   **This helper builds the logical `AttachDistributedSequence` directly, so 
these tests skip the path pandas-on-Spark takes.** pandas-on-Spark attaches its 
default index with `sdf.select(SF.distributed_sequence_id().alias(column_name), 
"*")` ([internal.py 
L952](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/python/pyspark/pandas/internal.py#L952)),
 where `SF.distributed_sequence_id()` passes `F.lit(True)` as the `cache` 
argument ([pyspark/sql/internal.py 
L108-L111](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/python/pyspark/sql/internal.py#L108-L111)).
 `ExtractDistributedSequenceID` then reads that flag and puts a `Project` above 
the operator ([ExtractDistributedSequenceID.scala 
L37-L44](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/ExtractDistributedSequenceID.scala#L37-L44)).
 Could the helper build the same `select` with
  `Column.internalFn("distributed_sequence_id", lit(cache))`, as 
`DataFrameSuite` does at 
[L2422](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala#L2422)
 without the argument? That would also drop the `classic` cast, and since the 
description says the Python pandas wrapper was not executed, it would cover the 
plan that wrapper builds.



##########
core/src/test/scala/org/apache/spark/scheduler/DAGSchedulerSuite.scala:
##########
@@ -9499,6 +9554,146 @@ class DAGSchedulerSuite extends SparkFunSuite with 
TempLocalSparkContext with Ti
     assertDataStructuresEmpty()
   }
 
+  for ((cacheState, cachedPartitions) <- Seq(
+      "cold" -> Seq.empty[Int], "partial" -> Seq(0), "full" -> Seq(0, 1))) {
+    test(s"pipelined cache preflight: reject a persisted consumer before 
admission " +
+        s"(cache=$cacheState)") {
+      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"))
+      }
+      scheduler.asInstanceOf[MyDAGScheduler].maxConcurrentTasksForTest = 3
+
+      assertPersistedPipelinedConsumerRejected(consumer, 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"))

Review Comment:
   **nit: this cache location is never read.** `checkPipelinedCacheSupported` 
rejects the job before admission and stage creation, and only 
`getMissingParentStages` and the preferred-location lookup call `getCacheLocs`, 
so the test passes without this line, like the "evicted" snapshot in my item 5. 
The cold/partial/full loop above already covers cache state. Could we drop it, 
so this test only varies the slot check?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnablePipelinedShuffle.scala:
##########
@@ -127,24 +131,26 @@ object EnablePipelinedShuffle extends Rule[SparkPlan] {
    * True if any [[ShuffleExchangeExec]] in `plan` is read by an operator the 
channel transport
    * cannot serve. The unsupported operators (see class doc for why each is 
fatal) are
    * `CoalesceExec`, `CartesianProductExec`, and the limit operators 
`CollectLimitExec` /
-   * `CollectTailExec` / `TakeOrderedAndProjectExec`. For each such operator 
anywhere in the plan,
-   * check whether a shuffle is reachable below it.
-   *
-   * The reachability walk descends through EVERY child of a non-exchange node 
-- not only unary
-   * children -- so a shuffle behind a `UnionExec`/join (a `BinaryExecNode`) 
beneath the operator
-   * is still found. It stops at the FIRST [[ShuffleExchangeExec]] on each 
path: a shuffle deeper
-   * than that first one is not read by this operator (the intervening 
exchange's own reader reads
-   * one reduce partition per task), so it is not this operator's concern.
+   * `CollectTailExec` / `TakeOrderedAndProjectExec`, plus cached 
`AttachDistributedSequenceExec`.
+   * A shuffle above an unsupported operator with shuffle-free input does not 
disqualify the plan.
    */
-  private def readsShuffleThroughUnsupportedConsumer(plan: SparkPlan): Boolean 
= {
-    def reachesShuffle(p: SparkPlan): Boolean = p match {
-      case _: ShuffleExchangeExec => true
-      case other => other.children.exists(reachesShuffle)
-    }
-    plan.exists {
-      case p if PipelinedShuffleEligibility.isUnsupportedConsumer(p) =>
-        p.children.exists(reachesShuffle)
-      case _ => false
+  private[exchange] def readsShuffleThroughUnsupportedConsumer(plan: 
SparkPlan): Boolean = {

Review Comment:
   **This rewrite replaces an 8-line `plan.exists` with shared mutable state, 
to save planning work that seems small.** The old version was quadratic only 
when unsupported consumers nest with no exchange between them, and even at the 
new grid test's depth of 128 that is about 16K node visits during planning. In 
exchange, the new version has a `foundUnsupported` flag shared across the 
recursion, a manual iterator loop for the early exit, and a grid test with a 
custom `CountingChildrenExec` to pin the visit count. Could we go back to the 
`plan.exists` version? If the linear walk is needed, a post-order fold that 
returns `(hasShuffle, found)` would avoid the shared `var`.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/exchange/PipelinedShuffleSqlSuite.scala:
##########
@@ -213,7 +218,89 @@ class PipelinedShuffleSqlSuite extends SparkFunSuite
     }
   }
 
+  private def distributedSequence(
+      spark: SparkSession,
+      input: DataFrame,
+      cache: Boolean): DataFrame = {
+    classic.Dataset.ofRows(spark.asInstanceOf[classic.SparkSession],
+      AttachDistributedSequence(AttributeReference("index", LongType)(),
+        input.queryExecution.analyzed, cache))
+  }
+
+  private def assertDistributedSequenceResult(df: DataFrame): Unit = {
+    val rows = df.collect()
+    assert(rows.map(_.getLong(0)).sorted === (0L until 4000L).toArray)
+    assert(rows.map(_.getLong(1)).sorted === (0L until 4000L).toArray)
+  }
+
   for (aqe <- Seq(false, true)) {
+    test(s"distributed sequence cache construction retains regular shuffles 
with AQE=$aqe") {
+      withPipelinedSession("pipelined-distributed-sequence-cache", aqe,
+          channelBatchSize = 8, channelQueueCapacity = 1) { spark =>
+        val cacheKey = "pandas_on_Spark.compute.default_index_cache"
+        for {
+          storage <- Seq(None, Some("MEMORY_ONLY"), Some("LOCAL_CHECKPOINT"), 
Some("NONE"))
+          upperShuffle <- Seq(false, true)
+        } {
+          // An absent setting exercises pandas-on-Spark's default 
MEMORY_AND_DISK_SER.
+          storage match {
+            case Some(level) => spark.conf.set(cacheKey, "\"" + level + "\"")
+            case None => spark.conf.unset(cacheKey)
+          }
+          val indexed = distributedSequence(spark,
+            spark.range(0, 4000, 1, 2).repartition(4).toDF(), cache = true)
+          val df = if (upperShuffle) indexed.repartition(3) else indexed

Review Comment:
   **With AQE on, the `upperShuffle = true` iterations pass without the new 
`isUnsupportedConsumer` case.** `collectCandidates` collects the 
`repartition(3)` exchange and does not walk below it, since it only continues 
below a `SinglePartition` exchange ([AQEEnablePipelinedShuffle.scala 
L118-L121](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEEnablePipelinedShuffle.scala#L118-L121)).
 The `repartition(4)` exchange is never a candidate, so `hasRegularPrefix` 
keeps the whole plan regular ([AQEEnablePipelinedShuffle.scala 
L72-L79](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEEnablePipelinedShuffle.scala#L72-L79))
 whether or not the cached operator is an unsupported consumer. So under AQE, 
only the `upperShuffle = false` iterations check the new case. Could the upper 
shuffle be `repartition(1)
 `? `Repartition` plans one partition as `SinglePartition` 
([basicLogicalOperators.scala 
L2285-L2289](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/basicLogicalOperators.scala#L2285-L2289)),
 so the walk would reach the cached operator, where `isStatsSensitive` now 
blocks the `repartition(4)` exchange ([AQEEnablePipelinedShuffle.scala 
L162](https://github.com/apache/spark/blob/8622bf64d253128af42121f0da257c055961a627/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEEnablePipelinedShuffle.scala#L162)),
 and the iteration would fail without the new case.



##########
core/src/test/scala/org/apache/spark/shuffle/local/pipelined/PipelinedChannelShuffleSuite.scala:
##########
@@ -685,6 +686,64 @@ 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 = consumer.partitions.indices.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 during setup.
+        blocks.take(cachedPartitions).foreach { block =>
+          assert(blockManager.putIterator(
+            block, Iterator((block.splitIndex, block.splitIndex)),
+            StorageLevel.MEMORY_ONLY, tellMaster = true))
+        }
+        
assert(blockManager.master.getLocations(blocks.toArray[BlockId]).map(_.nonEmpty).toSeq
 ===
+          blocks.indices.map(_ < cachedPartitions))
+
+        val events = new JobEvents
+        sc.addSparkListener(events)
+        val error = intercept[SparkException](consumer.collect())
+        checkError(
+          exception = error,
+          condition = "PIPELINED_SHUFFLE_UNSUPPORTED",
+          sqlState = Some("0A000"),
+          parameters = Map("reason" ->
+            (s"cache/persist/localCheckpoint on RDD $consumer in a pipelined 
consumer's " +
+              "within-stage chain (unpersist this RDD or rebuild it without 
localCheckpoint " +
+              "before submitting the job; persist only RDDs that do not read a 
pipelined " +
+              "shuffle within their stage)")))
+        sc.listenerBus.waitUntilEmpty()
+        assert(events.taskStarts.isEmpty)
+        assert(events.stageEnds.isEmpty)
+        assert(ChannelShuffleRendezvous.numQueuesForTesting === 0)
+        assert(consumer.getStorageLevel === StorageLevel.MEMORY_ONLY)
+
+        consumer.unpersist(blocking = true)
+        for (_ <- 0 until 2) {
+          assert(consumer.collect().map(_._1).sorted === (0 until 
1000).toArray)
+          assert(ChannelShuffleRendezvous.numQueuesForTesting === 0)
+        }
+      }
+    }
+  }
+
+  test("cached producer input survives eviction between pipelined actions") {

Review Comment:
   **nit: the name reads as if the cached blocks survive eviction, but the test 
evicts them.** It removes the input's blocks with `removeRdd` and checks that 
the second pipelined `collect()` recomputes the input and still succeeds. Could 
the name say that, e.g. "pipelined actions recompute an evicted producer-input 
cache"?



-- 
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]

Reply via email to