Repository navigation
Pipeline parallelism: wait for the gathered output on the CPU, not in a GPU command buffer - #2542
Closed
AlexCheema wants to merge 1 commit into
Closed
AlexCheema wants to merge 1 commit into
AlexCheema wants to merge 1 commit into
Conversation
Outside prefill, a pipeline stage gathers the last stage's output from every rank and
slices its own copy. The slice ran in a GPU command buffer that waited for the gather, so
the GPU waited for the slowest rank. When that wait outlasts the GPU watchdog, the command
buffer fails ("Caused GPU Timeout Error") and the runner crashes.
An 816B-parameter MoE model (4-bit, two Mac Studios, 2x pipeline parallel over the ring)
hit it twice:
- exo's warmup: the first decode step of the first stage waited while the second stage was
still compiling its kernels, and the instance never became ready;
- teacher forcing a 3,284-token chunk in a correctness harness, where the first stage
waits for the second stage's ~5 s of compute (2 of 2 runs failed).
The gather is now evaluated on its own, as the send and the receive already are, so the
wait happens on the CPU, which has no watchdog. Warmup succeeds, the harness run passes
with 4,096-token chunks, and exo serves 64k-token prompts on the pair. Greedy tokens and
log-probs are bit-identical before and after, and decode speed is unchanged.
test_pipeline_gather.py checks that the gather is evaluated before it is sliced, and
(slow) runs two ranks over the ring with the last stage 8 s late: without the fix the
first rank fails with the GPU timeout.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
cdvankammen
added a commit
to cdvankammen/exo
that referenced
this pull request
Nov 1, 2026
…command buffer Port of upstream PR exo-explore#2542. Outside prefill, every pipeline stage gathers the last stage's output from all ranks and keeps its own copy. PipelineLastLayer built the slice on top of the gather and evaluated the slice. The gather runs on the CPU, and the slice runs in a GPU command buffer that waits for it. So the GPU waits for the slowest rank. When that wait outlasts the GPU watchdog (about 5 s on Apple silicon), Metal fails the command buffer with 'Caused GPU Timeout Error', and the runner crashes. The fix evaluates the gather on its own first, then slices. The send and the receive already avoid this (each is evaluated on its own, so its wait happens on the CPU, which has no watchdog). The gather was the one collective left inside a GPU command buffer. Any pipeline instance whose later stage can fall more than a few seconds behind is exposed (e.g. 816B MoE on 2x Mac Studios hitting it during warmup and during long chunks outside prefill).
This was referenced Nov 1, 2026
AlexCheema
added a commit
that referenced
this pull request
Nov 3, 2026
The ring's collectives run on a CPU stream. A GPU op that used the result
of an unfinished collective in the same eval made a GPU command buffer
wait for the network, that is, for the slowest rank. macOS fails a
command buffer that runs for more than a few seconds ("[METAL] Command
buffer execution failed: Caused GPU Timeout Error"), so a rank that fell
that far behind (a stalled link, a busy Mac, a later pipeline stage still
compiling its kernels or computing a long chunk) crashed the ranks
waiting on it, failing every request in flight.
Each such collective is now evaluated on its own first, as the pipeline's
send and receive already are, so the wait happens on the CPU, which has
no watchdog; the GPU op then only sees data that is already there:
- PipelineLastLayer (sharding/pipeline.py): outside prefill, every stage
gathers the last stage's output and slices its own copy;
- mx_all_gather_tasks: the gathered task ids are reshaped;
- KVPrefixCache._over_limit: the gathered eviction flags are reshaped and
maxed;
- warmup_inference: the gathered cancel-check intervals are maxed.
Values are unchanged.
An 816B-parameter MoE model (4-bit, two Mac Studios, 2x pipeline parallel
over the ring) hit the pipeline case twice: in exo's warmup, the first
stage's first decode step waited while the second stage was still
compiling its kernels, and the instance never became ready; and teacher
forcing a 3,284-token chunk, where the first stage waits for the second
stage's ~5 s of compute (2 of 2 runs failed). With the fix, warmup
succeeds, the 4,096-token chunks pass, exo serves 64k-token prompts on
the pair, greedy tokens and log-probs are bit-identical before and after,
and decode speed is unchanged.
Tests:
- test_pipeline_gather.py checks that the gather is evaluated before it
is sliced, and (slow) runs two ranks over the ring with the last stage
8 s late;
- test_slow_peer.py (slow) runs two ranks over the ring with one rank
joining an all_gather 8 s late, for the pipeline decode step, task
agreement and the prefix cache's eviction check.
Without the fix the waiting rank fails with the GPU timeout in each slow
case; with it, both ranks come through with the right result.
This folds #2542 (the pipeline part, at its new location) into #2510.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Folded into #2510, which is now rebased on main and carries this change and its test, together with the same fix for task agreement, the prefix cache and warmup. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Outside prefill, every pipeline stage gathers the last stage's output from all ranks and keeps its own copy.
PipelineLastLayerbuilt the slice on top of the gather and evaluated the slice:The gather runs on the CPU, and the slice runs in a GPU command buffer that waits for it. So the GPU waits for the slowest rank. When that wait outlasts the GPU watchdog (about 5 s on Apple silicon), Metal fails the command buffer with
Caused GPU Timeout Error, and the runner crashes. The send and the receive already avoid this: each is evaluated on its own, so its wait happens on the CPU, which has no watchdog (see the comment inPipelineFirstLayer). The gather was the one collective left inside a GPU command buffer.Any pipeline instance whose later stage can fall more than a few seconds behind is exposed. An 816B-parameter MoE model (4-bit, 533 GB) on two Mac Studios (M3 Ultra, 512 GB), as 2x pipeline parallel over the ring, hit it two ways:
is_prefilloff so every rank gets logits), a 3,284-token chunk makes the first stage wait for the second stage's ~5 s of compute. It failed in 2 of 2 runs, with 4096- and with 2048-token chunks.The fix
The gather is evaluated on its own first, then sliced:
Nothing else changes: the same arrays are computed, in the same order.
Evidence
On the same pair (Thunderbolt, 2x pipeline parallel over the ring), with the 816B model:
/v1/chat/completions: 7/7 at 4.5k, 16k and 64k tokens, with no timeouts.What it costs
Nothing measurable, and the outputs don't change. The same 2x pipeline-parallel runs over the ring were made with
main'spipeline.pyand with this change, alternating before, after, before, after. Greedy decoding was 128 tokens per prompt, plus teacher forcing over the prompts and continuations.Tests
test_pipeline_gather.py:test_a_stage_evaluates_the_gather_before_slicing_it: a structural check with the gather and the send mocked. It records whatmx.evalis given and requires the full gathered array before its slice. It fails before the fix: only the slice is evaluated.test_a_last_stage_slower_than_the_gpu_watchdog_does_not_fail_the_others(slow, Metal only): two ranks over the ring on one Mac. The last stage is 8 s late reaching the gather, longer than the GPU watchdog. Without the fix, the first rank fails with[METAL] Command buffer execution failed: Caused GPU Timeout Error; with it, both ranks finish with the right values.Overlap with #2521
#2521 ("Pipeline parallelism: build a rank's decode graph while its input is still in flight") also changes
sharding/pipeline.py. It moves the decode-step receive's wait intoPipelineLastLayer(on the CPU, before any GPU work). ItsPipelineLastLayerstill slices the gather in a GPU command buffer, so it has this bug too. Its slow test (a sender 8 s late) doesn't cover it: the delayed rank there is the first, which hasn't built its graph yet. The two changes touch different lines ofPipelineLastLayer, and whichever lands second rebases trivially.Test plan
uv run basedpyright(0 errors),uv run ruff check,nix fmt(no changes).pytest src -m "not slow" --import-mode=importlib) on a Mac Studio with MLX: 2504 passed, 3 skipped.test_pipeline_gather.pyincluding the slow test: both pass with the fix and both fail without it (the structural one finds no evaluation of the gathered array; the slow one fails with the GPU timeout).🤖 Generated with Claude Code