Skip to content

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
mainfrom
fix/pp-last-layer-gather
Closed

AlexCheema wants to merge 1 commit into
mainfrom
fix/pp-last-layer-gather

Conversation

@AlexCheema

Copy link
Copy Markdown
Contributor

The bug

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:

output = mx.distributed.all_gather(output, group=self.group)[-output.shape[0] :]
mx.eval(output)

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 in PipelineFirstLayer). 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:

  • exo's warmup. On the first decode step, the first stage waited in the gather while the second stage was still compiling its kernels (that machine's CPU was also busy with another job). The runner failed and the instance never became ready.
  • Long chunks outside prefill. In the correctness harness (teacher forcing, is_prefill off 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:

gathered = mx.distributed.all_gather(output, group=self.group)
mx.eval(gathered)
output = gathered[-output.shape[0] :]
mx.eval(output)

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:

  • Warmup: the instance comes up, with the other machine's CPU as busy as when it failed.
  • Long chunks: the harness run that failed (teacher forcing with 4096-token chunks, after greedy decoding) passes: haystack prompts of 3.3k and 7.5k tokens.
  • Serving: needle recall through /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's pipeline.py and with this change, alternating before, after, before, after. Greedy decoding was 128 tokens per prompt, plus teacher forcing over the prompts and continuations.

model prompts decode tok/s per prompt, before after greedy tokens and log-probs
the 816B MoE, 4-bit 4 10.0, 9.9, 10.0, 9.9 (both runs) 9.9, 9.9, 10.0, 9.9 / 10.0, 9.9, 10.0, 9.9 bit-identical: greedy tokens, their top-k log-probs, and teacher-forced log-probs at 1,256 positions
Qwen3.6-35B-A3B, 4-bit 5 67.7, 68.1, 67.9, 69.8, 71.1 / 67.4, 68.2, 68.0, 69.9, 71.3 67.5, 67.9, 68.0, 69.8, 71.0 (second run) bit-identical (954 teacher-forced positions)
  • The first "after" run of Qwen3.6 started with a cold kernel cache: 46.2 tok/s on its first prompt, then 65.8, 67.9, 69.9 and 70.9.
  • A fifth prompt with the large model, a 109-token tool call, varied between 2 and 4.6 tok/s from run to run in both versions, so it is left out of the table. Its outputs are bit-identical too.

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 what mx.eval is 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 into PipelineLastLayer (on the CPU, before any GPU work). Its PipelineLastLayer still 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 of PipelineLastLayer, and whichever lands second rebases trivially.

Test plan

  • uv run basedpyright (0 errors), uv run ruff check, nix fmt (no changes).
  • pytest as CI runs it (pytest src -m "not slow" --import-mode=importlib) on a Mac Studio with MLX: 2504 passed, 3 skipped.
  • test_pipeline_gather.py including 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).
  • The A/B runs above, and the large model's warmup, long-chunk and needle runs on two Mac Studios.

🤖 Generated with Claude Code

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).
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>
@AlexCheema

Copy link
Copy Markdown
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant