dongjoon-hyun commented on PR #58097:
URL: https://github.com/apache/spark/pull/58097#issuecomment-5363201382

   Thanks for the detailed write-up in the PR description -- the inline 
rationale (especially in `ChannelShuffleRendezvous` and the `epoch` design) 
made this much easier to follow. I reviewed the full diff and ran the new 
suites locally. One confirmed deadlock plus three smaller items below.
   
   ## 1. Deadlock: partial-read job whose result RDD reaches the pipelined 
shuffle through a non-identity chain
   
   `submitMissingTasks` only stamps `SPARK_PIPELINED_LIVE_REDUCE_PARTITIONS` 
when `readsShuffleByIdentity` holds (`DAGScheduler.scala:2736`), i.e. the 
result RDD reaches the shuffle through a chain of *single* `OneToOneDependency` 
hops. When it does not hold, the property is left unset and the writer reads 
that as "every reduce partition is live".
   
   The comment there argues this is safe because "that operator drains every 
reduce partition". That is true for a **full** read, but not for a **partial** 
one. `executeTake`/LIMIT runs its first job on partition 0 only, so the 
remaining reduce partitions have no reduce task at all -- not a reader that 
stopped early (which `abandon` covers), but one that never started, so the 
abandoned mark never appears and `putUnlessAbandoned`'s `q.offer(..., 100ms)` 
loop has no exit.
   
   A join is the easy case to hit: the result RDD is a `ZippedPartitionsRDD2` 
with two dependencies, so the match falls through to `case _ => false`.
   
   ```scala
   // local mode, spark.sql.pipelinedShuffle.enabled=true, channel manager, 
shuffle.partitions=4
   left.join(right, $"k" === $"k2").limit(10).collect()   // hangs
   ```
   
   I added this to `PipelinedLimitHangSuite` (purely additive; existing helpers 
untouched):
   
   ```scala
     private def joinLimitOverPipelinedCompletesWithin(seconds: Int, aqe: 
Boolean): Boolean = {
       val pool = Executors.newSingleThreadExecutor()
       val fut = pool.submit(new Runnable {
         override def run(): Unit = withSession(aqe) { spark =>
           import spark.implicits._
           // Force a shuffled join: a broadcast side would leave only one 
exchange, and the
           // AQE rule flips a join's inputs only as a symmetric pair.
           spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "-1")
           spark.conf.set("spark.sql.adaptive.autoBroadcastJoinThreshold", "-1")
           // Distinct row counts and column names so the two exchanges cannot 
canonicalize
           // alike: a ReusedExchangeExec would make EnablePipelinedShuffle 
leave the whole plan
           // regular and the query would pass for the wrong reason.
           val left = spark.range(0, 4000000L, 1, 4).withColumn("k", ($"id" % 
100))
           val right = spark.range(0, 100000L, 1, 4).withColumn("k2", ($"id" % 
100))
           val rows = left.join(right, $"k" === 
$"k2").limit(10).collect().length
           require(rows == 10, s"expected 10 rows, got $rows")
         }
       })
       try {
         fut.get(seconds.toLong, TimeUnit.SECONDS)
         true
       } catch {
         case _: java.util.concurrent.TimeoutException =>
           fut.cancel(true)
           false
       } finally {
         pool.shutdownNow()
       }
     }
   
     test("LIMIT over a pipelined shuffle read through a join completes (AQE 
off)") {
       assert(joinLimitOverPipelinedCompletesWithin(90, aqe = false),
         "LIMIT over a joined pipelined shuffle should complete, but the writer 
hung feeding " +
           "reduce partitions that executeTake never reads")
     }
   
     test("LIMIT over a pipelined shuffle read through a join completes (AQE 
on)") {
       assert(joinLimitOverPipelinedCompletesWithin(90, aqe = true),
         "LIMIT over a joined pipelined shuffle should complete, but the writer 
hung feeding " +
           "reduce partitions that executeTake never reads")
     }
   ```
   
   Result -- the two existing tests pass, the two new ones burn the full 90s 
deadline in both AQE modes:
   
   ```
   [info] - LIMIT over a pipelined shuffle completes (AQE off) (3 seconds, 136 
milliseconds)
   [info] - LIMIT over a pipelined shuffle completes (AQE on) (287 milliseconds)
   [info] - LIMIT over a pipelined shuffle read through a join completes (AQE 
off) *** FAILED *** (1 minute, 30 seconds)
   [info] - LIMIT over a pipelined shuffle read through a join completes (AQE 
on) *** FAILED *** (1 minute, 30 seconds)
   ```
   
   Thread dump of the forked test JVM at the 45s mark confirms it is a 
deadlock, not slowness -- every live executor thread is parked, each having 
burned ~0.1s of CPU over 44.8s elapsed:
   
   ```
   "Executor task launch worker for task 0.0 in stage 0.0 (TID 0)"  
TIMED_WAITING (parking)
        at java.util.concurrent.LinkedBlockingQueue.offer(...)
        at 
...ChannelShuffleWriter.putUnlessAbandoned(ChannelShuffleWriterReader.scala:116)
        at ...ChannelShuffleWriter.write(ChannelShuffleWriterReader.scala:164)
   
   "Executor task launch worker for task 0.0 in stage 2.0 (TID 8)"  
TIMED_WAITING (parking)
        at java.util.concurrent.LinkedBlockingQueue.poll(...)
        at 
...ChannelShuffleReader$$anon$1.takeItem(ChannelShuffleWriterReader.scala:281)
        at 
...ChannelShuffleReader$$anon$1.advance(ChannelShuffleWriterReader.scala:298)
   ```
   
   | thread | state | cpu / elapsed |
   |---|---|---|
   | stage 0.0 TID 0..3 (producer map tasks) | `putUnlessAbandoned` -> `offer` 
| 104-116ms / 44.81s |
   | stage 2.0 TID 8 (result stage, partition 0) | `takeItem` -> `poll` | 128ms 
/ 44.81s |
   
   Note there is exactly **one** reduce task for 4 shuffle partitions, which is 
the crux: partitions 1-3 never get a reader, so they never get abandoned, so 
the writers never reach their end-of-stream loop, so the partition-0 reader 
never counts `numMaps` markers.
   
   The same reasoning applies to `coalesce(...).limit(n)` and 
`union(...).limit(n)`.
   
   On the fix: extending `readsShuffleByIdentity` to cover join/union/coalesce 
means re-deriving each operator's partition mapping, which seems fragile. It 
may be more robust to invert the fallback. Today "cannot determine the live 
set" degrades to "everything is live", but the safe degradation is the 
opposite: if the result stage does not read all of its partitions 
(`rs.partitions.length != rs.rdd.partitions.length`) and the live set cannot be 
determined exactly, reject the job fail-fast (or leave the plan regular) rather 
than risk a hang. Happy to be wrong here if you see a cheaper invariant.
   
   ## 2. `AQEEnablePipelinedShuffle`: `toFlip` matches structurally, not by 
identity
   
   `toFlip` is a `mutable.HashSet[ShuffleExchangeExec]` 
(`AQEEnablePipelinedShuffle.scala:79`). `TreeNode` overrides `hashCode` but not 
`equals`, so the case-class structural `equals` applies and 
`toFlip.contains(s)` at line 91 flips *every* structurally equal exchange, not 
just the node the collector selected.
   
   `duplicatedShuffleForms` is the only guard, and it is skipped entirely when 
`spark.sql.exchange.reuse=false` (`val shared = if (conf.exchangeReuseEnabled) 
... else Set.empty`). In that configuration a twin on a `blocked` path -- one 
`collectCandidates` deliberately left regular -- gets flipped too. If it sits 
below a regular boundary, `classifyJobShuffleShape` then rejects the whole job 
with `SparkException`.
   
   Keying on `SparkPlan.id`, or an `IdentityHashMap`-backed set, removes the 
ambiguity regardless of the reuse flag.
   
   ## 3. `ContextCleaner`: the new arm is gated on the manager, not on the 
shuffle
   
   The `else if (tracklessPipelinedManagerActive)` arm 
(`ContextCleaner.scala:264`) fires for *any* shuffle found in neither tracker 
once the channel manager is configured -- including regular ones. The comment 
at 265-277 anticipates the double-clean problem and gates on the manager to 
avoid it for default deployments, but the gate does not distinguish a regular 
shuffle from a pipelined one.
   
   That matters here precisely because this feature produces regular shuffles: 
the materialized prefix of a mixed job. A prefix shuffle unregistered early 
(`RDD.cleanShuffleDependencies`, or `Dataset.rdd` with 
`spark.sql.classic.shuffleDependency.fileCleanup.enabled`) will, on later GC, 
take the new arm and fire `shuffleCleaned` a second time plus an extra 
`RemoveShuffle` RPC. Tracking channel shuffle ids in the manager, or checking 
that the dependency was pipelined, would scope it correctly.
   
   ## 4. `classifyJobShuffleShape` adds per-boundary graph walks to every job 
submission
   
   `rddGraphHasPipelinedDependency(sd.rdd)` (`DAGScheduler.scala:1202`) is 
called once per frontier regular boundary, and each call builds its own 
`visited` set. The previous `classifyJobShuffleKinds` visited each RDD exactly 
once.
   
   For a job whose frontier boundaries share a large ancestor subgraph (a wide 
join or union over a common base), this is O(K x |graph|) on the 
single-threaded DAGScheduler event loop, before any stage is created -- and it 
runs for everyone, including users who never enable this feature, where the 
answer is always `false`. Carrying a `belowRegular` flag through the single 
existing traversal would restore the original cost.
   
   ## Minor notes
   
   - The PR description says "Two registered configs" but there are three -- 
`spark.shuffle.pipelined.channel.queueCapacity` is missing from the 
user-facing-change section. The description also mentions a `clearAbandoned` 
test helper that does not exist in the code.
   - `PipelinedChannelShuffleManager.stop()`'s comment says the rendezvous is 
"keyed only by (shuffleId, reducePartitionId)", which is stale now that `epoch` 
is part of the key.
   
   Nothing above touches the core design, which I think is sound -- routing by 
dependency type, the `requiresDetachedRecords` gate combined with the 
`needToCopyObjectsBeforeShuffle` de-duplication, the per-run `epoch` keying, 
and the cooperative `killTaskIfInterrupted` escapes on both sides all look 
right to me.
   


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