Metal: distributed collectives run on the CPU stream, and with the default fence each crossing costs ~180 µs (MLX_METAL_FAST_SYNCH: ~4 µs)
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 28.5k
- Forks
- 2.3k
- Avg merge
- 3d 8h
- Merged PRs (30d)
- 62
Description
Summary
On the Metal backend AllReduce, AllGather, Send, Recv and ReduceScatter
have no eval_gpu and throw (mlx/backend/metal/distributed.cpp:17-18, v0.32.2),
while the CUDA backend implements them (mlx/backend/cuda/distributed.cu:12). On
Metal, then, every collective in a sharded forward becomes a CPU<->GPU stream
crossing, and eval synchronizes those with a Fence (mlx/transforms.cpp, the
fences[...].wait(...) / Fence{stream} paths).
With the default fence a crossing costs ~180 µs on my setup; with
MLX_METAL_FAST_SYNCH=1 it costs ~4 µs. I wanted to write the size of that gap
down somewhere, since the flag is easy to miss and isn't mentioned in the
distributed docs.
Single-process repro
M3 Ultra, macOS 26, mlx 0.32.2. Chain of 101 GPU blocks with one trivial op
between each; the two arms are interleaved and I take the min of 40, because
measuring them sequentially makes the difference very sensitive to background
load.
# run twice: once plain, once with MLX_METAL_FAST_SYNCH=1
import time, os, mlx.core as mx
N, REPS = 101, 40
x0 = mx.ones((1, 1, 4096), dtype=mx.bfloat16)
W = mx.random.normal((4096, 2048)).astype(mx.bfloat16)
V = mx.random.normal((2048, 4096)).astype(mx.bfloat16)
one = mx.array(1.0, dtype=mx.bfloat16)
mx.eval(x0, W, V, one)
def run(stream):
x = x0
for _ in range(N):
x = mx.multiply(((x @ W) @ V).astype(mx.bfloat16), one, stream=stream)
mx.eval(x)
def timed(stream):
t0 = time.perf_counter()
run(stream)
return (time.perf_counter() - t0) * 1e6 / N
for _ in range(5):
run(mx.gpu); run(mx.cpu)
G, C = [], []
for _ in range(REPS):
G.append(timed(mx.gpu)); C.append(timed(mx.cpu))
g, c = min(G), min(C)
print(f"FAST_SYNCH={os.environ.get('MLX_METAL_FAST_SYNCH', '0')} "
f"gpu {g:.1f} us/op, cpu {c:.1f} us/op, crossing {c - g:.1f} us")
Three runs of each:
| between blocks | default | MLX_METAL_FAST_SYNCH=1 |
|---|---|---|
| a GPU-stream op | 162-164 µs/op | 162-164 µs/op |
| a CPU-stream op | 344-347 µs/op | 167.6 µs/op |
| crossing penalty | ~181 µs | ~4 µs |
Two processes, real collectives
2 ranks over a Thunderbolt-RDMA (jaccl) group, all_sum on [1,1,4096] bf16
interleaved with GPU compute, 101 chained reduces per step, 1000 steps:
| default | fast | |
|---|---|---|
| step p50 | 30.7 ms | 2.98 ms |
| step p99 | 118.8 ms | 3.46 ms |
| per collective | 303.7 µs | 29.5 µs |
An isolated all_sum of that shape is 20.4 µs, and 101 chained with no GPU work
between them are 8.2 µs each, so the transport isn't the limiting factor here.
Where the cost seems to go
A standalone Swift probe (no MLX), N=101, min of 60, µs/op:
| µs/op | |
|---|---|
| all dispatches in one encoder | 15.8 |
| one encoder per dispatch, no events | 15.6 |
pure MTLSharedEvent ping-pong, no encoders |
1.8 |
| dispatch + close + signal + wait | 144.5 |
So closing and reopening an encoder is close to free, and the event round trip is
~1.8 µs; the remaining ~127 µs behaves like the GPU draining and refilling because
encodeWaitForEvent sits mid-command-buffer — it grows with work in flight
(115.8 / 152.4 / 182.1 µs as the dispatch grows). The fast fence avoids that by
dispatching a one-thread kernel that spins on a shared-memory counter inside the
already-open encoder (mlx/backend/metal/fence.cpp, use_fast), whereas the
default path goes through CommandEncoder::signal_event / wait_event
(mlx/backend/metal/device.cpp:499-509), both of which call end_encoding()
first. Happy to post the probe source if it would be useful.
Questions
- Would it be worth documenting
MLX_METAL_FAST_SYNCHin the distributed docs?
Someone benchmarking distributed MLX on Metal today will measure the default
path without necessarily realising there is another one. - Is selecting the fast fence automatically on Metal 3.2+ something you would
consider, or does #3142 (GPU stuck in thefence_waitloop with fast synch and
the JACCL backend) keep it opt-in deliberately? I ran 1000 steps x 101
collectives on a 2-rank group without a hang, but that is well short of the
4-node case in that report, so I may simply not have hit it. One small
thought, offered tentatively: a max-iteration bound infence_waitwould turn
that failure into an error rather than a wedged GPU — though I realise it
would not address the coherence issue itself. - Longer term, is a Metal
eval_gpufor collectives something on the roadmap, so
they can sit in the GPU timeline the way they do on CUDA?
Environment: Apple M3 Ultra (512 GB), macOS 26, mlx 0.32.2, jaccl backend over
Thunderbolt RDMA. Line numbers are against v0.32.2.
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 reading mlx/backend/metal/distributed.cpp alongside mlx/backend/cuda/distributed.cu, then trace the fence paths in mlx/transforms.cpp, mlx/backend/metal/fence.cpp, and mlx/backend/metal/device.cpp:499-509. Review the distributed documentation and issue #3142 before choosing a scope. Done requires a maintainer-approved decision on documentation, fence selection, or GPU collectives, with corresponding tests or benchmarks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- backend, distributed-systems, performance
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100