[muon] Per-head Newton-Schulz for attention projections - #8384
[muon] Per-head Newton-Schulz for attention projections#8384alanhuangyoo wants to merge 12 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, |
|
@alanhuangyoo, let's add end to end training results with a realistic model setup for a realistic training on GPUs with world size > 1 and report the loss etc |
|
|
||
| @compiler.compile() | ||
| def muon_update(grad, momentum, beta=0.95, ns_steps=5, nesterov=True, ns_method="gram", is_expert_group=False): | ||
| def _per_head_orthogonalize(update, num_heads, ns_steps, ns_method): |
There was a problem hiding this comment.
let's think about if possible not to all gather parameters for heads not useful for this rank.
There was a problem hiding this comment.
There are no heads that are not useful to this rank in this design, and I want to lay out why rather than just say no.
Every Muon path here partitions by whole parameter, round-robin over ranks, not by head. MuonWithAuxAdam.step takes params[base_i + rank], computes the whole update locally, and the all_gather replicates the updated parameter so each rank has it for the next forward. Stage 3 does the same thing with gradients (stage3.py:1699), and ZeRO-1/2 calls muon_update on the full-shape gradient before narrowing into the flat partition (stage_1_and_2.py:2172). Since each rank runs a full replica of the parameter, it needs all of the heads.
Per-head does not touch any of that. It changes what happens inside muon_update on the rank that already owns the parameter — a view of [out, in] as [heads, head_dim, in] before the same batched NS kernel — so the collective and its volume are identical with the flag on and off.
The thing your question does point at is real, though, and I think it is a separate change: the work partition, not the gather. When a group has fewer parameters than ranks, params_pad pads with torch.empty_like and those ranks compute on padding. Per-head makes a head-level partition natural, because the NS is already batched over a head dimension — heads of one parameter could be split across ranks and the orthogonalized blocks gathered, which would use the idle ranks and cut the per-rank NS cost. That changes the partition for all of Muon rather than only the per-head path, so it wants its own PR and its own measurements. Happy to open an issue for it if you think it is worth pursuing.
There was a problem hiding this comment.
Correcting the last paragraph of my reply — I measured the follow-up I suggested and it does not hold up, so please do not spend review time on it.
I said a head-level partition would use ranks that the round-robin leaves idle and cut the per-rank Newton-Schulz cost. Both parts are small or zero on real models.
Idle ranks. Only the final chunk can be short, so the wasted rank-slots are (ws - len(params) % ws) % ws out of ceil(len/ws) * ws:
| Muon parameters | ws=4 | ws=8 | ws=16 | ws=32 |
|---|---|---|---|---|
| Llama-3-8B, 224 | 0% | 0% | 0% | 0% |
| Qwen3-32B, 448 | 0% | 0% | 0% | 0% |
| Kimi-K3-0.40B hybrid, 318 | 0.6% | 0.6% | 0.6% | 0.6% |
A transformer has 7 * num_layers Muon matrices, which is divisible by every world size people use. It only bites on a toy model — 14 parameters on 8 ranks is 12.5%.
Load balance. I expected the descending sort plus round-robin to hand rank 0 the largest matrix of every chunk. It does, but a transformer repeats each shape num_layers times, so after sorting each chunk of ws consecutive parameters holds one shape and every rank gets the same work. Modelling the Gram NS cost as the X @ X.mT that builds the Gram matrix, max/mean per-rank work is 1.00x for Llama-3-8B and Qwen3-32B at ws = 2, 4, 8 and 16.
So the redundancy your comment points at is real as a description of the design, but on the models this would run on there is nothing measurable to recover. Where it could still matter is a model whose Muon matrices are genuinely heterogeneous — mixed expert sizes, or a hybrid whose linear-attention projections are much smaller than its MLPs — and I have not measured one of those. If you would like me to, say so and I will; otherwise I would leave the partition alone.
The rest of my reply stands: the collective replicates a whole updated parameter, every rank needs every head of it, and per-head changes neither the collective nor its volume.
| return parts[-2].lower() if len(parts) >= 2 else parts[-1].lower() | ||
|
|
||
|
|
||
| def _attention_head_count(param_name: str, param: torch.Tensor, model: torch.nn.Module): |
There was a problem hiding this comment.
I think this one is more universal, please search for other places to see if there are any implementations for extracting num of attention / kv heads etc.
There was a problem hiding this comment.
Agreed, and it was there. Switched to AutoTPMeta.from_model_config (deepspeed/module_inject/tp_shard.py), which is the repo's existing reader: it descends into text_config and probes the spellings models actually use (num_attention_heads, n_head, attention_heads, ...) instead of assuming one attribute name.
That fixed a real gap rather than just deduplicating — my version read num_attention_heads only, so a config spelled n_head was silently untagged. There is a test for it (test_head_count_comes_from_the_shared_extractor).
| if config_head_dim is not None and param.shape[0] != num_heads * config_head_dim: | ||
| return None | ||
|
|
||
| return num_heads |
There was a problem hiding this comment.
is this compatible with MLA arch?
There was a problem hiding this comment.
It is now; it was not when you asked. MLA's up-projections are head-blocked but not at head_dim:
q_b_projisnum_heads x (qk_nope_head_dim + qk_rope_head_dim)kv_b_projisnum_heads x (qk_nope_head_dim + v_head_dim)
and the down-projections q_a_proj / kv_a_proj_with_mqa mix latent and rope components with no head structure, so they stay on the full-matrix path. Checked against DeepSeek-V3, GLM-5.2 and Kimi-K3 shapes, including DeepSeek-V2-Lite, which has no q_lora_rank and therefore reaches the same MLA width through a plain q_proj.
That last case is why the tagger no longer picks a branch by which config fields exist: it collects every geometry a leaf name could plausibly have and lets the shape confirm one, which is what @delock asked for in his review.
| CPU-only: these pin the arithmetic, not the accelerator path. | ||
| """ | ||
|
|
||
| import pytest |
There was a problem hiding this comment.
we should do integration test where we do an e2e training loop, look for other examples using SimpleModel for e2e training
There was a problem hiding this comment.
Added: tests/unit/ops/muon/test_per_head_muon_e2e.py, a real deepspeed.initialize and training loop over ZeRO 1/2/3 — the tags reach the optimizer, the flag is off unless asked for, and training progresses either way.
Beyond the unit tests, the PR body has multi-GPU runs on real checkpoints with world size > 1, which is what you asked for in the thread comment. That writeup also reports a negative result and its cause: the two mini models @delock pointed at ship untrained weights, so their heads are interchangeable and per-head has nothing to act on. On trained checkpoints the gradient carries a 6-8x median head-norm imbalance that the whole-matrix path reduces to ~3x and per-head takes to ~1.1x.
|
Hi @alanhuangyoo, I would suggest to test your implementation against these two mini-MLA models as well. These are the mini-version of the model that use per-head Muon. They are small enough to test your implementation and contains MLA. A correctness test should be enough and convergence would be optional. You may want to check whether the modeling and setting is consistent with the original model itself in sense of validate per-head Muon implementation. And whether they could be trained without per-head muon with z2 or z3 (baseline) 1 inference-optimization/GLM-5.2-0.8B-A0.8B https://huggingface.co/inference-optimization/GLM-5.2-0.8B-A0.8B |
Addresses the review on deepspeedai#8384. MLA blocks its two up-projections by head instead of using q/k/v: q_b_proj is [num_heads * (qk_nope + qk_rope), rank] and kv_b_proj is [num_heads * (qk_nope + v_head_dim), rank]. That is the split GLM-5's Muon Split applies, and neither width is head_dim, so the previous shape check rejected both. The down-projections (q_a_proj, kv_a_proj_with_mqa) mix latent and rope components and stay on the full-matrix path. Verified against the real tensor shapes of the two models named in deepspeedai#8367: inference-optimization/GLM-5.2-0.8B-A0.8B tags q_b_proj (4096, 512) and kv_b_proj (5120, 128) with 16 heads, matching 16*(192+64) and 16*(192+128), and leaves the down-projections, o_proj and the MLP alone. inference-optimization/Kimi-K3-0.40B is kimi_linear rather than MLA - q_proj is [256, 1024] against 8 heads of 74 - so the shape check keeps it off the per-head path. Head counts now come from AutoTPMeta.from_model_config, the repo's stated single source of truth, which descends into text_config and probes the several spellings models use (num_heads, n_head, attention_heads) instead of one hardcoded name. Adds end-to-end training over ZeRO 1/2/3 at world size 2: tags survive deepspeed.initialize into the ZeRO call sites, per_head_muon stays off by default, and both paths train. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
@pengdurice @delock thanks — all four of the review points are in, plus the mini-model check. MLA (@pengdurice's question, @delock's models)It was not compatible, and the failure mode was a silent skip rather than a wrong split. MLA does not use q/k/v projections at all; it blocks its two up-projections by head: Neither per-head width is Checked against the real tensor shapes of both models you named, read from their safetensors
Head extraction (@pengdurice)You were right that this already exists. Now goes through End-to-end training (@pengdurice, @delock's baseline point)
( Not all-gathering unused heads (@pengdurice)Looked at this and I do not think it belongs in this PR. On the ZeRO-3 path Totals38 unit tests plus the 9 end-to-end ones. |
Thank you for the response, instead of unit test, would you please run the e2e training and report your config, hardware spec, loss curves etc. I think this change is bit enough to warrant this kind of test. |
An MLA attention block only builds the q_a/q_b pair when q_lora_rank is set.
Without it the query up-projection is a plain q_proj, and its per-head width is
still qk_nope_head_dim + qk_rope_head_dim rather than head_dim:
self.q_proj = nn.Linear(hidden_size, num_heads * self.qk_head_dim)
if self.q_lora_rank is None else None
DeepSeek-V2-Lite is in that shape. Measured by instantiating DeepseekV2Attention
on the released deepseek-ai/DeepSeek-Coder-V2-Lite-Instruct config under
transformers 5.16.1:
heads=16 head_dim=64 qk_nope=128 qk_rope=64 q_lora_rank=None
q_proj.weight (3072, 2048) 16 * 192
kv_b_proj.weight (4096, 512) 16 * 256
kv_a_proj_with_mqa.weight (576, 2048)
o_proj.weight (2048, 2048)
head_dim is present and equal to 64, so the shape check compared 3072 against
16 * 64 = 1024 and put the model back on the full-matrix path. The fallback is
the behaviour before this feature, so nothing was wrong, but a whole class of
MLA checkpoints never reached the per-head split the feature exists for.
q_proj now takes the MLA width when the config carries the MLA head dimensions,
and falls through to head_dim when it does not, so ordinary attention models are
unchanged.
Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
@pengdurice @delock — end-to-end training runs, on the two models you named plus the ones they turned out not to cover. Four things up front, so you can pick what is worth reading:
Setup
The only difference between an "off" and an "on" run is One note in passing, since it cost me a cycle: 1. Tagging on the real checkpoints
Worth stating plainly why this model is the right one to check against: its 2. Kimi-K3-0.40B — I got this wrong earlierI told you it is
Layers 3 and 7 zero-indexed, matching It also trains under ZeRO-2, with the flag on and off, from 18.5:
Last-100 means 7.1075 and 7.1006, within-window sd 0.21 — the same picture as GLM-5.2, for the No ZeRO-3 row for Kimi: stage 3 on this model is far slower here than stage 2 and I did not get a 3. A gap the two mini models did not coverChecking the MLA path against other checkpoints turned up one this branch was missing. An MLA block only builds the # transformers/models/deepseek_v2/modeling_deepseek_v2.py
self.q_proj = (nn.Linear(self.hidden_size, self.num_heads * self.qk_head_dim, bias=False)
if self.q_lora_rank is None else None)
transformers populates Note 4. Baselines and loss curvesBoth stages train, with and without the flag, which is the baseline question. 25-step trailing
Mean of the last 100 steps: 6.8483, 6.8478, 6.8477, 6.8418, against a standard deviation of I am not claiming a convergence benefit from these runs, and the numbers do not support one. The four curves differ by 0.001 to 0.045 at each checkpoint, against a standard deviation of 0.29 inside the window each of those points averages over — the widest gap anywhere is about a sixth of the noise it sits in, and the median gap is a fiftieth of it. To put a number on that rather than eyeball it I reran the same configuration — same seed, same data order, same flag — five times per arm, and swept three seeds. Mean of the last 100 steps, ZeRO-2, seed 1234, five runs of each arm that differ only in what the GPU does non-deterministically:
The difference is +0.0025 against a pooled standard deviation of 0.0035 — 0.71 sd, with the two ranges overlapping across most of their span. Across seeds it does not even hold its sign:
Two seeds favour per-head, the third reverses by more than either win. On this model, at this scale, the flag is a no-op for convergence, and section 5 is why. 5. Why these models cannot show one, and where it does showBoth mini checkpoints carry untrained weights. Loaded with Head imbalance does appear once it starts training, but not much of it. Over 400 steps through the real Muon path, measuring what the optimiser actually orthogonalises — the Nesterov blend, not the raw gradient — medians across the run:
At that level both paths land in the same place, and per-head is not even consistently the tighter of the two — on It is exercised on checkpoints that have been trained. Same measurement, 8 steps on wikitext, median over all layers: These are standard-attention checkpoints, not MLA — I could not find a trained MLA model small enough to run here — but the split is the same operation on the same kind of head-blocked matrix.
Two things there. A trained model's heads are not interchangeable — the gradient carries a 6–8x median imbalance on Comparing medians of different quantities is not much of an argument, so the same runs paired per layer, 24 layers each:
Per-head is tighter than whole-matrix in every layer of every projection. And The GQA row also confirms the KV head count is used where it should be: DeepSeek-V2-Lite at 16 B was the closest trained MLA checkpoint and I did not want to pull 30 GB onto this box for it. If either of you has a smaller one, I will run the same measurement against it. 6. Implementation auditTwo properties I checked because a reviewer would, both on the real shapes. The split does not change how large the update is. If it did, the two arms would run at different effective learning rates and section 4 would be meaningless. Ratio of per-head to whole-matrix update norm:
The spread there is Newton-Schulz, not the scaling. This implementation runs a
The last row is the one worth flagging. On the square projection the whole-matrix What that means in practice: on a I looked at whether raising 7. TestsOn the same 2 × H20: The two
The tagging tests are no longer only synthetic configs: llama, qwen2, mistral, gpt_neox and 8. Open questionsBoth are real head-blocked matrices in the models you named that this code leaves on the full-matrix path. Neither is a bug — the tagger declines rather than guessing — but both are decisions I would rather you made than assume. GLM-5.2's DSA indexer. # transformers/models/glm_moe_dsa/modeling_glm_moe_dsa.py
self.wq_b = nn.Linear(self.q_lora_rank, self.n_heads * self.head_dim, bias=False)Its head count lives in Kimi-K3's KDA layers. Their If the answer to either is yes, the general fix is probably to read the head count off the owning module — most attention implementations carry Not all-gathering unused headsUnchanged from my previous answer, restating it since it was one of the four points: ZeRO-3 gathers whole parameters ( |
Adding the per-head helper above muon_update left @compiler.compile() attached to the new function instead of to muon_update, so muon_update lost the torch.compile it has on master. That was not intended and it applies to every Muon user, not only the per-head path. The decorated set now matches master again: zeropower_via_newtonschulz5, zeropower_via_gram_newtonschulz and muon_update. _per_head_orthogonalize is called from inside muon_update and does not need its own. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Two things since that comment, one of them a mistake of mine. The decoratorAdding The CI failure, which I cannot pin on it
I expected the decorator to be the cause and it is not, at least not here. On 2 × H20, ten runs:
So it reproduces in none of them, including the state that failed, and including the merge CI actually tests. Different accelerator and a different torch build than the runner, so I cannot rule out that it is real there and only shows on that hardware — but I have no evidence it is this change, and the change does not touch the allreduce path or run any new code with CI is running again on c3e9334. If it comes back red on the same two tests I will keep digging rather than call it flaky. Does this affect the numbers in the previous commentNo. Every training run in it used the same build for both arms, so the on/off comparison is unaffected, and the head-spread measurements are ratios of 1.1x to 8x where compilation moves the last few digits. But the runs were made without |
|
Closing the loop on the CI question, since the rerun did not answer it: that run was cancelled at the 1h30m job timeout, not failed. Both of my runs today were, and it is not specific to this branch — the last 30 Since PR-level GPU CI cannot give the signal right now, I ran the whole file it flagged on 2 × H20, on upstream master and on this branch merged with master: Same count, and the same single failure on both:
One thing I did verify after restoring the decorator, because it was a gap rather than a formality: |
|
Numbers for that last paragraph, since "re-ran them" is not a measurement. On the merged tree, against the compiled And the thing I actually wanted to know — One graph per distinct head count, repeats free. A model carries a query count and a KV count, so with the flag on that is at most two graphs beyond the one the whole-matrix path already compiles. |
|
Hi @alanhuangyoo , I agree that Kimi architecture support better be done in a seperate PR so we could have better discussion. If you open a seperate issue I'll assign it to you. I'll review and answer the rest of your discussion next week. |
|
Opened as #8420. It covers both cases I flagged — Kimi-K3's KDA layers and GLM-5.2's DSA indexer — with the shapes, the lines in each model's own code that produce them, and why the current tagger declines rather than mis-splits them. I framed the two model questions first, since whether the split is meant to apply there is yours to answer and the code change follows from it either way. Happy to take it if you assign it. No rush on the rest of the discussion here. |
delock
left a comment
There was a problem hiding this comment.
Review: per-head Newton-Schulz (thanks for the thorough e2e writeup)
The kernel and the ZeRO plumbing look right to me — per-head reuses the batched NS kernels
behind a view, all six call sites read the tag through the same attribute channel as
use_muon, and communication volume is unchanged. The asks below are all about the tagger
(_attention_head_count), which is where the review weight of this PR sits.
Change requested
1. The dispatch cascade is order-dependent — make it candidate-based
q_proj is now in both _MLA_Q_LEAVES and _QUERY_HEAD_LEAVES, and which branch wins
depends on which config fields happen to exist, not on the model. Kimi-K3's KDA q_proj
is the live example: top-level qk_nope/rope are MLA leftovers, so the MLA branch claims
it and it survives only because 8×96 ≠ 256. That is safety by accident of a width
mismatch, not by recognition.
Suggested shape (the outcome is only num_heads, so two candidates that agree on the head
count are equivalent — ambiguity only exists when counts differ):
candidates = [(heads, width, source), ...] # mla / kv / q, all evaluated
exact = [c for c in candidates if rows == heads*width]
one exact match, or several with equal head counts -> tag
several exact matches with different head counts -> skip + warn (real ambiguity)
none -> skip
This kills the ordering dependency, turns the Kimi case into principled safety, and is
~15 lines. While you are in there, splitting the function into three named steps would
make each one testable on its own:
classify(leaf) -> kind
geometry_candidates(kind, cfg) -> [(heads, width, source)]
confirm(param, candidates) -> (num_heads | None, reason)
2. An explicit opt-in must not silently degrade — and TP should refuse, not fall back
per_head_muon: true is an explicit request. Today every skip funnels to return None
with no signal at all. The PR description itself says "skipped with a warning", so part of
this is aligning the code with your own stated behavior. Principle: configuration that is
explicitly requested either applies, or reports why it did not — and where it can never
apply, refuses. Concretely:
- Hard error at
initializewhen the flag is on and zero parameters were tagged.
This one rule covers the most severe case outright: under tensor parallelism the config
describes the whole model while each rank holds a shard, so every attention projection
fails the width check and the feature is silently off model-wide — users believe they
are training with per-head dynamics and are not. It also catches misconfigurations and
unrecognized architectures. Fail before training starts; the escape hatch is unsetting
the flag. The message should list the likely causes (tensor parallelism active,
architecture not recognized, model with no attention projections). Muon already has
this culture: it rejectsreduce_scatterrather than silently degrading. - Warning, aggregated per leaf, for non-systemic shape mismatches — a leaf that
matched an attention table but no candidate confirmed (q_proj×6: width-mismatch).
This is the unrecognized-layout class (Kimi KDA today, until #8420). Keep it a
warning, not an error: hybrid models still get per-head on their recognized leaves,
and erroring would block the working half. - Info summary whenever the flag is on, aggregated per leaf name —
per_head_muon: tagged 12/69 Muon params — q_b_proj×6→16, kv_b_proj×6→16— including
the declared exclusions (o_proj, fused QKV, MLA down-projections), which are by design
and should not warn.
The per-leaf skip reasons fall out of the confirm() step in the refactor above, so the
two changes are one piece of work.
3. Docs
per_head_muon has no documentation anywhere. Repo rule: new features ship with docs.
One section in the optimizer docs (flag, what gets tagged, what deliberately does not —
fused QKV, o_proj, MLA down-projections) would also be the right home for the
"shape confirms the name" contract.
4. License header
The three new test files carry # Copyright (c) Microsoft Corporation. New files in this
repo use the two-line header only — # SPDX-License-Identifier: Apache-2.0 + # DeepSpeed Team. Please drop the Microsoft line; a wrong copyright line is a licensing compliance
issue, not a style nit.
Tests requested (with #1)
- Kimi-K3 regression fixture: a non-MLA attention with MLA leftover config fields must
land onNone— pins today's accident. - Synthetic ambiguity case: two candidates exactly matching with different head counts
must skip + warn.
Open questions
- Not all-gathering unused heads: agreed this should not gate the PR. For the record,
the reasoning checks out on my read: ZeRO-3's gather granularity is the whole parameter
(_partitioned_buffers_all_gather), so per-head changes only how an already-gathered
matrix is sliced and adds no communication, while gathering just the heads a rank needs
means reworking ZeRO-3's partitioning contract itself — a separate change with its own
correctness surface. @pengdurice — you raised this one; does a separate PR work for you,
or would you rather it be pursued here?
Minor (non-blocking)
_ns_tolerance/_norm_rtlduplicate the kernels' compute-dtype selection (method →
dtype, plus the accelerator fallback). If a kernel ever changes precision, the tolerances
drift silently — either 8× too tight (spurious flakes) or orders of magnitude too loose
(test goes vacuous). Hoisting a sharedns_compute_dtype(ns_method)into
original_muon.py, used by both the kernels and the tests, makes the drift structurally
impossible.AutoTPMeta.from_model_configis constructed inside_attention_head_count, i.e. once
per parameter, though it is loop-invariant — same probe sequence and dataclass rebuilt
thousands of times for one model. Harmless in practice, but hoisting it above the loop
inset_optimizer_flags(or threading a prebuilt meta intogeometry_candidates) is
free and reads better; it slots naturally into the refactor above.
Decorator fix (c3e93341) and the non-repro investigation both look right — thanks for
chasing it to thirteen runs.
Addresses @delock's review. The dispatch was an ordered cascade, so which branch claimed a leaf depended on which config fields happened to exist rather than on the model. q_proj sat in both the MLA and the standard tables; Kimi-K3's KDA q_proj was claimed by the MLA branch on the strength of top-level qk_nope/qk_rope that belong to its two MLA layers, and survived only because 8 x 96 != 256. Safety by width mismatch, not by recognition. Now every geometry the config makes plausible for a leaf is collected and the shape confirms one: classify(leaf) -> kind geometry_candidates(kind, meta, cfg) -> [(heads, width, source)] confirm(param, candidates) -> (num_heads | None, reason) Candidates that confirm and agree on the head count are not a conflict, since the head count is the whole output. Candidates that confirm and disagree are, and the parameter is skipped with both named in the warning. This also removes a weaker rule that was hiding in the old code: with no head_dim the tagger fell back to divisibility alone, and rows % heads == 0 holds for matrices with no head structure at all -- the way o_proj slipped through in the first version. Every candidate now carries a width, derived as hidden_size // num_attention_heads where a config omits head_dim. An explicit opt-in that silently does nothing is its own failure. deepspeed.initialize now raises when per_head_muon is set and no projection could be tagged, listing the likely causes. The systemic one is tensor parallelism: the config describes the whole model while each rank holds a shard, so every width check fails and per-head is off model-wide while the user believes it is on. Non-systemic misses are aggregated per leaf as a warning so a hybrid model keeps per-head on its recognized layers, and an info line reports what was tagged. Also from the review: - AutoTPMeta.from_model_config is built once per model rather than per parameter. The model-taking _attention_head_count is kept for single-parameter callers. - ns_compute_dtype moves into original_muon.py, so the kernels and the test tolerances read the NS precision from one place instead of restating it. - per_head_muon is documented in config-json.md: what is tagged, what deliberately is not, the shape-confirms-the-name contract, and the reporting behaviour. - The three test files carried a Microsoft copyright line; they use the repo's two-line header now. Tests added: a Kimi-K3 hybrid fixture pinning that the KDA projections are declined by candidate confirmation rather than by branch order, a synthetic ambiguity case, agreement-is-not-ambiguity, the divisibility-alone rejection, head_dim derivation, and the initialize-time error. 52 unit, 9 end-to-end on 2 x H20. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
All six in 37740f6. Thanks — #1 and #2 were the right things to push on, and #1 found something I had not noticed. 1. Candidate-based dispatchYou are right that the Kimi case was safety by accident. Worse than that: it was accidental in a way that would have looked correct in review, because the outcome depended on which config fields exist rather than on the model. Agreement is not ambiguity, as you said — the head count is the whole output, so two candidates that confirm with the same count give the same answer. Disagreement is, and the skip names both: Going through it turned up a weaker rule hiding in the old code. With no 2. Report, or refuseImplemented as you specified. The hard error fires at Non-systemic misses are aggregated per leaf as a warning, so a hybrid model keeps per-head on its recognized layers, plus an info line for what was tagged. Your point about the TP case is the one that convinced me it has to be an error rather than a warning: there is no partial result to keep, and the user has no way to notice. 3 & 4. Docs, header
TestsBoth you asked for, plus four the refactor made worth pinning:
52 unit and 9 end-to-end on 2 × H20, MinorBoth taken. Open questionKimi and the DSA indexer are #8420, which you assigned to me. I will not start it until the two model questions there have an answer, since the code follows from them either way. |
|
Note on the red check here, since a red X reads as "this PR broke something" and that is not what happened. The 137 is SIGKILL — the sandbox killed pytest mid-suite. There is not a single The same thing hit #8356 on the same day, and three other branches of mine that ran within the same hour (#8362, #8433, #8435) went green, so it is intermittent rather than a property of this tree. I cannot re-run it — that needs write access to the repo. Any maintainer re-running the failed job should be enough; happy to push an empty commit instead if that is easier. |
…lelism set_optimizer_flags runs before the engine partitions the model, so the count it records is the model's, not the rank's. AutoTP replaces the parameter's .data in place, so the tag rides onto a column-parallel shard and nothing catches it: with tp=2 a tag of 8 heads lands on a shard holding 4 heads' worth of rows, out_features % num_heads still divides, and Newton-Schulz runs on half of each head. Measured on a Llama with 8 heads of 32 and autotp_size 2: every q/k/v tagged 8 heads of 16. The per-head width is what a column-parallel split leaves alone, so record it and re-derive the count from the shard. A shard whose rows are not a multiple of the width does not hold whole heads and is dropped. This is not only a repair. A column-parallel shard holds whole heads, so per-head Newton-Schulz on the shard is bit-identical to the corresponding blocks of per-head Newton-Schulz on the whole matrix - the split is along the same axis the batch is taken over. There is a test asserting equality, not closeness, for both kernels at tp=2 and tp=4, against the whole-matrix path, which differs by more than 10% on the same input. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Found a correctness bug in this PR while looking at what happens under tensor parallelism, and it turned into the strongest argument for the feature. Fixed in 943fd03. The bug
Nothing catches it, because the stale count usually still divides. Llama, 8 heads of 32, 128 % 8 == 0, so the divisibility check in My description also said tensor parallelism was the likely reason nothing gets tagged. That was wrong for AutoTP in the opposite direction: everything gets tagged, incorrectly. Corrected in the error message and the docs. The fix, and why it is more than a repairA column-parallel split is on dim 0, which is the axis the heads are on and the axis per-head Newton-Schulz batches over. A rank therefore holds whole heads, and the per-head width is invariant under the split while the count is not. So: record the width, re-derive the count from the shard after partitioning, and drop any shard whose rows are not a multiple of the width. The consequence is that per-head Newton-Schulz is exact under tensor parallelism. Not close — equal:
The whole-matrix path has no such property, and that is what makes the comparison mean something: Where that leaves Muon under AutoTP generallyThat second number is not about this PR — it is what Muon already does with Filed separately as #8437 so it is not tangled up with this PR. With Tests
57 passed across the three per-head files; yapf and flake8 clean. |
zero.Init replaces a partitioned parameter's data with a flat placeholder and records the layer's shape as ds_shape, so param.shape is 1-D for every parameter in the model. The width check then confirms nothing and the flag raises on a model whose layout it could read perfectly well. Same root cause as deepspeedai#8438, which fixes the use_muon test on master; this applies it to the tagger's shape reads as well. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
One more from the same thread, and it is why #8438 exists. Per-head tagging had the same shape problem as 5d16495 reads the layer's shape instead — the same thing Chasing that turned up a bigger one that is not this PR's: on master,
That is #8438, against master, separate from this. It is worth landing on its own regardless of what happens here — 58 passed across the per-head files here; yapf and flake8 clean. |
|
@alanhuangyoo thanks for extensive test of muon path. I can help review your subsequent PRs if you would like to go down this path. My bandwidth allows me to review one Muon related PR at a time, I guess that aligns with your plan. Hi @pengdurice requested your re-review to see if all your comments had been addressed. Thanks! |
|
You put that politely, so let me not: seven Muon PRs in two days isn't a plan, it's me opening things as I found them without once thinking about who has to read them. The bugs were worth reporting, but the pacing was mine to get right and I didn't. So you're not the one deciding what to pick up first — in the order I'd argue for, worst first:
Those five are independent of this PR and of each other. #8436 is the only one that has to wait, since it stacks on this branch. And I'm not adding to the pile — nothing new goes to Muon from me until some of these clear. #8437, #8439 and #8443 are issues with measurements attached rather than PRs, so they need no review time; leave them until the queue's empty. If a different order suits you better, or you'd rather I close some and reopen them later, just say. I'd rather three of these land than seven sit. |
|
@alanhuangyoo Thanks for your ordering, it looks good to me. I don't mean that it is too much, if we find a bug, better record it today than never. Let's do it one at a time and keep the pace.
|
|
@pengdurice — a re-review request, not a new argument. Your changes-requested from 2 Sep is the only thing left on this one (@delock approved on 7 Sep), and I think what you asked for is in. You wanted end-to-end training on a realistic model at world size > 1, with the loss reported. That is the 4 Sep comment above: both mini-MLA models @delock named, 2 GPUs, per-head vs whole-matrix on the same seed and batch, loss curves for each. I also reported the part that did not work — MLA's Since then the branch has picked up a correctness fix of its own (
If anything from your original comment is still open I would rather hear it than assume it is closed — but if it reads as answered, a re-review would unblock this. |
pengdurice
left a comment
There was a problem hiding this comment.
let's consolidate the tests a bit. 3 files for one feature is excessive.
@pengdurice asked for fewer files. Five, one per concern, become two split by what they need to run: tests/unit/runtime/zero/test_per_head_muon.py CPU the arithmetic (was test_per_head_muon.py) which parameters are tagged, and with how many heads (was test_per_head_muon_tagging.py) re-resolving the count against a tensor-parallel shard (was test_per_head_muon_tensor_parallel.py) tests/unit/ops/muon/test_per_head_muon_accelerator.py GPU a shard of the per-head result is the per-head result of the shard (was test_per_head_muon_under_sharding.py) end-to-end training (was test_per_head_muon_e2e.py) Nothing is dropped or rewritten: 77 tests before, 77 after, same names. The tagging module's _Attn and the tensor-parallel module's _Attn were different classes with the same name, so the latter is now _ShardedAttn. The accelerator file is not called test_per_head_muon.py because these directories have no __init__.py, and two same-named modules break collection when both are selected in one run. Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
|
Done in
Nothing dropped or rewritten — 77 tests before, 77 after, same names. Verified on 2×H20 with both files in one run. Two mechanical notes:
@pengdurice — this was the only thing outstanding from your side as far as I can tell; the end-to-end training results you asked for on 2 Sep are in the 4 Sep comment above. If anything else is still open I would rather hear it than assume it is closed. |
…ted files deepspeedai#8384's five test files became two (@pengdurice asked for fewer). The linear attention and DSA indexer cases this branch added to test_per_head_muon_tagging.py move into section 3 of the consolidated tests/unit/runtime/zero/test_per_head_muon.py; nothing is rewritten. 89 passed across the two files on 2xH20 (77 from the base, 12 from here).
…speedai#8442) Closes deepspeedai#8441. ## The problem `MuonWithAuxAdam.step` applies an update it assumes has been orthogonalized already: ```python # deepspeed/runtime/zero/muon/muon_optimizer.py if group["use_muon"]: # we move the muon update part to the deepspeed's optimizer since the parameter here is a flat version # thus not suitable for muon update for p in group["params"]: p.mul_(1 - group["lr"] * group["weight_decay"]) p.add_(p.grad.reshape(p.shape), alpha=-group["lr"]) ``` That holds under ZeRO: `get_flat_partition` in `stage_1_and_2.py` and the sub-group loop in `stage3.py` call `muon_update`, so by then the gradient holds the orthogonalized update. With no ZeRO optimizer nothing does, and `p.add_(p.grad, alpha=-lr)` on a raw gradient is SGD. `zero_optimization.stage` defaults to `0`, so a config that just names Muon gets that. Counting the Newton-Schulz kernel calls on one step: | config | wrapper | Newton-Schulz calls | max\|w - SGD\| | | --- | --- | --- | --- | | no `zero_optimization` block, fp32 | `MuonWithAuxAdam` | **0** | 1.49e-08 | | `stage: 0`, bf16 | `FP16_UnfusedOptimizer` | **0** | 4.88e-04 | | `stage: 0`, fp16 | `FP16_UnfusedOptimizer` | **0** | — | | `stage: 1`, fp32 | `DeepSpeedZeroOptimizer` | 2 | 9.77e-02 | 1.49e-08 is reduction ordering: those are the SGD weights, seven orders below what a real Muon step does to the same gradient. Training runs and the loss falls either way. ## The change The two cases are distinguishable by shape, which I initially thought they were not. ZeRO hands `step()` a flat 1-D partition. An unwrapped optimizer hands it the model's weight, and `FP16_UnfusedOptimizer` hands it a per-parameter fp32 **clone** — `p.clone().float().detach()`, same shape — not a flat buffer. Measured: ``` stage 0 / fp32 MuonWithAuxAdam ndims=[2] stage 0 / bf16 FP16_UnfusedOptimizer ndims=[2] stage 0 / fp16 FP16_UnfusedOptimizer ndims=[2] stage 1 / fp32 DeepSpeedZeroOptimizer ndims=[1] ``` So: orthogonalize when the parameter is a matrix, and keep applying the update as-is when it is a partition. After the change, stage 0 fp32 produces `max|w - SGD| = 9.772e-02` — the same value stage 1 gives, i.e. the same update. Newton-Schulz is scale-invariant and the momentum starts at zero, so `initialize_optimizer_states`' warm-up step on zero gradients stays a no-op. `num_heads` is deliberately not threaded through here: it does not exist on `muon_update` on master. Once deepspeedai#8384 lands, this call site is where per-head would be added for the unwrapped path. ## Tests `tests/unit/runtime/zero/test_muon_without_zero_optimizer.py`, 7 cases: Newton-Schulz runs at stage 0 for fp32, bf16 and fp16 — all three wrappers; it runs for a config with no `zero_optimization` block, which is the plainest form; and it still runs on stages 1, 2 and 3. On master, 4 fail and 3 pass. The four that fail are the stage-0 ones, with `Newton-Schulz ran 0 times for two Muon matrices`; the three that pass are the ZeRO stages, which is the control that says the test measures the right thing. The counter is started **after** `deepspeed.initialize`, because `FP16_UnfusedOptimizer` steps once at construction to allocate state and that call would otherwise satisfy the assertion on its own. It is also patched inside the test body rather than in a fixture, since `DistributedTest` runs the body in a worker a parent-process fixture would not reach. This is the assertion the existing Muon tests were missing: `tests/unit/ops/muon/` parametrizes stages `[1, 2, 3]` and checks that the loss moves, which SGD also does — which is why stage 0 went unnoticed. 7 passed. yapf and flake8 clean. --------- Signed-off-by: alanhuangyoo <alanhuangyoo@gmail.com>
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 counts come from
AutoTPMeta.from_model_config, the repo's existing reader for them, and the per-head width fromthe config:
q_proj,query,wqnum_attention_headshead_dimk_proj,v_proj,key,value,wk,wvnum_key_value_headshead_dimq_b_proj, andq_projon an MLA confignum_attention_headsqk_nope + qk_ropekv_b_projnum_attention_headsqk_nope + v_head_dimDifferent counts under GQA, and using the query count for k/v would silently split them wrong.
MLA does not use
head_dimfor either up-projection, so anum_heads * head_dimcheck rejectsboth — on GLM-5.2 that is 16 × 64 = 1024 against a real 4096. Without a
q_lora_rankthere is noq_a/q_bpair and the query up-projection is a plainq_projat the same MLA width(DeepSeek-V2-Lite).
Matching is on the leaf module name, not the path, so
denseinattention.output.densecannotpull in
intermediate.dense. Whatever the name suggests, the shape has to agree: dim 0 mustdivide by the head count, and where a per-head width is known it must match exactly.
Three things it deliberately declines to guess at:
The output projection stays on the full-matrix path everywhere. Its head structure is on
the input dimension, and with the usual
hidden == num_heads * head_dimsplitting dim 0 stilldivides evenly — the shape check cannot catch that one, only the name exclusion can.
Fused QKV stays on the full-matrix path. Its three sections split separately, and under GQA
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.
Anything whose output dim does not divide by the head count is skipped with a warning
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
44 CPU-only cases across two files, plus 9 end-to-end.
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, kv count for k/v underGQA, output projection excluded, MLP matrices named
densenot mistaken for attention, fused QKVskipped, non-attention params untouched, non-divisible shapes skipped, opt-in required, and
use_muontagging unchanged. Real architectures rather than only synthetic configs: llama,qwen2, mistral, gpt_neox, falcon, and MLA fixtures whose shapes were measured by instantiating
the attention module on the released
GLM-5.2-0.8B-A0.8BandDeepSeek-Coder-V2-Lite-Instructconfigs.tests/unit/ops/muon/test_per_head_muon_e2e.py—DistributedTestatworld_size=2, ZeRO 1/2/3:tags survive
deepspeed.initializeinto the ZeRO call sites, the flag is off by default, andboth paths train.
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.End-to-end training
Requested by @pengdurice; run on 2 × H20 against both models @delock named, at
#8384 (comment). Short version: GLM-5.2 trains under ZeRO-2 and ZeRO-3 and Kimi-K3 under ZeRO-2, with and
without the flag, and the loss curves are indistinguishable. The reason is that both mini
checkpoints carry untrained weights, so their attention heads are interchangeable and the split
has nothing to separate. On
trained checkpoints the imbalance it targets is large — a 6–8x median head-norm spread in the
gradient, which whole-matrix Newton-Schulz only reduces to ~3x and per-head takes to ~1.1x.
Numbers, config, hardware and the two open layout questions are in that comment.