Uh oh!
There was an error while loading. Please reload this page.
[SPARK-57491][FOLLOWUP] Make stale push-based shuffle fallback chunk-granular and opt-in - #58008
[SPARK-57491][FOLLOWUP] Make stale push-based shuffle fallback chunk-granular and opt-in#58008gaoyajun02 wants to merge 2 commits into
Conversation
a0786f5 to
a36c3b8Comparegaoyajun02
commented
Aug 14, 2026
PTAL @cloud-fan |
a36c3b8 to
52d6c01Compare…granular and opt-in
52d6c01 to
091123cCompare
cloud-fan
left a comment
There was a problem hiding this comment.
2 blocking, 2 non-blocking, 2 nits.
Changes requested: the chunk-level fallback shape is sound, but the rollout default and runtime-indeterminacy lifecycle need correction before merge.
Design / architecture (2)
- Blocking: core/src/main/scala/org/apache/spark/internal/config/package.scala:2985: Make stale-push fallback actually opt-in by changing the registered default to false. -- see inline
- Blocking: core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:89: Evaluate runtime indeterminacy when the duplicate arrives instead of caching it at TaskSetManager construction. -- see inline
Correctness (2)
- Non-blocking: core/src/main/scala/org/apache/spark/storage/PushBasedFetchHelper.scala:301: Avoid copying the entire stale-index set for every fetched chunk. -- see inline
- Non-blocking: core/src/test/scala/org/apache/spark/scheduler/TaskSetManagerSuite.scala:2930: Test the real default and indeterminate-only mode independently. -- see inline
Nits: 2 minor items (see inline comments).
Verification
Traced a late duplicate from TaskSetManager.handleSuccessfulTask through stale marking and MapOutputTracker propagation to the reducer's chunk predicate and single-chunk fallback. Confirmed that DAGScheduler can set runtime indeterminacy after TaskSetManager construction, and that the worker getter copies the complete stale-index set on each chunk check.
| "spark.shuffle.push.stale.detectAllStages.enabled is also true.") | ||
| .version("4.4.0") | ||
| .booleanConf | ||
| .createWithDefault(true) |
There was a problem hiding this comment.
Blocking:
The rollout contract is opt-in: both this entry's documentation and the PR description say the default is false. Leaving this as true enables fallback for indeterminate stages without an explicit setting and reintroduces the fetch amplification this PR is meant to avoid.
| .createWithDefault(true) | |
| .createWithDefault(false) |
There was a problem hiding this comment.
Done. Changed createWithDefault(true) -> createWithDefault(false) so the registered default matches the "Default false" documentation and the opt-in contract. The default no longer re-enables reducer fallback for indeterminate stages without an explicit setting.
Verified the only reader is TaskSetManager.stalePushFallbackEnabled; tests set the config explicitly, so no test relied on the true default.
| // identical output across attempts, so a stale push there is benign). Defaults to false when the | ||
| // stage isn't registered (e.g. tests). Uses taskSet.stageId (constructor param) because the | ||
| // `stageId` field below is not initialized yet at this source position. | ||
| private val isIndeterminateShuffleMapStage: Boolean = isShuffleMapTasks && |
There was a problem hiding this comment.
Blocking:
Keep the ShuffleMapStage reference and evaluate its indeterminacy when the duplicate result arrives, rather than caching this Boolean. DAGScheduler can set isChecksumMismatched later in the same stage attempt; a subsequent duplicate then still sees false here, skips stale marking in the indeterminate-only mode, and leaves reducers able to consume the stale merged chunk.
There was a problem hiding this comment.
Done. TaskSetManager no longer caches indeterminacy as a Boolean at construction. It now holds the Option[ShuffleMapStage] reference and re-evaluates isStaticallyIndeterminate || isRuntimeIndeterminate inside detectStalePushIfShuffleTask, when the late duplicate result arrives. A checksum mismatch set by DAGScheduler during the stage (via registerMapOutput) is therefore observed rather than read from a stale constructor snapshot.
Added a test case (fallbackEnabled=true, detectAllStages=false) that sets isChecksumMismatched after the TaskSetManager is constructed and asserts stale marking still fires — this would fail under the old cached-Boolean approach.
| // shuffleId is only available when isShuffleMapTasks=true | ||
| private val shuffleId = taskSet.shuffleId | ||
| // Scopes stale-push reducer fallback to indeterminate stages (deterministic stages reproduce | ||
| // identical output across attempts, so a stale push there is benign). Defaults to false when the |
There was a problem hiding this comment.
Nit:
Please phrase this as reproducing the same data set. Spark's UNORDERED level is not indeterminate, but its contract explicitly permits record order to change across reruns, so identical output is stronger than the invariant used by this guard.
There was a problem hiding this comment.
Done. Reworded to "reproduce the same data set" — UNORDERED is not indeterminate but permits record reordering, so "identical output" was stronger than the invariant this guard relies on.
| * the stale pushed map indexes recorded by the MapOutputTracker, which are non-empty only | ||
| * when reducer fallback marking is enabled. | ||
| * | ||
| * Each invocation gets its own SparkContext via [[withSpark]] so the caller can loop without |
There was a problem hiding this comment.
Nit:
The unqualified member does not resolve from this suite.
| *Each invocation gets its own SparkContext via [[withSpark]] so the caller can loop without | |
| *Each invocation gets its own SparkContext via [[LocalSparkContext.withSpark]] so the caller can loop without |
There was a problem hiding this comment.
Done. Changed [[withSpark]] -> [[LocalSparkContext.withSpark]].
| logWarning(s"Found stale pushed map indexes in merged block $shuffleBlockId from" + | ||
| s" ${address.host}:${address.port}, falling back to fetch the original blocks") | ||
| private[spark] def isStaleChunk(blockId: ShuffleBlockChunkId): Boolean = { | ||
| val staleMapIndexes = mapOutputTracker.getStaleMapIndexes(blockId.shuffleId) |
There was a problem hiding this comment.
Non-blocking:
This runs once per fetched chunk, while MapOutputTrackerWorker.getStaleMapIndexes allocates a new HashSet and copies every stale index on each call. A partition with C chunks and S stale maps therefore copies O(C * S) entries in the reducer fetch path. Please move the intersection behind a tracker query that inspects the published snapshot without returning a defensive copy.
There was a problem hiding this comment.
Done. Added intersectsStaleMapIndexes(shuffleId, bitmap) to MapOutputTracker (trait + master + worker) and ShuffleStatus, which inspects the published stale snapshot via iterator + bitmap.contains without returning a defensive copy. isStaleChunk now delegates to it, dropping the O(C * S) copy on the reducer fetch path.
getStaleMapIndexes is kept for the driver-side propagation serialization path, where a copy is still appropriate.
| // Both switches enabled: the late attempt is marked stale regardless of stage determinism | ||
| // (the test does not register an indeterminate ShuffleMapStage, so detectAllStages is | ||
| // required here). | ||
| (true, true, true), |
There was a problem hiding this comment.
Non-blocking:
This matrix bypasses both contracts it needs to protect: the disabled row sets false explicitly, and the enabled row turns on detectAllStages. Leave the fallback setting unset in one case, and add an enabled/detect-all-disabled case with a registered stage whose runtime indeterminacy changes after manager construction; those cases catch the current default mismatch and stale constructor snapshot.
There was a problem hiding this comment.
Done. The matrix now takes Option[Boolean] so either switch can be left unset:
(None, None, expectStale=false)— exercises the real default (no explicit config set); catches the default mismatch.(Some(true), Some(false), expectStale=true, runtimeIndeterminateAfterConstruction=true)— enabled / detect-all-disabled, with a registeredShuffleMapStagewhoseisChecksumMismatchedis set afterTaskSetManagerconstruction; catches the cached-snapshot issue (the indeterminate-only path).
The helper registers a real ShuffleMapStage in dagScheduler.stageIdToStage so the reference resolves, matching production.
…erminacy, stale-set lifecycle Address cloud-fan's review (2 blocking, 2 non-blocking, 2 nits) on apache#58008 plus a related stale-set lifecycle bug found during review. Blocking: - spark.shuffle.push.stale.fallback.enabled registered default true -> false, matching its "Default false" doc and the opt-in contract (default true re-enabled fetch amplification for indeterminate stages). - TaskSetManager no longer caches stage indeterminacy as a Boolean at construction. It now holds the ShuffleMapStage reference and re-evaluates isStaticallyIndeterminate || isRuntimeIndeterminate when a late duplicate result arrives, so a checksum mismatch set by DAGScheduler during the stage is observed. Non-blocking: - isStaleChunk delegates to a new intersectsStaleMapIndexes(shuffleId, bitmap) tracker query that inspects the published stale snapshot without returning a defensive copy per fetched chunk (was O(C * S) copies on the reducer fetch path). - Test matrix now covers the real default (config unset) and an enabled/detect-all-disabled case with runtime indeterminacy toggled after TaskSetManager construction, catching both the default mismatch and the cached indeterminacy snapshot. Related fix: - unregisterAllMapAndMergeOutput now clears ShuffleStatus.staleMapIndexes. Without this, stale marks from a previous attempt survived a stage retry and caused spurious chunk fallback on the retried attempt's fresh push data. The worker-side stale cache is refreshed via the existing epoch bump. Nits: - "identical output" -> "the same data set" (UNORDERED permits reordering). - [[withSpark]] -> [[LocalSparkContext.withSpark]] (unqualified member did not resolve). Co-Authored-By: Claude <noreply@anthropic.com>
gaoyajun02
commented
Aug 25, 2026
Addressed all 6 review items in
PTAL @cloud-fan |
What changes were proposed in this pull request?
Follow-up to SPARK-57491 (stale push-based shuffle data detection). SPARK-57491's reducer fallback was too coarse; this PR makes it chunk-granular and opt-in.
Why are the changes needed?
Speculation is common on large stages in production, so duplicate map attempts that both push are routine. SPARK-57491's fallback had too wide a blast radius:
Net effect: a stale partition now costs roughly 1 chunk's worth of extra fetches instead of a full merged block, and only indeterminate stages pay it.
Does this PR introduce any user-facing change?
Yes — two new optional configs (both default false): spark.shuffle.push.stale.fallback.enabled and spark.shuffle.push.stale.detectAllStages.enabled.
How was this patch tested?
Added/updated unit tests:
Was this patch authored or co-authored using generative AI tooling?
Yes. Generated-by: Claude Code (Anthropic)