Skip to content

feat: sequence-parallel Ring Attention for MLX - #2255

Open
abendrothj wants to merge 6 commits into
exo-explore:mainfrom
abendrothj:feat/ring-attention
Open

feat: sequence-parallel Ring Attention for MLX#2255
abendrothj wants to merge 6 commits into
exo-explore:mainfrom
abendrothj:feat/ring-attention

Conversation

@abendrothj

Copy link
Copy Markdown

Related to/Closes #39

Sequence-parallel Ring Attention for MLX

Implements distributed Ring Attention prefill for verified MLX Llama and Qwen3 architectures, with replicated decode caches, capability-gated placement, per-node memory admission, failure-safe cleanup, dashboard selection, integration coverage, and reproducible benchmarks.

What's included

  • RingAttentionLayer (src/exo/worker/engines/mlx/ring_attention.py): wraps only the attention module (never a full decoder block), splits the sequence across ranks, and rotates KV blocks around the ring while accumulating attention with a numerically stable online-softmax merge.
  • Overlapped communication: dedicated CPU send/receive streams (MLX distributed send/recv are CPU ops; unified memory lets them consume Metal-produced KV without copies). Receives are posted before the current block's attention is scheduled, and the lazy attention recurrence is explicitly mx.async_eval-ed to create the computation window the transfer overlaps with. Transport peer (always the ring neighbor) is correctly separated from KV origin rank.
  • Placement: Sharding.Ring is gated on MlxRing transport, ≥2 nodes, model-card supports_ring, and per-node admission of the full model size (weights are replicated; only prefill sequence is sharded).
  • Model cards: ring support declared for verified Llama 3.x cards; supports_ring defaults off for everything else.
  • Tests: 33 unit/integration tests including scheduling-order regression (communication posted before compute is waited on), online-merge equivalence vs full attention, and real 2-rank and 3-rank distributed Metal tests over the MLX ring backend.
  • Docs/benchmarks: README instructions (MLX_METAL_FAST_SYNCH=1), exo-bench --sharding ring support.

Benchmarks

Collected with exo-bench against a live 2-node exo cluster running this branch (--warmup 1 --repeat 3, prefix caching disabled, MLX_METAL_FAST_SYNCH=1 uv run exo on both nodes).

Environment: both nodes on a single MacBook Pro (M4 Pro, 14-core, 24 GB, macOS 26.5.2, MLX 0.32.0.dev20260709). Model: mlx-community/Llama-3.2-1B-Instruct-4bit.

⚠️ Because both ranks share one GPU, these numbers demonstrate functional correctness and stability, not multi-device scaling. Multi-machine numbers will differ qualitatively; treat this as a smoke-test baseline, and prompt_tps as the primary ring prefill metric.

Ring (sequence-parallel prefill), 2 nodes

pp tg prompt_tps (3 runs) gen_tps (mean) peak mem/node
2048 64 2137 / 2114 / 2069 (mean 2114) 125 1.12 GB
4096 64 1857 / 1849 / 1712 (mean 1806) 104 1.55 GB
8192 64 1283 / 1281 / 1212 (mean 1258) 58 2.36 GB

Pipeline baseline (same nodes, transport, model, params)

pp tg prompt_tps (3 runs) gen_tps (mean) peak mem/node
2048 64 240¹ / 2012 / 1993 (median 1993) 165 1.24 GB
4096 64 1998 / 2005 / 1993 (median 1998) 159 1.56 GB
8192 64 1990 / 2008 / 719² (median 1990) 138 1.88 GB

¹ cold first iteration (kernel compilation) · ² transient slowdown, likely thermal/contention

Reproduce with:

uv run bench/exo_bench.py --model Llama-3.2-1B-Instruct-4bit \
  --pp 2048,4096,8192 --tg 64,64,64 --min-nodes 2 --max-nodes 2 \
  --instance-meta ring --sharding ring --warmup 1 --repeat 3
# baseline: same command with --sharding pipeline

Updates since original submission

Continuous batching (--no-batch no longer required, fixed in 0a0a6c4f): the incompatibility was decode-only — _decode_step re-implemented attention by hand and hard-gated on isinstance(cache, KVCache). Fix: forward decode to the wrapped attention module (return self.original_layer(x, mask=mask, cache=cache)), inheriting support for BatchKVCache and all other cache types. Verified on a live 2-node cluster: 3 concurrent requests batched and answered correctly; a 3570-token ring prefill produced output identical to pipeline baseline at temperature 0. Net diff: −40 lines of duplicated attention code, +2 regression tests.

Real two-node validation — heterogeneous Metal + CUDA ring over a physical network:

  • Node A: MacBook Pro M4 Pro, 24 GB (Metal, MLX_METAL_FAST_SYNCH=1)
  • Node B: Debian 13 LXC, RTX 3050 Laptop 4 GB VRAM (CUDA, mlx-cuda-12 0.32.0, driver 610.43.02), ~6 ms RTT over Tailscale LAN

Result: 20/20 consecutive 4096-token ring prefills, zero hangs, identical deterministic output, ~468 prompt tok/s (428–508), ~42 tok/s decode. 8K/16K OOM the 4 GB rank cleanly (surfaced as HTTP 500, no wedged runners). The 16K same-host deadlock could not be probed on this hardware due to VRAM constraints.

Follow-up issues identified during CUDA validation (pre-existing, not regressions — all addressed in this branch):

  1. Ring memory admission now accounts for context-length working memory (KV + activations), not just weights.
  2. CUDA_HOME is auto-detected from bundled nvidia-cuda-runtime-cu12 packages when present.
  3. Runner exception handler disables loguru variable inspection (diagnose=False) so CUDA errors aren't masked by a segfault in _format_value.
  4. A progress watchdog converts future stalls into clean runner failures instead of stuck requests.

Stress validation: 12/12 consecutive 8,192-token same-host Metal prefills clean after the above fixes; 543 tests passing including watchdog, CUDA environment, accelerator admission, and memory-filtering coverage.

Known issues / follow-ups

  • Intermittent hang at long sequence length (same-host only): observed at pp=16384 with 2 ranks sharing one GPU — cross-rank circular wait between mx.eval and mx.async_eval. Did not reproduce at ≤8192 (9/9 runs clean) or in the distributed test suite. Needs investigation on genuine multi-node hardware with adequate VRAM headroom.

Testing

  • uv run basedpyright — 0 errors
  • uv run ruff check — clean
  • uv run pytest — 543 passed, 3 skipped (includes 2-rank and 3-rank distributed Metal ring tests, BatchKVCache decode regression, watchdog, and admission tests)
  • Live 2-node same-host cluster: 18 benchmark completions across ring and pipeline placements
  • Live 2-node heterogeneous Metal + CUDA cluster: 20/20 ring prefills, coherent generations

Refiled from #2217, which was closed unintentionally. Follow-up comments from that PR are incorporated above.

Implement distributed Ring Attention prefill for verified MLX Llama and Qwen3 architectures, with replicated decode caches, capability-gated placement, per-node memory admission, failure-safe cleanup, UI selection, integration coverage, and reproducible benchmarks.
Ring decode re-implemented attention by hand and hard-required the plain
KVCache, forcing --no-batch cluster-wide. Decode on a ring instance is
replicated with no communication, so forward to the wrapped attention module
instead: decode stays byte-for-byte identical to the non-ring path and
inherits support for every cache type the model handles, including
BatchKVCache. Verified against a live 2-node batching-enabled cluster:
concurrent requests batch correctly and a 3570-token ring prefill produces
output identical to the pipeline baseline at temperature 0.
loguru's diagnose mode calls repr() on every local in a traceback; repr of
MLX CUDA objects can segfault mid-log, replacing the actual runner exception
with an opaque signal 11. Observed masking a missing-CUDA_HOME error as a
crash during heterogeneous ring validation.
MLX's CUDA backend JIT-compiles kernels with NVRTC at runtime (first
triggered by the distributed send in ring prefill) and fails with 'Can not
find locations of CUDA headers' unless CUDA_HOME/CUDA_PATH is set. The pip
nvidia-cuda-runtime package ships the headers; resolve them from the nvidia
namespace package in the runner bootstrap when nothing is configured.
…ll watchdog

The intermittent long-sequence prefill hang was a cross-rank circular wait:
with MLX_METAL_FAST_SYNCH, comm ops posted with unfinished GPU inputs block
on Metal shared events inside a bounded command-buffer queue, and two ranks
can each be waiting on GPU work that is queued behind an event only the
other rank's transport would signal. Materialise KV payloads before posting
sends and allocate receive templates on the CPU receive stream so transport
never waits on the GPU and always drains.

As containment for any residual stall mode, ring prefill now runs under a
progress watchdog (EXO_PREFILL_STALL_TIMEOUT, default 300s, kicked per
chunk) that dumps all thread stacks and exits the runner, converting an
indefinite hang into a clean instance failure.
… set

Workers now report dedicated accelerator memory via NVML and placement
admits against min(RAM, VRAM); weight-only RAM admission let a 4GB-VRAM
rank into 16K-context Ring placements it could never serve. Ring admission
additionally requires an estimated prefill working set (KV cache at a
16K-capped context with a 4x multiplier derived from observed Metal/CUDA
peaks) on every rank, since Ring replicates weights and exists specifically
for long-context prefill.
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.

Ring Attention for coupling the data transfer with computation of attention block matrices

1 participant