Skip to content

[BUG] Distributed pipeline generation: Metal fence handoff deadlocks under MLX_METAL_FAST_SYNCH=1 (TCP ring; orphaned fence_wait kernel locks GPU until reboot) and hits the ~5s GPU watchdog when unset (kIOGPUCommandBufferCallbackErrorTimeout at ~7.3k tokens) #3830

Description

@geekybraindev

Summary

Running pipeline-style distributed inference over the plain TCP ring backend silently deadlocks when large activation tensors cross the GPU-stream ↔ CPU-communication-stream fence handoff with MLX_METAL_FAST_SYNCH=1. Both ranks sit at ~100% CPU forever, memory flat, no error. Killing the wedged processes releases their memory but leaves the GPU compute engine occupied by the orphaned fence_wait spin kernel — after that, any Metal eval from any process on the machine hangs, and only a reboot recovers (in one instance the machine escalated to a full userspace freeze). We believe this is the same family as #3142 / #3141, but note the fence layer is transport-independent (ring.cpp and jaccl.cpp declare the identical CPU communication stream), so the "JACCL" framing of #3142 undersells the blast radius: plain-Ethernet TCP-ring users hit it too. This matches silent multi-node hangs long reported in the wild (e.g. mlx_lm.server --pipeline over 10 GbE wedging mid-generation after ~2k tokens on ~60-layer models; exo-explore/exo#1666 / #1853 / #1726).

With the env var unset, the deadlock disappears — but long generations instead die at the macOS GPU watchdog (details below). Distributed pipeline users currently choose between an untimed spin that deadlocks and a driver watchdog that kills their process.

Environment

  • Mac Studio M2 Ultra 192 GB + MacBook Pro M5 Max 128 GB, macOS 26.5.1 (25F80)
  • mlx 0.31.2, mlx-lm 0.31.3 (PyPI), Python 3.13
  • Reproduces on 2 ranks on ONE machine over a loopback TCP ring, and across two Macs over 10 GbE

Minimal repro (no model needed, ~30 s)

fence_stress.py (~90 lines, happy to attach): 2-rank loopback ring; per iteration it replays mlx-lm's per-token pipeline pattern — GPU compute → CPU-stream send/recv_like/all_gather → GPU compute, with mx.async_eval double-buffering exactly like mlx_lm/generate.py.

echo '[["127.0.0.1:19100"],["127.0.0.1:19101"]]' > /tmp/hostfile.json
# rank 1 then rank 0, both with:
MLX_HOSTFILE=/tmp/hostfile.json MLX_RANK=<r> MLX_METAL_FAST_SYNCH=1 \
  python fence_stress.py --log /tmp/rank<r>.log --rows 256 --dim 5120 --layers 4 --scalar-allsum
  • --rows 1 (10 KB messages): clean for 2,000,000 iterations (also clean at 500k with heavier compute, and at 217 ms/iter GPU residency for 7k iterations).
  • --rows 256 (2.6 MB messages ≈ a ~256-token prompt's prefill activation): deadlocks within seconds.

What the wedge looks like inside (sample of both ranks)

  • Main thread parked: mlx::core::eval → array::wait → Event::wait → -[IOSurfaceSharedEvent waitUntilSignaledValue:timeoutMS:]
  • Both SocketThread::worker threads idle in condition_variable::wait — zero pending socket tasks; the network layer is NOT stalled.
  • A CPU-stream thread burning 100% in the fast-fence spin (fence.cpp:65 while (f.cpu_value()[0] < count) {} — hot frames show -[IOGPUMetalBuffer contents]), waiting on a timestamp the GPU side never bumps.
  • GPU utilization pinned at 100% — the fence_wait kernel (fence.metal while(1) on a non-atomic volatile load, no timeout) occupying the queue. Operator-visible tell: GPU gauges read pegged while producing nothing.

Second bug: GPU poisoning after process death

After kill -9 of both wedged ranks (their wired memory IS released): a trivial mx.eval from a fresh process hangs forever; killing every remaining GPU-client process does not recover; only reboot restores the GPU. On a poisoned machine every subsequent distributed run "wedges" instantly at startup — we suspect this causes serial misdiagnosis in the wild.

With MLX_METAL_FAST_SYNCH unset: deterministic watchdog crash on long generations

The deadlock is gone (the synthetic killer config ran 100,000/100,000 iterations clean; real mlx_lm.server --pipeline runs complete normally). But long single generations die deterministically — three runs at 7,325 / 7,391 / 7,280 tokens (DeepSeek-V2.5-1210-4bit, 2 ranks over 10 GbE) with:

libc++abi: terminating due to uncaught exception of type std::runtime_error:
[METAL] Command buffer execution failed: Caused GPU Timeout Error
(00000002:kIOGPUCommandBufferCallbackErrorTimeout)

thrown from mlx::core::gpu::check_error in the completion handler (SIGABRT), rank 1 first. Mechanism: the slow path parks committed command buffers on encodeWait for cross-rank fence data; the macOS ~5 s GPU watchdog counts waiting as running and kills the buffer — the GPU is actually idle when it dies. (Same class as the stream=mx.cpu workaround discussion in #3207, but here the waiting buffers are the compute graph's fence waits, which users can't move off the GPU stream.)

We also confirmed the FAST_SYNCH deadlock is network-independent: it reproduces on loopback and on a verified single-interface 10 GbE link (after we eliminated an ARP-flux problem from dual-homed hosts — which the ring's missing SO_KEEPALIVE had been converting into separate silent stalls).

Static observations that may help localize

  1. fence.metal fence_wait spins on a plain volatile load (not a Metal atomic) with thread_scope_system fences only; the CPU signals via std::atomic_uint store (fence.cpp:112). No atomic RMW forces cache-line ownership transfer; a missed invalidation is an unrecoverable spin — and there is no timeout/error path in either fast-path wait (the slow path's waitUntilSignaledValue at least dies loudly).
  2. The fast GPU wait is dispatched into the current open command encoder without end_encoding() (fence.cpp:87-94) and buffers batch many ops (MLX_MAX_OPS_PER_BUFFER), so a spinning wait can block a later-encoded fence_update the peer rank transitively needs through the ring round-trip — a cross-rank ordering deadlock whose probability rises with tensor size / ops-per-buffer, matching the observed size dependence.
  3. Unrelated but found while auditing ring.cpp (happy to file separately): the SocketThread worker reads/mutates sends_/recvs_ and task.buffer/size outside queue_mutex_ (ring.cpp:233-259 — a data race); recv()==0 peer-close is misclassified as EAGAIN via stale errno; after 10 errors the worker exits and orphans all pending promises; no timeout or SO_KEEPALIVE anywhere — so ANY distributed stall is silent and permanent for mlx_lm.server users.

Asks

  • Do not leave GPU command buffers occupied or committed across fence waits: split/commit before the wait on the slow path (fixes the watchdog kills), and end_encoding() before the fast GPU wait (breaks the cross-rank ordering cycle).
  • A bounded wait + error path in both fast-path spins (GPU kernel and CPU loop) — converts a silent machine-killer into a diagnosable exception.
  • We can provide: the stress harness, full sample captures of both ranks mid-wedge (synthetic and mlx_lm.server), macOS crash reports for the watchdog kills, and the two-machine 10 GbE reproduction recipe.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions