Repository navigation
Conversation
91a0c86 to
99d4755
Compare
|
cc: @saurabhd336 |
9a3e314 to
5a892c8
Compare
|
Thanks for adding this feature. This is a very nice improvement to keep the shuffle sizes bounded proactively. I would also add |
venkata91
left a comment
There was a problem hiding this comment.
Thanks for working on this. Bounding shuffle storage for long-running sessions is valuable. I ran the new suite plus a few extra scenarios locally (Spark 3.5 and 4.0) and found two blockers and some smaller items:
-
Spark 4 + AQE is a no-op (likely cause of the spark4 CI failures). Spark 4.0 wraps the final AQE plan in
ResultQueryStageExec, a leafQueryStageExec, socollectnever reaches the exchanges. With-Pspark-4.0I get 3/6 failures inCelebornQueryEndShuffleCleanupSuite(all the AQE cases). Matchingcase stage: QueryStageExec => extractShuffleIds(stage.plan)makes all 6 pass locally. -
Silent empty results when the same DataFrame is executed again with
celeborn.client.spark.stageRerun.enabled=false.CelebornShuffleDriverComponentsdoesn't overrideremoveShuffle, so it goes throughLocalDiskShuffleDriverComponents→BlockManagerMaster.removeShuffle→RemoveShuffleto every BlockManager, including the driver's. That also unregisters the shuffle from the driver'sMapOutputTrackerMaster. Because theShuffleDependencyis still reachable from the DataFrame's executed plan, the same shuffle id is reused on the next execution. Combined with the LifecycleManager's delayed expiry, Spark and Celeborn can disagree on whether the shuffle exists. Celeborn treats unknown shuffles as empty (SHUFFLE_UNREGISTERED/UNKNOWN_APP_SHUFFLE_ID), so the result is wrong rather than an error. Repro (Spark 3.5, AQE off, stageRerun=false, cleanFailedShuffle=false,shuffle.expired.checkInterval=1s):val df = spark.sql("SELECT id % 10 AS k, COUNT(1) AS cnt FROM ta GROUP BY id % 10") df.collect() // correct // wait until LifecycleManager expires the shuffle df.collect() // correct df.collect() // correct df.collect() // returns an EMPTY result, no exception
Two threads calling
collect()on the same DataFrame concurrently also fail (CelebornIOException: createPartitionReader failed) with stageRerun disabled. With stageRerun enabled, all my sequential and concurrent runs were correct. Could we fail fast at init unlessstageRerun.enabledis true, asfetch.cleanFailedShufflealready does, rather than only recommending it in the doc? Even with stageRerun, a reader that asks for the shuffle id after the mapping was removed getsUNKNOWN_APP_SHUFFLE_IDwithsuccess=true, which is read as empty. I couldn't trigger that race, but it may be worth a guard. -
Scope question. Spark 4's built-in equivalent (SPARK-47764,
spark.sql.shuffleDependency.fileCleanup.enabled) is scoped to Spark Connect / Thrift executions, which re-plan every statement, so DataFrame re-use can't happen. This PR applies to all SQL executions, including classic Dataset/PySpark patterns likedf.toPandas(); df.collect(). Would a narrower scope (or a per-session switch) make sense? On Spark 4+ it might also be possible to rely on Spark's own cleanup, which callsshuffleManager.unregisterShuffle.
Happy to share the repro suite if useful. A few inline comments follow.
| private def extractShuffleIds(plan: SparkPlan): Seq[Int] = { | ||
| plan.collect { | ||
| case adaptivePlan: AdaptiveSparkPlanExec => extractShuffleIds(adaptivePlan.executedPlan) | ||
| case stage: ShuffleQueryStageExec => extractShuffleIds(stage.plan) |
There was a problem hiding this comment.
On Spark 4.x the final AQE plan is wrapped in ResultQueryStageExec (also BroadcastQueryStageExec here), both leaf QueryStageExecs, so collect doesn't descend into them. Matching case stage: QueryStageExec => extractShuffleIds(stage.plan) fixed all AQE tests for me on -Pspark-4.0. TableCacheQueryStageExec.plan is an InMemoryTableScanExec, so cached plans still won't be touched.
| case adaptivePlan: AdaptiveSparkPlanExec => extractShuffleIds(adaptivePlan.executedPlan) | ||
| case stage: ShuffleQueryStageExec => extractShuffleIds(stage.plan) | ||
| case reused: ReusedExchangeExec => extractShuffleIds(reused.child) | ||
| case exchange: ShuffleExchangeExec => Seq(exchange.shuffleDependency.shuffleId) |
There was a problem hiding this comment.
ShuffleExchangeExec.shuffleDependency is a lazy val. For exchanges that never materialized (e.g. an AQE query that failed or was cancelled before creating that stage), touching it runs child.execute(), allocates a new shuffle id and calls shuffleManager.registerShuffle, all on the listener-bus thread. Could we only collect ids from materialized stages (e.g. ShuffleQueryStageExec with isMaterialized)?
Also: collectWithSubqueries would cover scalar-subquery shuffles, and non-vanilla ShuffleExchangeLike implementations (e.g. Gluten's columnar exchange) aren't handled.
|
|
||
| val lifecycleManager = | ||
| SparkEnv.get.shuffleManager.asInstanceOf[SparkShuffleManager].getLifecycleManager | ||
| val shuffleIds = extractShuffleIds(qe.executedPlan) |
There was a problem hiding this comment.
If planning itself failed, qe.executedPlan will re-trigger planning here on the listener thread and throw again (Spark 4 guards this with isExecutedPlanAvailable). Nit: could the LifecycleManager be passed into the listener's constructor instead of SparkEnv.get.shuffleManager.asInstanceOf[...] on every event? Also maybe NonFatal instead of Throwable at L37.
| .createWithDefault(true) | ||
|
|
||
| val CLIENT_SPARK_SQL_QUERY_END_SHUFFLE_CLEANUP_ENABLED: ConfigEntry[Boolean] = | ||
| buildConf("celeborn.client.spark.queryEndShuffleCleanup.enabled") |
There was a problem hiding this comment.
Nit: the PR description says celeborn.client.spark.sql.queryEndShuffleCleanup.enabled, but the key here is celeborn.client.spark.queryEndShuffleCleanup.enabled, while the val is CLIENT_SPARK_SQL_.... Please align them. Also see the top-level comment about enforcing stageRerun.enabled instead of only recommending it.
| sparkConf | ||
| } | ||
|
|
||
| Seq(true, false).foreach { aqeEnabled => |
There was a problem hiding this comment.
Could we add cases for re-executing the same DataFrame (sequentially and concurrently, with stageRerun on/off) and for a failed AQE query? Nit: spark.stop() at the end of each test is redundant with afterEach.
…d unmaterialized exchanges - Descend into generic QueryStageExec so Spark 4's ResultQueryStageExec leaf is reached - Only read shuffle ids from materialized shuffle stages; never touch the lazy shuffleDependency of unmaterialized exchanges on the listener thread - Cover scalar subqueries via subqueriesAll and descend below exchanges/stages - Fail fast at init when stageRerun is disabled; inject LifecycleManager into the listener; catch NonFatal instead of Throwable - Align config key to celeborn.client.spark.sql.queryEndShuffleCleanup.enabled and mention spark-connect-server in the doc - Add tests: failed AQE query, sequential/concurrent DataFrame re-execution, stageRerun fail-fast; drop redundant spark.stop()
|
@venkata91 Thanks for the thorough review and the repros — all findings confirmed and addressed in the latest commit. Summary of the changes: Blocker 1 — Spark 4 + AQE no-op. Fixed. The extractor now matches generic Blocker 2 — silent empty results with Lazy
Planning failures / nits. Failed queries are detected via Config key. Aligned to Tests. Added: failed AQE query, sequential/concurrent DataFrame re-execution, and the stage-rerun fail-fast case; removed the redundant Re: scope and SPARK-47764. Confirmed it shipped in 4.0.0 scoped to Spark Connect ( |
…eleborn.client.spark.shuffleCleanup.enabled
…terialized only exists since 3.2 Read the materialization state reflectively, falling back to resultOption (an Option on 3.0, an AtomicReference[Option] on 3.1).
venkata91
left a comment
There was a problem hiding this comment.
@yew1eb thanks for the quick turnaround. I re-ran last round's repros against bd7f9b3 on Spark 3.5 and 4.0. The Spark 4 AQE fix works, and the sequential and concurrent re-execution cases are correct with stageRerun on.
I also added a few cases around the new traversal. Each finding below reproduces on both 3.5 and 4.0, and details are inline:
- A failed non-AQE query can still launch Spark jobs on the listener bus thread.
- With AQE, some shuffles are not cleaned at query end: scalar subqueries, finished child stages of a failed stage, and stages AQE drops during re-optimization. They fall back to GC cleanup, so this is a leak, not wrong results.
- The stageRerun check only fails the first query.
Two other things:
- The PR description still mentions
CelebornSQLExecutionEndCleanerandCelebornQueryEndShuffleCleanupSuite(renamed in 2096b7d). It could also mention thatstageRerun.enabledis now required. - Could we file a follow-up JIRA for the deferred
UNKNOWN_APP_SHUFFLE_IDrace and link it here?
I have a local repro suite and a prototype patch if either would help.
| case exchange: ShuffleExchangeExec => | ||
| // In a failed query an exchange here may be unexecuted; RangePartitioning ones are | ||
| // skipped since reading them could launch a sampling job on this thread. | ||
| if (!inAdaptivePlan && | ||
| (!queryFailed || !exchange.outputPartitioning.isInstanceOf[RangePartitioning])) { | ||
| shuffleIds += exchange.shuffleDependency.shuffleId | ||
| } | ||
| exchange.children.foreach(visit(_, inAdaptivePlan)) |
There was a problem hiding this comment.
In bd7f9b3, this still reads shuffleDependency for a failed non-AQE query when the outer exchange isn't RangePartitioning. That lazy val runs child.execute(), so a nested range exchange, subquery or broadcast underneath can still start a job.
Repro (AQE off): bad.union(good).collect(), where each side is repartitionByRange(4, x).groupBy(x % 10).count() and bad has a throwing UDF. After the query fails, the driver log shows Got job 2 (onOtherEvent at SparkListenerBus.scala:100), coming from CelebornShuffleCleanupListener.scala:103 -> shuffleDependency$lzycompute -> RangePartitioner.sketch. The exception then aborts the walk, so good's shuffle isn't cleaned either.
Some options:
(a) Skip raw exchanges when executionFailure is set and leave those shuffles to GC cleanup. This is simplest, and it's what Spark's own query-end cleanup effectively does, since it only uses AQE stage info.
(b) Only read the id when the exchange has already executed, e.g. by checking whether mapOutputStatisticsFuture / cachedShuffleRDD was initialized. This relies on Spark internals that differ across versions.
(c) Catch per exchange so that one failure doesn't stop the rest of the walk. This contains the damage, but the job still runs.
| private def extractShuffleIds(plan: SparkPlan, queryFailed: Boolean): Seq[Int] = { | ||
| (plan +: plan.subqueriesAll).flatMap(collectShuffleIds(_, queryFailed)).distinct | ||
| } |
There was a problem hiding this comment.
With AQE on, scalar-subquery shuffles aren't found. The root is an AdaptiveSparkPlanExec, which is a leaf, so subqueriesAll is empty. The subqueries live in expressions of nodes inside executedPlan, and visit only follows children.
Repro (AQE on): SELECT COUNT(*) FROM ta WHERE id > (SELECT MAX(c) FROM (SELECT id % 10 k, COUNT(1) c FROM ta GROUP BY id % 10)) cleans only the main-query shuffle. The two subquery shuffles stay registered.
Some options:
(a) Also visit p.subqueries for each node inside visit. That covers both AQE and non-AQE, and the top-level subqueriesAll would no longer be needed.
(b) Read stages from AdaptiveSparkPlanExec.context.stageCache. AQE subqueries share the context, so their stages are in it too (see the next comment).
| case shuffleStage: ShuffleQueryStageExec => | ||
| if (isStageMaterialized(shuffleStage)) { | ||
| // Non-vanilla ShuffleExchangeLike (e.g. Gluten's columnar exchange) is ignored. | ||
| shuffleStage.shuffle match { | ||
| case exchange: ShuffleExchangeExec => | ||
| shuffleIds += exchange.shuffleDependency.shuffleId | ||
| case _ => | ||
| } | ||
| // A materialized stage only references materialized child stages; keep descending. | ||
| visit(shuffleStage.plan, inAdaptivePlan = false) | ||
| } |
There was a problem hiding this comment.
Two cases with AQE are missed here:
- If a stage isn't materialized, we don't descend into it, so its materialized children are skipped. For example, if the UDF fails in the second map stage, the first stage's shuffle stays registered.
- Stages that AQE drops from the final plan are never visited. For example,
ta JOIN (SELECT * FROM tb WHERE id < 0) b ON ...with broadcast off leaves both shuffles registered after the query ends.
Some options:
(a) Descend into the stage regardless of whether it is materialized, since each stage below is checked on its own. This fixes the first case only.
(b) Also collect the materialized ShuffleQueryStageExecs from adaptivePlan.context.stageCache. That holds every stage the query created; I checked it exists in 3.0 through 4.x. This covers both cases and is close to Spark 4's context.shuffleIds.
Minor: the inAdaptivePlan = false reset on L88 doesn't seem needed. Keeping true would guarantee that no raw exchange inside an AQE subtree is ever touched.
| if (celebornConf.clientSparkShuffleCleanupEnabled()) { | ||
| if (!celebornConf.clientStageRerunEnabled()) { | ||
| throw new IllegalArgumentException( | ||
| CelebornConf.CLIENT_STAGE_RERUN_ENABLED().key() | ||
| + " has to be " | ||
| + "enabled, when " | ||
| + CelebornConf.CLIENT_SPARK_SHUFFLE_CLEANUP_ENABLED().key() | ||
| + " is set to true"); | ||
| } | ||
| SparkUtils.addSparkListener(new CelebornShuffleCleanupListener(lifecycleManager)); | ||
| } |
There was a problem hiding this comment.
This check runs lazily on the first registerShuffle, after lifecycleManager has already been assigned. With stageRerun=false, the first query fails, but later queries run normally with cleanup silently off. The cleanFailedShuffle and broadcast callbacks below are also never registered.
Some options:
(a) Move the check to the constructor (driver only, e.g. next to validateAttemptConfig), so the SparkContext fails to start.
(b) Keep it here, but validate before lifecycleManager is assigned, so every query fails consistently.
(c) Log a warning and disable cleanup instead of throwing.
(a) seems the most predictable to me. The fail-fast test would then intercept around getOrCreate().
| def isAppShuffleRegistered(appShuffleId: Int, hasMapping: Boolean): Boolean = { | ||
| if (hasMapping) { |
There was a problem hiding this comment.
Nit: now that stageRerun is required whenever this is used, hasMapping is always true. We could either drop the parameter and check shuffleIdMapping only, or keep it if you expect other callers.
| val CLIENT_SPARK_SHUFFLE_CLEANUP_ENABLED: ConfigEntry[Boolean] = | ||
| buildConf("celeborn.client.spark.shuffleCleanup.enabled") |
There was a problem hiding this comment.
Nit: shuffleCleanup.enabled could be read as general shuffle cleanup, which already happens via ContextCleaner. Some options: keep it as is, or use a name that says when it fires, e.g. celeborn.client.spark.sql.cleanShuffleOnQueryEnd.enabled. No strong opinion.
Separately, it might help to note the limits in the doc: it only applies to SQL/Dataset executions, non-vanilla exchanges such as Gluten aren't covered, and shuffles of failed or cancelled stages fall back to GC cleanup.
What changes were proposed in this pull request?
Add an opt-in configuration
celeborn.client.spark.shuffleCleanup.enabled (default false). When enabled, aSparkListenerregistered on theSparkContextlistener bus handlesSparkListenerSQLExecutionEndevents and proactively unregisters all shuffles written by the completed SQL query (or Dataset action), instead of waiting for the driver GC to trigger Spark'sContextCleaneror for the application to end.CelebornSQLExecutionEndCleanerlistener collects shuffle ids from the query's final executed plan (AQE-aware) and cleans them up through the standard Spark cleanup entry pointshuffleDriverComponents.removeShuffle, reusing the existing unregister pipeline with zero master/worker/protocol changes.LifecycleManager.isAppShuffleRegisteredguard so only actually registered shuffles are unregistered.Why are the changes needed?
In long-running Spark applications (
ThriftServer,Kyuubi,spark-sql,spark-connect-server), one application runs many SQL queries, each producing new shuffles. Shuffle unregister is only triggered by Spark's GC-drivenContextCleaner, which may be delayed indefinitely in long sessions due to DAG/query-plan reference chains (seeCELEBORN-2244), so obsolete shuffle data piles up on Celeborn worker disks for the whole session lifetime.Does this PR resolve a correctness bug?
No.
Does this PR introduce any user-facing change?
Yes, a new client configuration
celeborn.client.spark.shuffleCleanup.enabled (default false, opt-in).How was this patch tested?
add ut
CelebornQueryEndShuffleCleanupSuite