Repository navigation
Fused residual-add RMSNorm and fused RoPE + KV-append decode kernels #905
Description
Background
In the per-token decode loop, residual add, RMSNorm, RoPE application, and KV-cache append each run as separate MLX graph nodes, paying kernel-launch and intermediate-tensor costs every token. The repo has direct evidence this class of overhead matters: the MoE decode-gap investigation counted ~773 AsType ops per token on Metal before the CUDA dtype patch reduced that path to zero (docs/benchmark_results/moe-decode-gap-investigation.md, single-dtype-decode-astype-gb10-2026-07-10.md), and #824/#829 showed RMSNorm kernels are on the correctness- and performance-critical path.
The candidate fusions, standard in serving-oriented kernel libraries:
- FusedAddRMSNorm: compute
residual' = input + residualandout = rmsnorm(residual') * (weight_bias + weight)in one pass. Stash the fp32 sum once, run a two-stage (warp then block) sum-of-squares reduction, applyrsqrt(mean + eps). Aweight_biasscalar absorbs the Gemma(1 + w)weight convention (weight_bias = 1.0);weight_bias = 0.0is the standard case. - Fused RoPE + append: apply rotary embedding to q and k and write k/v into the cache in the same kernel (cos/sin from a precomputed cache or computed on the fly), removing separate rope ops and the append's read-modify-write.
- Gated activation:
silu(x[:d]) * x[d:2d]style fused gate with 16-byte vectorized loads.
One MLX-specific constraint: MLX arrays are lazy and immutable from the graph's perspective, so an in-place residual update must be expressed as a two-output kernel (normed, new_residual) rather than true in-place mutation. mlx::core::fast::metal_kernel/cuda_kernel support multiple outputs.
Task
- FusedAddRMSNorm kernel (Metal + CUDA JIT strings in a new
src/lib/mlx-cpp/turbo/fused_norm.cpp, following thepaged_attention.cppdual-source pattern): inputs(x, residual, weight, eps, weight_bias), outputs(normed, new_residual). Expose throughmlxcel-coreas a layer helper with a graph-composed fallback. - Adopt in decode paths: wire into the Llama3-family shared decode layer first (
src/models/llama3.rs, which also serves Qwen2-family), then Gemma families (usingweight_bias = 1.0). Gate with envMLXCEL_FUSED_ADD_RMSNORM=0kill switch. Prefill may adopt it too if parity holds (same kernel, larger row count). - Fused q/k RoPE + KV-append (CUDA first, Metal follow-up): one kernel applying rope to q and k and writing k/v into the destination cache slab (dense
KVCacheslab and paged pool block layouts both supported; start with whichever the batched decode path uses after Route production batched paged decode through the fused v2 kernel, retiring the gather-then-SDPA hot path #899 lands, coordinate to avoid conflicts). Fallback and kill switchMLXCEL_FUSED_ROPE_APPEND=0. - Measure-then-keep policy: each fusion lands only if the microbench shows a win on at least one backend and no loss on the other; otherwise the kernel stays available but unwired (record the numbers either way).
Performance validation (mandatory)
- Op-level microbench (add to
benchmarks/or an example binary): per-op latency for (add+rmsnorm) and (rope+append) fused vs unfused across hidden sizes {2048, 4096, 8192} and batch {1, 4, 8}, Metal and CUDA. - End-to-end: decode tok/s on one dense model (Llama3-class 4bit) and one MoE model, batch 1 and 4, before/after, Metal and GB10 when available.
- Required outcomes: fused ops beat unfused at op level on the adopting backend; end-to-end decode shows no regression anywhere and the measured gain is reported honestly (small single-digit percent is the expectation; that is acceptable for landing).
- Record in
docs/benchmark_results/fused-norm-rope-<hw>-<date>.md.
Regression guard (mandatory)
- Numerical parity: fused vs unfused outputs within documented per-dtype tolerance (fp16/bf16 activations, fp32 accumulation); Gemma
(1+w)convention covered by an explicit test; rope parity across position offsets including rotated/ring caches (RingSlidingKVCachepositions are absolute). - Greedy token parity on pinned prompts for each adopted family vs unfused baseline.
- Kill switches restore unfused behavior; full
cargo test -p mlxcel-coreand affected model tests pass; families not yet adopted show zero change. - LoRA and surgery paths (which rewrite weights) still see consistent results (one test each with a scaled weight).
References
src/models/llama3.rssrc/lib/mlxcel-core/src/layers.rsdocs/benchmark_results/moe-decode-gap-investigation.md
Line numbers are indicative as of current main; search for the named symbols.
Part of epic #909.
Activity
feat(core): wire fused add-RMSNorm and fused RoPE + append into decode
test(core): parity, kill-switch and microbench coverage for #905 kernels
perf(core): cache backend availability off the fused-kernel hot path
test(models): pin greedy token parity for the #905 fused decode path
style: apply rustfmt to the #905 kernel wiring and tests
fix(core): land the #905 fusions unwired after measurement, and repai…
feat(core): add fused residual-add RMSNorm kernel launcher
feat(core): wire fused add-RMSNorm and fused RoPE + append into decode
test(core): parity, kill-switch and microbench coverage for #905 kernels
perf(core): cache backend availability off the fused-kernel hot path
test(models): pin greedy token parity for the #905 fused decode path
style: apply rustfmt to the #905 kernel wiring and tests
fix(core): land the #905 fusions unwired after measurement, and repai…
test(core): assert the #905 gates against their defaults, not a pinne…
perf(core): fused residual-add RMSNorm and fused RoPE + KV-append ker…
fix(speculative): name the rules correctly and make the optimal rule …
feat(speculative): opt-in acceptance-optimal rejection sampling, and …
perf(core): compute shared prompt prefixes once per decode batch (#1004)