mx.distributed ring backend: gradient-shaped CPU-stream all_sum deadlocks probabilistically (~3-4%/call) across a training loop; GPU-stream variant crashes deterministically at ~5s
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 28.5k
- Forks
- 2.3k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 62
Description
Summary
Running a realistic LoRA-training-shaped gradient reduction (mlx.nn.utils.average_gradients)
repeatedly across a --backend ring cluster of ≥3 ranks fails in one of two ways depending
on which stream the collective runs on, and neither is a training-code bug — both reproduce
in a self-contained script with no model, dataset, or third-party library involved:
- GPU (default) stream + a real-world timing skew between ranks: the ~5s Metal command-buffer
watchdog kills the waiting rank deterministically (kIOGPUCommandBufferCallbackErrorTimeout),
tearing down the ring. - CPU stream (
communication_stream=mx.cpu, the documented workaround for the above):
the watchdog no longer fires, but the ring's socket exchange deadlocks outright on a
measured ~3-4% of calls, independent of skew, tree shape, chunking, or a pre-barrier —
ranks sit blocked forever inrecvfrom/sendtoinsidelibmlx.dylib. Over a training run
of realistic length (hundreds of gradient syncs), this failure rate makes the run's survival
probability effectively zero.
Both are visible in this project's real training loop (mlx_vlm's sft_trainer.py, an
independent third-party package, not the source of the bug) but reproduce identically in the
attached minimal script with a synthetic model and synthetic gradients — no mlx_vlm, no
dataset, no external dependency of any kind.
Environment
- Hardware: 4x Apple M4 Mac mini, connected via Gigabit LAN
- OS: macOS 26.3 (build 25D125)
- mlx: 0.32.2 (
pip show mlx) - Backend:
mlx.launch --backend ring, numeric IPs, one process per host
Repro 1 — deterministic GPU-stream watchdog kill
# gpu_stream_repro.py — run: mlx.launch --hosts <ip1>,<ip2> --backend ring -- python3 gpu_stream_repro.py
import time
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from mlx.nn.utils import average_gradients
group = mx.distributed.init()
rank, world_size = group.rank(), group.size()
model = nn.Linear(1024, 1024)
mx.eval(model.parameters())
optimizer = optim.Adam(learning_rate=1e-3)
loss_and_grad_fn = nn.value_and_grad(model, lambda m, x: m(x).sum())
x = mx.random.normal((64, 1024))
loss, grad = loss_and_grad_fn(model, x)
grad = average_gradients(grad) # default (GPU) communication_stream
optimizer.update(model, grad) # queued lazily on GPU
if rank == 1:
time.sleep(7) # simulate a real per-rank timing skew
t0 = time.perf_counter()
mx.eval(model.state) # <-- rank 0 dies here at ~5.0s:
print(f"[{rank}] eval done in {time.perf_counter()-t0:.2f}s")
Result: rank 0's mx.eval(model.state) raises kIOGPUCommandBufferCallbackErrorTimeout
at 5.01s (measured, repeatable) whenever a peer is delayed past ~5s before the collective
resolves — regardless of how small the actual payload is (this uses a single 1024x1024
linear layer, ~4MB of gradients).
Repro 2 — probabilistic CPU-stream deadlock
The documented fix for Repro 1 is to force the collective onto the CPU stream and eval it
explicitly before any GPU-stream node depends on it (materialize-then-reduce-then-update).
This does eliminate the watchdog kill — but repeating it in a loop, with a gradient tree
shaped like a real LoRA fine-tune (~500 small fp32 arrays, 32KB-384KB each, ~87MB total —
the exact shape average_gradients's default 32MiB chunking produces from a real
nn.value_and_grad output), deadlocks intermittently:
# cpu_stream_loop_repro.py — run at >=3 ranks:
# mlx.launch --hosts <ip1>,<ip2>,<ip3>[,<ip4>] --backend ring -- python3 cpu_stream_loop_repro.py [delay_s] [iters]
import sys, time
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
from mlx.nn.utils import average_gradients
from mlx.utils import tree_flatten
group = mx.distributed.init()
rank, world_size = group.rank(), group.size()
delay_s = float(sys.argv[1]) if len(sys.argv) > 1 else 7.0
n_iters = int(sys.argv[2]) if len(sys.argv) > 2 else 30
# Synthesize a gradient tree matching a real ~22M-param LoRA adapter's shape:
# 3 groups of 195/195/114 fp32 arrays that average_gradients's 32MiB
# _group_by_size chunking produces from a real training step's grad tree.
sizes = []
for count, total_mb in ((195, 33.82), (195, 33.55), (114, 19.92)):
total = int(total_mb * 1e6)
tail = 280_000
base = (total - tail) // (count - 1)
sizes.extend([base] * (count - 1) + [total - base * (count - 1)])
model = nn.Linear(64, 64)
mx.eval(model.parameters())
optimizer = optim.Adam(learning_rate=1e-3)
loss_and_grad_fn = nn.value_and_grad(model, lambda m, x: m(x).sum())
x = mx.random.normal((32, 64))
for it in range(1, n_iters + 1):
_, g = loss_and_grad_fn(model, x)
mx.eval(g) # Phase 1: materialize local GPU work
grads = [mx.random.normal((s // 4,)) for s in sizes]
mx.eval(grads)
if it == 1 and rank == 1 and delay_s > 0:
time.sleep(delay_s) # optional: simulate arrival skew
t0 = time.perf_counter()
with mx.stream(mx.cpu): # Phase 2: CPU-stream-only reduction
reduced = average_gradients(grads, communication_stream=mx.cpu)
mx.eval(reduced)
mx.synchronize(mx.cpu)
print(f"[{rank}] iter {it}: reduction done in {time.perf_counter()-t0:.2f}s", flush=True)
pflat = tree_flatten(model.parameters()) # Phase 3: GPU update, unrelated to the bug
grads_tree, offset = {}, 0
for k, p in pflat:
grads_tree[k] = reduced[0][offset: offset + p.size].reshape(p.shape)
offset += p.size
optimizer.update(model, grads_tree)
mx.eval(model.state)
print(f"[{rank}] all {n_iters} iterations completed")
Observed across our own runs at 4 ranks (master + 3 workers), same script/config, repeated:
| run | iterations completed before hang | injected skew |
|---|---|---|
| 1 | 0 (hung on iteration 1) | 7s on rank 1 |
| 2 | 4 (passed, no hang in 4 iters) | 7s |
| 3 | 32 (passed clean) | none |
| 4 | 24 (passed clean) | 7s |
| 5 | 12 (hung entering iter 4) | 7s, forced single 87MB group instead of 3 chunks |
| 6 | 12 (hung entering iter 4) | 7s, plus a small scalar all_sum pre-barrier before each reduction |
| 7 | 36 (hung entering iter 10) | none — the "no skew" condition also hangs |
Cumulative: 4 hangs across ~124 successful CPU-stream collectives at 4 ranks (~3-4%
per collective), independent of every variable we controlled for: gradient tree shape
(single tensor / 6 large arrays / ~500 small arrays), chunking (default 32MiB grouping vs
forcing one flat group), rank-arrival skew (7s vs none), and a rendezvous barrier
immediately before the reduction. At 2 ranks we have never observed a hang across any run.
When a hang occurs, sample <pid> on every host shows the ring's worker threads blocked in
__recvfrom/__sendto inside libmlx.dylib, with no CPU spin and no forward progress —
this is a genuine stuck-forever deadlock, not a slow collective (confirmed by waiting well
past the point any successful collective in the same run had ever taken).
At a ~3.5%/collective failure rate, a training run of realistic length (roughly 800+
gradient syncs for one epoch of a modest fine-tune) has a survival probability on the order
of 1e-13 — which is consistent with what we observed operationally: every real multi-hour
distributed training attempt eventually died at some point in the run, never at a fixed
iteration, while short smoke tests (a few dozen iterations) usually didn't hit it.
What we ruled out
- Not a tree-shape issue: single tensor, 6 large arrays, and ~500 small arrays (matching a
real LoRA adapter's exact shape) all pass individually; only repeating the reduction in a
loop surfaces the hang, and even then non-deterministically. - Not the default 32MiB chunking specifically: forcing one flat 87MB group (single
concatenate → singleall_sum→ single split, viaaverage_gradients(..., all_reduce_size=2**30))
hangs the same way. - Not purely a rank-arrival-skew race: a scalar pre-barrier
all_sumimmediately before the
big reduction visibly absorbs the arrival skew (0.004-0.872s barrier times observed) and
the very next big reduction still deadlocks. - Not skew-dependent at all: a fully skew-free loop (
time.sleepremoved entirely) also
hung, at collective ~37 of an intended ~90.
Ask
This looks like a race condition inside the ring backend's socket-level exchange for
larger payloads (tens of MB) under mx.distributed.all_sum/average_gradients, independent
of which mx.Stream the call is issued on — the GPU-stream case just fails faster and more
visibly (watchdog kill) than the CPU-stream case (silent deadlock). We're happy to share the
full test harness (adds a few more probe modes on top of the two scripts above — chunking
overrides, pre-barrier toggle, etc.) if useful for reproducing on your end.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running gpu_stream_repro.py and cpu_stream_loop_repro.py with the documented mlx.launch commands, then trace mx.distributed.all_sum and average_gradients through the ring backend. Reproduce both the GPU watchdog timeout and repeated CPU-stream hang across at least three ranks. Done means the supplied multi-rank loops complete without timeout or deadlock.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, python
- Domain
- distributed-systems, machine-learning, networking
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100