Skip to content

fix(mlx sharding): bypass deadlocking pipeline prefill + chunk stream_generate + CPU all_gather (#2108) - #2269

Open
adamteale wants to merge 4 commits into
exo-explore:mainfrom
adamteale:fix/pipeline-prefill-deadlock-2108
Open

fix(mlx sharding): bypass deadlocking pipeline prefill + chunk stream_generate + CPU all_gather (#2108)#2269
adamteale wants to merge 4 commits into
exo-explore:mainfrom
adamteale:fix/pipeline-prefill-deadlock-2108

Conversation

@adamteale

@adamteale adamteale commented Aug 18, 2026

Copy link
Copy Markdown

Fixes #2108 — multi-machine sharded inference deadlocks/hangs

Four changes that make multi-machine sharded inference (27B across 2 Macs over Thunderbolt 5) work for all prompt sizes + auto-recover from ring transport failures.

1. Bypass the deadlocking pipeline prefill (generate.py)

prefill_step_size = 100000 so pipeline_parallel_prefill is never taken — that path has a deeper chunked-ring send/recv deadlock that queue_sends=False + skipping the distributed callback do NOT resolve. All prompts use stream_generate.

2. Chunk stream_generate to avoid OOM (generate.py)

Pass prefill_step_size=2048 to stream_generate(...) (NOT the 100000 threshold). Without this the entire prompt is one forward pass → OOMs the 24GB peer Metal memory at ~6K+ tokens (kIOGPUCommandBufferCallbackErrorOutOfMemory).

3. Decode all_gather on the CPU stream (auto_parallel.py)

PipelineLastLayer decode: mx.distributed.all_gather(..., stream=mx.default_stream(mx.Device(mx.cpu))). mlx AllGather has no GPU implementation — on the GPU generation_stream it silently never executes → eval hangs. CPU stream collectives DO block (matching mx_barrier in utils_mlx.py).

4. Crash-on-ring-abort auto-recovery (supervisor.py)

The mlx ring transport prints [ring] Too many send/recv errors. Aborting... to stderr under sustained load (an errno 14 EFAULT on recv — a buffer-lifetime bug in the mlx C++ ring code) but does NOT crash the process — the in-flight collective hangs forever, leaving the runner alive but unresponsive. exo diagnostics were only acted upon on runner termination, so the hung runner was never recovered.

This wires a fatal-diagnostic callback: when the ring abort is detected on stderr, the supervisor clears the collected diagnostics (avoids a pre-existing pydantic validation flood in ErrorChunk sending — RunnerRingTransportError.message is rejected as extra_forbidden by the receiver) + kills the runner process → RunnerFailed → exo re-places the instance with a fresh distributed group (re-initialized ring) → the 27B recovers.

Verification (27B: M4 Max 36GB + M5 Pro 24GB)

  • Warmup: both ranks ready ✅
  • 10 tokens: 1-8s ✅ | 6373 tokens: 34s ✅ | 9382 tokens: 47s ✅
  • Agent (pi -p, ~18K context): 144s, correct TDD implementation ✅
  • Sustained load (120 calls, 5K-prompt): 118 OK, 2 ring breaks → both auto-recovered, exo API stayed up, 111 consecutive OK after warmup

What does NOT work (and why)

  • pipeline_parallel_prefill (bypassed) — the chunked ring send/recv deadlocks even with the queue_sends/callback fixes.
  • The mlx ring errno 14 EFAULT is an underlying C++ buffer-lifetime bug that cannot be fixed in Python — the crash-on-ring-abort fix makes the system resilient (auto-restart) rather than fixing the ring itself.
  • The pre-existing pydantic TaggedModel serialization bug (RunnerRingTransportError.message rejected as extra_forbidden) is worked around by clearing diagnostics; the real fix would be in the schema.

…xo-explore#2108)

Two bugs deadlocked pipeline_parallel_prefill for 2-node sharded inference:

1. queue_sends=True + async flush never submitted the send. With
   queue_sends=True, the PipelineLastLayer appends the send to
   _pending_prefill_sends and flush_prefill_sends submits it via
   mx.async_eval(sent). But no eval ever forces that async send, so it
   is never actually submitted and the receiver's recv deadlocks forever.
   Fix: queue_sends=False submits the send immediately in the
   PipelineLastLayer (like the warmup path), so send/recv pair correctly.

2. The distributed_prompt_progress_callback's all_sum/all_gather didn't
   block for the delayed peer. The callback (agree_on_cancellations +
   agree_on_tasks) runs collective ops (mx_any all_sum,
   mx_all_gather_tasks all_gather) on the rank doing the leading dummy
   (no forward, ~0s) while the peer is still doing the real chunk forward
   (~8s). The leading rank's collective recv returns stale/garbage data
   instead of blocking, and the forward rank's all_sum deadlocks. For
   single-request prefill (no cancellations, no new tasks) the callback
   is a no-op anyway (all counts are 0). Fix: pass None to skip it.

Verified: Qwen3.8-27B-OptiQ-4bit sharded across M4 Max (36GB) + M5 Pro
(24GB) over Thunderbolt 5 — 8K (4825 tok) 24s, 16K (8125 tok) 25s,
32K (16225 tok) 67s, all correct generation. Previously deadlocked at
the first pipeline callback for any prompt >= prefill_step_size.
…_generate + CPU all_gather (exo-explore#2108)

Three changes that together make multi-machine sharded inference (27B across
2 Macs over Thunderbolt) work for all prompt sizes:

1. generate.py prefill(): set prefill_step_size=100000 so the
   pipeline_parallel_prefill path is never taken. That path has a deeper
   chunked-ring send/recv deadlock that queue_sends=False + skipping the
   distributed callback do NOT resolve. All prompts use stream_generate.

2. generate.py prefill(): pass prefill_step_size=2048 to stream_generate
   (NOT the 100000 bypass threshold) so the prefill is chunked. Without this
   the entire prompt is processed in one forward pass, which OOMs the 24GB
   peer's Metal memory at ~6K+ tokens
   (kIOGPUCommandBufferCallbackErrorOutOfMemory).

3. auto_parallel.py PipelineLastLayer: run the decode all_gather on the CPU
   stream (stream=mx.default_stream(mx.Device(mx.cpu))). mlx's AllGather has
   NO GPU implementation, so on the GPU generation_stream it silently never
   executes and the eval hangs. The CPU stream's collectives DO block
   (matching mx_barrier in utils_mlx.py).

Verified: 27B sharded across M4 Max (36GB) + M5 Pro (24GB):
  - warmup: both ranks ready (50 tokens)
  - small prompt (~10 tokens): 8s, correct
  - 5K prompt (6373 tokens): 34s, correct
  - 8K prompt (9382 tokens): 47s, correct, no OOM
@adamteale adamteale changed the title fix(pipeline prefill): queue_sends=False + skip distributed callback (#2108) fix(mlx sharding): bypass deadlocking pipeline prefill + chunk stream_generate + CPU all_gather (#2108) Aug 18, 2026
…#2108)

The mlx ring transport prints '[ring] Too many send/recv errors.
Aborting...' to stderr but does NOT crash the process — the in-flight
collective hangs forever, leaving the runner alive but unresponsive.
exo's diagnostics were only acted upon on runner termination, so the
hung runner was never recovered.

This wires a fatal-diagnostic callback: when the ring abort is detected
on the runner's stderr, the supervisor kills the runner process →
RunnerFailed → exo re-places the instance with a fresh distributed
group (re-initialized ring). Verified-compiled; needs a sustained-load
run to confirm the auto-recovery.
…xo-explore#2108)

The ErrorChunk's diagnostics field fails pydantic validation for
RunnerRingTransportError (the message field is rejected as extra_forbidden
on the receiving side — a pre-existing TaggedModel serialization bug).
Clearing the diagnostics list before stopping the runner avoids the
validation flood that would otherwise wedge the supervisor + take down
the exo API.
@adamteale
adamteale force-pushed the fix/pipeline-prefill-deadlock-2108 branch from 58a18d0 to 375b0e7 Compare August 19, 2026 01:57
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.

Pipeline-parallel deadlock at 32+ concurrent requests — all_gather collective blocks indefinitely

1 participant