Repository navigation
Faster Hy3: fused decoder kernels, bit for bit, and one all-reduce per layer over the TCP ring - #2535
AlexCheema wants to merge 5 commits into
Conversation
e084830 to
3718b0a
Compare
On a decode step a pipeline rank other than the first waited for the previous rank's activation before the rest of its shard's graph was built, so every token paid for building that graph (tenths of a millisecond for a few dozen layers) after the activation arrived, on the critical path of the pipeline. The first layer now posts the receive and the shard's graph is built on it; the last layer waits for the receive on the CPU, on its own, before it evaluates anything on the GPU. The graph is built while the previous rank computes. The values are unchanged, and a GPU command buffer still never waits on the network: test_pipeline_decode_receive.py (slow) runs two ranks over the ring with a sender 8 s late, longer than the GPU watchdog, and both come through with the right values. Without the CPU-side wait the receiving rank fails with a Metal GPU timeout there and the other rank reads zeros. Prefill is unchanged. A layer that reads, on the host, a value computed from its input (`.tolist()`, `.item()`, ...) would evaluate the pending receive together with GPU work, so models that do keep the eager receive (EAGER_RECEIVE_MODEL_MODULES). Over mlx-lm's models and exo's model families only DBRX's MoE does (`inds.tolist()`); exo can't pipeline-shard DBRX today (its layers are `transformer.blocks`), but it is listed. A test scans every forward (`__call__`) of mlx-lm's models and exo's families for host reads and fails on one that is not listed or audited; another runs the late sender against a layer that reads its input on the host, under the eager receive. Two pipeline ranks of Llama-3.2-1B-Instruct-4bit over the ring on one Mac Studio (M4 Max, 36 GB), decode after a 512-token prompt, median of 9 runs: 260.6 -> 269.5 tok/s (+3.4%), identical tokens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…MLX stubs The stubs for mlx.core.fast (rope, rms_norm, layer_norm, scaled_dot_product_attention, metal_kernel) are the same as in #2514's typing commit; drop them here if that lands first. nn.RoPE's dims, traditional, base and scale and nn.RMSNorm's eps are read by Hy3's fused layers, and nn.RoPE takes an array offset as MLX does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A Hy3 decode step at batch 1 was about 40 dependent GPU kernels per layer for ~140 MB of weights. Fused Metal kernels now compute exactly what the ops they replace compute (same float32 arithmetic, same order, same roundings): - the residual add with the next RMSNorm (and the router's float32 input); each layer hands the next one on its rank its input norm; - the per-head query and key RMSNorms with RoPE, in attention's layout; - the router after its logits: sigmoid, expert bias, the top 8 in the order argpartition returns them on the GPU (a stable sort), normalisation and scaling; its float32 scales and biases are converted once; - the MoE combination with the shared expert and the residual stream; - the experts' gate and up projections stacked in one gather, and SwiGLU inside the 4-bit down projection where MLX runs its matrix-vector kernel. Off Metal, and wherever a kernel does not apply, #2520's code runs as written. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… TCP ring Over MLX's ring backend (Ethernet) every all-reduce costs about a millisecond and the per-layer collectives set the speed. Attention and the dense first layer now stay whole on every rank there, so a layer has one all-reduce (the MoE's) instead of two: 79 per token instead of 160. Each rank reads all the attention weights and keeps the whole KV cache. Over RDMA attention stays split by heads, as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
d49e219 to
1b40f34
Compare
…n M1-M5 The bit-for-bit kernels depend on the GPU's code generation and arithmetic, so they now run on the generations whose hardware ran them bit for bit (M1, M3, M4); M2 and M5 GPUs keep the MLX ops. The stacked projections follow MLX 0.32.4's dispatch rules, so they run on every generation those rules name (M1-M5; on M5 this rests on the rules, not on M5 hardware); the stacked expert gathers on M1-M4 (on M5 MLX runs large sorted gathers on the GPU's matrix units, which isn't checked here). GPUs are matched by MLX's architecture name, strictly: anything else (another MLX release, or a virtual machine's paravirtual GPU such as CI's) runs the original ops. Tests check the paths each GPU takes and the original ops elsewhere, the qmv limits against MLX's dispatch on every emulated generation, the stacks at Hy3's shapes and 2x tensor-parallel shards in float32, and rerun the fused-path tests with MLX dispatching as on M1, M2, M3 and M4. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fused path on every Apple GPU generationThe macOS CI job failed four of this PR's tests: What actually failed. No result was wrong. The fused model's logits matched #2520's bit for bit on CI. All four failures are a fast path that turned itself off while the test asserted it ran. CI's GPU is a virtual machine's:
What was actually exposed.
Fix
VerificationOn CI's M1-class hardware, the fused-path tests now also run in subprocesses with
The same tests pass natively on M3 Ultra and M4 Max, and on M3 Ultra under every emulated generation 13-16 (including CI's More checks:
Which GPUs run what
Performance
🤖 Generated with Claude Code |
Builds on #2520 (Tencent Hy3), now merged into main. Its first commit is #2521 (pipeline ranks build their decode graph while the input is on its way), carried here so that the numbers include it; it drops out once #2521 lands. The second commit adds typing stubs for the
nn.RoPEattributes the new code reads (themlx.core.fastandnn.RMSNormstubs are already in main).Summary
A Hy3 decode step at batch 1 ran about 40 dependent GPU kernels per layer for ~140 MB of weights per layer. On a Mac Studio (M3 Ultra) that was 0.33 ms per layer, against a weight-streaming floor of ~0.17 ms. This PR runs the same arithmetic in fewer kernels. Under tensor parallelism over Ethernet, it also removes half of the all-reduces.
On one Mac Studio (M3 Ultra), with the 4-bit card, through exo's API:
On two Mac Studios joined by 1 Gb Ethernet:
On a single device and under pipeline parallelism, every change computes exactly what #2520 computes. The fused Metal kernels run on M1, M3 and M4 GPUs, the generations whose hardware ran them bit for bit. M2 and M5 GPUs use the separate ops until verified on that hardware. The stacked dense gate/up still runs there; on M5 that rests on MLX's dispatch rules, not on a run on M5 hardware. The stacked expert gathers run on M1 to M4. Other GPUs and MLX releases run #2520's code.
All the code is in the
hy_v3family folder; there are no MLX or mlx-lm changes. The kernels aremx.fast.metal_kernels that follow the approach of exo's fused kernels for Flint. Off Metal (CUDA, CPU), and wherever a kernel does not apply, #2520's code runs as written.Decode: fewer, fused kernels (
models/hy_v3/kernels.py,models/hy_v3/fused.py,models/hy_v3/model.py)argpartitionreturns them on the GPU. MLX's merge sort is stable: highest first, ties to the lower index. The kernel ranks every expert against all the others;swiglufor the 16 values each thread reads, and runs MLX'sqmv_fastreduction on them unchanged.Tensor parallelism over the TCP ring (
models/hy_v3/sharding.py)Pipeline parallelism (
sharding/pipeline.py, #2521): a non-first rank builds its decode graph while the previous rank's activation is on its way.Not changed here:
Performance
Setup
XavierLocalAI/Hy3-4bit(affine 4-bit, group 64; 8-bit router).6b572abas pinned.Method: the exo correctness harness's perf runner (
exo_harness.perf), as in #2520./bench/chat/completionsrequests: prefix cache off, EOS banned, so every request generates 128 tokens, with the card's sampling defaults (temperature 0.9, top_p 1).generation_stats.Runs per build: 4 for single-request rows (2 sessions x 2), 8 requests for the concurrency rows; for 2x tensor at 16,384 tokens, one run per build (each request takes minutes over 1 Gb Ethernet).
Memory. MLX's active memory after loading is unchanged on a single device and under pipeline parallelism (in-process runs: 166.0 GB single, 82.3 / 84.3 GB per pipeline rank, about half the model each, as in #2520). Under tensor parallelism over the ring, each rank holds the whole attention: 85.1 GB per rank instead of 83.3 GB, and 92.6 GB instead of 88.3 GB at the peak with a 14.7k-token prompt (the whole KV cache). exo's batch engine reports MLX's peak since the process started (loading and the first request's one-off weight stacking included): single 179.1 → 179.7 GB at 512 tokens and 186.1 → 185.9 GB at 16k, pipeline 89.2 → 89.8 GB per node, tensor 90.3 → 92.4 GB per node.
Where the time goes
One Mac Studio (M3 Ultra, 96 GB), the first 8 layers of the 4-bit card: the dense layer and 7 MoE layers, real weights.
Correctness
Verdict: the same model, bit for bit, on a single device and under pipeline parallelism. Every comparison of log-probs, logits and sampled tokens below between the two builds is exact equality, not a tolerance. Tensor parallelism over the TCP ring changes its layout (attention is no longer all-reduced) and stays at bf16 noise from a single device, closer than #2520's layout.
1. Teacher-forced log-probs at full depth
#2520's correctness report scored 29 prompts (4,960 positions, up to 14.7k tokens) with exo in process on the 4-bit card, against Hugging Face transformers on the same weights. This branch was run the same way on the same kind of machine (a Mac Studio, M3 Ultra), teacher-forced on the same tokens:
So every full-depth number in #2520's report holds for this branch unchanged:
2. Chunk lengths, step sizes and batch sizes
MLX picks its kernels by shape, so a fixed prompt length could miss a deviation at one of its switch points:
qmv, thenqmv_widefrom 2 rows on M3 and later) to matrix kernels at a batch limit that depends on the shapes and the GPU.So, on the first 8 layers of the 4-bit card (real weights), #2520's code was run against this branch, comparing all logits at every position as bits:
592 of 592 logit cases and 49 of 49 engine cases are identical. Re-run after the GPU-generation change: 592 of 592 identical on M3 Ultra and M4 Max hardware, and with MLX dispatching as on M1, M2 and M4 Max.
3. Distributed placements
The first 8 layers, two ranks on one Mac Studio over the ring, with the harness's 29 prompts (5,612 teacher-forced positions) run through exo's loading, sharding and chunked prefill:
At full depth, on the two Mac Studios over Ethernet, with the harness's two ranks and #2520's 29 prompts (4,960 positions, the same tokens as in section 1):
4. Temperature 1
(a) Distribution check.
Calibration is identical in both builds, and calibrated: each position's top-20 probabilities, binned, against how often that token was the one sampled.
(b) GSM8K at temperature 1 with the card's sampling defaults (temperature 0.9, top_p 1; the template's default, answering without reasoning), through the API. 150 test items x 2 seeds, 8 requests in flight, so the batch generator decodes them together; full model, one Mac Studio:
(c) API end to end at temperature 1 (temperature 1.0, top_p 1), full model, one Mac Studio, each check 3 times:
enable_thinking) goes toreasoning_contenttool_callsOn the two Mac Studios (one trial each; the needles at 4k, 8k and 16k tokens included): 14 / 14 checks on 2x pipeline and 14 / 14 on 2x tensor, for both builds.
Test plan
basedpyright(0 errors),ruff check(clean),nix fmt(no changes), and the CI test commandpython -m pytest src -m "not slow" --import-mode=importlib: 2,030 passed and 3 skipped, on a Mac Studio (M3 Ultra) and on a Mac with an M4 Max. With the slow tests, the Hy3 family's and Pipeline parallelism: build a rank's decode graph while its input is still on the way #2521's tests: 103 passed on both.MLX_METAL_GPU_ARCH). The full suite is green on the macOS CI runner, whose M1-class hardware runs every kernel bit for bit under M1 dispatch.hy_v3/tests/test_hy_v3_kernels.py, 81 cases), bit equality with the ops each kernel replaces:SwitchGLUat 1, 2, 7, 8, 9, 63, 64, 95, 96, 97, 128 and 129 tokens (192 experts, top 8: mlx-lm sorts from 8 tokens, MLX's sorted gather starts at 96);test_hy_v3_sharding.py): tensor parallelism in both layouts (attention whole over the ring, split by heads over RDMA) against a single device; pipeline parallelism bit-identical, as before.test_pipeline_decode_receive.py), carried with its commit.Reproduce
How exactness was established
Each kernel was written against MLX 0.32.4's own sources for the ops it replaces, then checked bit for bit: by unit tests at Hy3's shapes, and on the real checkpoint. Bit equality also depends on the GPU's code generation and arithmetic, so the kernels run only on generations whose hardware ran them bit for bit: M1 (the CI runner), M3 and M4.
rms_norm(nn.RMSNorm):rms_single_rowreads 4 values per thread over ceil(D/4) threads, rounded up to a simdgroup. It sums the squares withsimd_sumwithin and across simdgroups, normalises in float32, rounds to the model dtype and multiplies by the weight in that dtype. The kernels do the same; for a 128-value head the secondsimd_sumadds zeros.mx.fast.rope(traditional=False, scale 1): the same angle,exp2(-d * log2(base))with log2 of the float32 base, andfast::cos/sin. The position arithmetic is the same too: integer for an int offset, unsigned for exo's per-row offset arrays.argpartitionruns MLX's merge sort: an odd-even sort within each thread, then merges that take the left run on ties. That is a stable sort, so the first k ofargpartition(-scores)are the k highest scores, ties to the lower index, in that order. The kernel ranks each expert against all the others with the same rule.row_reduce_smalldoes: one thread per row, left to right from 0.Sigmoid.(experts * weights[..., None]).sum(-2)over 8 experts:col_reduce_smallgives each expert row its own thread and adds the rows in order onto 0, after the products are rounded in their own kernel. So the combination kernel disables fused multiply-add.qmvfor one row andqmv_widefor a few, and both give every output row its own reduction over K. The limit depends on the shapes and the GPU generation: 6 to 33 rows across M1-M5, 12 to 32 on an M3 Ultra. Stacked projections are used only below it, on the generations MLX 0.32.4's dispatch names, and a test checks the limits against MLX's dispatch for each.gather_qmm_rhsfor sorted pairs from 16 pairs and 4 per expert, and otherwisegather_qmv. That choice depends only on the number of pairs and experts, andgather_qmvruns the sameqmv_fast_implasqmv. Stacking gate and up along the output rows changes neither.mx.compile's kernel for mlx-lm'sswiglucomputes a sigmoid and two multiplies in the model dtype. The fused down projection does the same for the 16 values eachqmv_fastthread reads.qmv_fast/gather_qmv_fast: one row per matmul, 4-bit, group size 64, an inner size that is a multiple of 512, on M1, M3 and M4 GPUs. M2 and M5 GPUs use the separate ops until verified on that hardware.🤖 Generated with Claude Code