[muon] Per-head Newton-Schulz for attention projections - #8384
[muon] Per-head Newton-Schulz for attention projections#8384alanhuangyoo wants to merge 4 commits into
Conversation
Full-matrix orthogonalization treats every attention head as one coupled block, so heads with larger momentum dominate the shared update direction while smaller-scale heads get insufficiently normalized updates. Kimi K3 (arXiv:2607.24653 5 2.5) and GLM-5 Muon Split (arXiv:2602.15763) both orthogonalize per head instead. With num_heads set, the update for a [num_heads * head_dim, in_features] projection is viewed as [num_heads, head_dim, in_features] and Newton-Schulz runs on that batch, with the existing max(1, m/n)**0.5 scaling applied per head block. Both NS kernels are already batch-safe, so this reuses the path the expert-group branch takes. Kernel only; the metadata plumbing and config surface for deepspeedai#8367 follow separately. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
…rough Adds the metadata and config half of deepspeedai#8367 on top of the kernel. set_optimizer_flags now also tags muon_num_heads next to use_muon, gated on an opt-in optimizer.params.per_head_muon. Head structure comes from the model config rather than AutoTP, so it does not require AutoTP to be enabled: q/o projections are blocked by num_attention_heads, k/v by num_key_value_heads, which differ under GQA. Deliberately conservative about what it claims to recognize. A fused QKV matrix is left on the full-matrix path - its three sections split separately, and under GQA they do not even share a head count - and any projection whose output dim does not divide by the head count is skipped with a warning rather than reshaped on a guess. All six muon_update call sites pass the tag through. Each one already operates on a whole parameter rather than a flat shard: the ZeRO-1/2 path views the momentum back to tensor.size() and asserts ndim > 1, and the ZeRO-3 path takes param.grad directly. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
Two mistakes in the previous commit's tagging, both of which produced a wrong update rather than an error. o_proj / out_proj were tagged with the query head count, but their head structure is on the input dimension ([hidden, num_heads * head_dim]) while the split is on dim 0. With the usual hidden == num_heads * head_dim they still divide evenly, so the matrix was silently cut across the wrong axis. Q/K/V only now. 'dense' was matched anywhere in the parameter path, which also names MLP matrices - intermediate.dense, output.dense, dense_h_to_4h, dense_4h_to_h - so a matrix with no head structure at all was split by the head count. Matching is now on the leaf module name against explicit Q/K/V names, and the shape has to confirm the layout: dim 0 divisible by the head count, and equal to num_heads * head_dim wherever the config states head_dim. Regression tests cover both; against the previous logic all five of the names they pin come back tagged. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Pushed a correction and ran this end to end on 2×H100. Reporting both, including the part that A correction firstThe previous commit's tagging had two mistakes, both of which produced a wrong update rather
Both have regression tests. Against the previous logic all five names those tests pin come back Tagging, verified on GPUSmall GQA model ( GQA splits correctly (q by 8, k/v by 2), What the change actually does, measuredThe papers' claim is that full-matrix orthogonalization lets heads with larger momentum dominate
With head scales uniform the two agree, so per-head does not distort the balanced case. As head What I could not showA convergence win. I ran full-matrix vs per-head on the same small model, matched seeds and The papers' claim is about stability at scale, which a toy model is the wrong instrument for. If |
The synthetic module the other cases use has the leaf names I chose, which is circular for a change whose whole job is recognizing real ones. These build actual HF configs instead. llama / qwen2 / mistral (split QKV, GQA): q_proj tagged with the query head count, k_proj and v_proj with the kv count, o_proj and the MLP projections left alone. gpt_neox / falcon (fused QKV): nothing tagged. These name their output projection 'dense' and their MLP matrices 'dense_h_to_4h' / 'dense_4h_to_h', which is exactly what the previous substring matching got wrong - all three came back tagged. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Added coverage against real model architectures. The other tagging cases use a stand-in module Built from actual HF configs,
Two things this pins that the synthetic cases could not:
30 tests across the two files, all CPU-only. |
|
Note on the red It is not specific to this PR either. The last eight runs of that workflow: Four cancellations across three different authors' PRs. For what it is worth, the tests this PR adds are CPU-only and take about 5 seconds for all 30, |
Implements #8367 — per-head Newton–Schulz for attention projections, as proposed there.
Scope grew since I opened this: it started as the kernel only, but the metadata and config half
turned out not to depend on the two questions I left on the issue, so it is all here. Points 1–4
of your proposal, plus unit tests for 5; the convergence run is below under "what is not here".
1. Kernel
muon_updategainsnum_heads. With it set, an attention projection of shape[num_heads * head_dim, in_features]is viewed as[num_heads, head_dim, in_features]andNewton–Schulz runs on that batch, so each head is orthogonalized against itself instead of
sharing one update direction with every other head — the coupled-block behaviour Kimi K3
(arXiv:2607.24653 §2.5) and GLM-5 Muon Split (arXiv:2602.15763) both move away from. The existing
max(1, m/n)**0.5scaling is applied per head block.As you said, mostly a view: both kernels are already batch-safe, and
muon_updatealready had abatched branch with per-block scaling for expert groups. This reuses that path.
2. Metadata
set_optimizer_flagstagsmuon_num_headsalongsideuse_muon, so it follows the patternalready there and does not require AutoTP to be on. Head structure comes off the model config:
q/o projections are blocked by
num_attention_heads, k/v bynum_key_value_heads— differentcounts under GQA, and using the query count for k/v would silently split them wrong.
Two things it deliberately declines to guess at:
they do not share a head count, so treating the matrix as
3 * num_headsuniform blocks wouldbe wrong. This is the question I raised on the issue; if you would rather it be handled, say
which layout to assume and I will add it.
rather than reshaped on a guess.
3. ZeRO integration
All six
muon_updatecall sites pass the tag through. Each already operates on a whole parameterrather than a flat shard — the ZeRO-1/2 path views the momentum back to
tensor.size()andasserts
ndim > 1, the ZeRO-3 path takesparam.graddirectly, and the DDP paths index realparameters out of
params_pad— so no call site needed reshaping.4. Config
Opt-in
optimizer.params.per_head_muon: true, as suggested. Off by default; with it off,muon_num_headsisNoneeverywhere and every call site takes exactly the branch it tookbefore.
Tests
20 CPU-only cases across two files.
test_per_head_muon.py— the arithmetic:(4,8,32)/(2,16,32)/(8,4,64)and both NS methodsnum_heads=1reproduces the full-matrix path100×, then asserting the other heads' updates differ from what full-matrix gives them and
that the four head-update norms land within 1.5× of each other. This is the case that fails if
num_headsis ignored, which is what makes the equivalence cases load-bearing.test_per_head_muon_tagging.py— what gets tagged: query count for q/o, kv count for k/v underGQA, fused QKV skipped, non-attention params untouched, non-divisible shapes skipped, opt-in
required, and
use_muontagging unchanged.On tolerances: the equivalence cases compare at a bound derived from the kernel's own compute
dtype rather than a tuned epsilon.
gramiterates in fp16 andnewtonschulz5in bf16, and NSamplifies rounding, so batched and unbatched agree to a few ulps, not bitwise — measured
0.027–0.053 absolute against a bf16 eps of 0.0078, norm ratios 0.995–1.005. The tests assert
8 * finfo(dtype).epselementwise plus a separate norm-ratio check, so scale is still pinned.What is not here
The convergence comparison. I have 8×H100 and can run full-matrix vs per-head on a small model
with matched seeds and steps and post loss curves — I would rather do that once you have looked
at the layout assumptions above, so I am not measuring the wrong thing.