Kernels
dongseokmotif commited on
Commit
a85f310
·
unverified ·
2 Parent(s): 9765db5ebeb3a1

Merge pull request #33 from MotifTechnologies/feat/per-head-muon

Browse files

feat: add Per-Head Muon (Kimi K3 §2.5) via head_keys [skip-build]

CLAUDE.md CHANGED
@@ -87,6 +87,7 @@ For detailed implementation documentation (pipeline internals, distributed utili
87
  - **`construct_shard_mesh()` / `get_slices_of_dtensor()`** (`distributed/utils.py`) — Utilities for building shard meshes from DTensor placements and computing per-rank local slices. Handles both `Shard` and `_StridedShard` (PyTorch 2.10+).
88
  - **Newton-Schulz iteration** (`newton_schulz.py`) — `_zeropower_via_newtonschulz5()`: 5 quintic iterations in bfloat16 with pre-optimized coefficients for gradient orthogonalization. Uses Triton kernel `matmul_transpose_assign` for efficient X @ X.T.
89
  - **QK Clipping** (`qk_clip.py`) — Optional dynamic clipping of attention head projections when QK logits exceed a threshold. Configured via `q_indices`, `k_indices`, `head_dim`, `threshold`.
 
90
  - **Fused AdamW** (`adamw.py`) — Uses PyTorch's `torch._fused_adamw_` for non-Muon parameters, grouping tensors by device/dtype and DTensor placement.
91
 
92
  ### Dependency Graph
 
87
  - **`construct_shard_mesh()` / `get_slices_of_dtensor()`** (`distributed/utils.py`) — Utilities for building shard meshes from DTensor placements and computing per-rank local slices. Handles both `Shard` and `_StridedShard` (PyTorch 2.10+).
88
  - **Newton-Schulz iteration** (`newton_schulz.py`) — `_zeropower_via_newtonschulz5()`: 5 quintic iterations in bfloat16 with pre-optimized coefficients for gradient orthogonalization. Uses Triton kernel `matmul_transpose_assign` for efficient X @ X.T.
89
  - **QK Clipping** (`qk_clip.py`) — Optional dynamic clipping of attention head projections when QK logits exceed a threshold. Configured via `q_indices`, `k_indices`, `head_dim`, `threshold`.
90
+ - **Per-Head Muon** (`head_keys`) — Kimi K3 §2.5 variant: attention Q/K/V momentum matrices are partitioned along the head dimension (dim 0) and each head block is orthogonalized separately via the batched Newton-Schulz. `head_keys` maps name keys to per-head row strides (e.g. `{"q_proj": 128}`); adjusted LR uses the head-block shape. Works across all execution paths since NS always runs on the full gathered matrix.
91
  - **Fused AdamW** (`adamw.py`) — Uses PyTorch's `torch._fused_adamw_` for non-Muon parameters, grouping tensors by device/dtype and DTensor placement.
92
 
93
  ### Dependency Graph
docs/implementation.md CHANGED
@@ -9,11 +9,12 @@ This document explains the internal architecture of the Muon optimizer for revie
9
  3. [Execution Paths](#execution-paths)
10
  4. [Parallel Pipeline (the core feature)](#parallel-pipeline)
11
  5. [MoE Expert Weight Support](#moe-expert-weight-support-expert_keys)
12
- 6. [Distributed Utilities](#distributed-utilities)
13
- 7. [Newton-Schulz Orthogonalization](#newton-schulz-orthogonalization)
14
- 8. [QK Clipping](#qk-clipping)
15
- 9. [AdamW for Non-Muon Parameters](#adamw-for-non-muon-parameters)
16
- 10. [Source File Map](#source-file-map)
 
17
 
18
  ---
19
 
@@ -158,7 +159,7 @@ class _muon_state:
158
 
159
  #### Stage 3: Compute
160
 
161
- The owning rank runs `_zeropower_via_newtonschulz5()` on the full gathered gradient. This is the most compute-intensive stage. Runs inline (no yield) since it is synchronous GPU work.
162
 
163
  #### Stages 4-5: Scatter
164
 
@@ -215,6 +216,26 @@ After expansion, the 2D params flow through the standard routing: DTensors with
215
 
216
  For EP/EFSDP background and torchtitan integration details, see [`docs/expert_parallel.md`](expert_parallel.md).
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  ## Distributed Utilities
219
 
220
  **File:** `distributed/utils.py`
 
9
  3. [Execution Paths](#execution-paths)
10
  4. [Parallel Pipeline (the core feature)](#parallel-pipeline)
11
  5. [MoE Expert Weight Support](#moe-expert-weight-support-expert_keys)
12
+ 6. [Per-Head Muon](#per-head-muon-head_keys)
13
+ 7. [Distributed Utilities](#distributed-utilities)
14
+ 8. [Newton-Schulz Orthogonalization](#newton-schulz-orthogonalization)
15
+ 9. [QK Clipping](#qk-clipping)
16
+ 10. [AdamW for Non-Muon Parameters](#adamw-for-non-muon-parameters)
17
+ 11. [Source File Map](#source-file-map)
18
 
19
  ---
20
 
 
159
 
160
  #### Stage 3: Compute
161
 
162
+ The owning rank runs `_zeropower_via_newtonschulz5()` on the full gathered gradient. This is the most compute-intensive stage. Runs inline (no yield) since it is synchronous GPU work. Parameters with `_muon_state.head_dim` set (Per-Head Muon) instead dispatch to `zeropower_via_newtonschulz5_per_head()`, which orthogonalizes each attention head's block separately.
163
 
164
  #### Stages 4-5: Scatter
165
 
 
216
 
217
  For EP/EFSDP background and torchtitan integration details, see [`docs/expert_parallel.md`](expert_parallel.md).
218
 
219
+ ## Per-Head Muon (`head_keys`)
220
+
221
+ **Files:** `newton_schulz.py` — `zeropower_via_newtonschulz5_per_head()`, `core.py` — `get_head_dim()` / `resolve_head_dim()`
222
+
223
+ Implements the per-head variant of Muon from the Kimi K3 tech report (§2.5). Full-matrix orthogonalization treats all attention heads as a single coupled block, so heads with larger momentum scale dominate the shared update while small-scale heads receive insufficiently normalized updates. Per-Head Muon partitions the momentum matrix of Q/K/V projections along the head dimension (dim 0) and orthogonalizes each head's block separately, equalizing the update scale across heads. It is also slightly cheaper: the Gram matrix per block is `head_dim x head_dim` instead of `min(M, N)^2`.
224
+
225
+ ### Configuration
226
+
227
+ ```python
228
+ optim = Muon(params, head_keys={"q_proj": 128, "k_proj": 128, "v_proj": 128}, ...)
229
+ ```
230
+
231
+ `head_keys` maps parameter-name keys (component-level matching, same rule as `expert_keys`) to the dim-0 row stride of one head. For MLA, pass each projection's per-head stride, e.g. `{"wq_b": qk_nope_head_dim + qk_rope_head_dim, "wkv_b": qk_nope_head_dim + v_head_dim}`. Only dim-0 (output-side) head layouts are supported — Q/K/V projections, not `o_proj`. Keys must be disjoint from `expert_keys`.
232
+
233
+ ### How It Works
234
+
235
+ - **Momentum is unchanged** (elementwise); only the Newton-Schulz block granularity changes. A matching `[H * head_dim, N]` matrix is viewed as `[H, head_dim, N]` and run through the batched bmm-based Newton-Schulz (the same kernel as per-expert NS), which normalizes each block by its own Frobenius norm.
236
+ - **The size-adjusted LR uses the head-block shape** `(head_dim, N)`, mirroring the per-expert path where each block is the NS unit.
237
+ - **Sharding-agnostic**: all three execution paths (`base()`, `distributed_muon()`, `parallel()`) apply the head partition to the *full global* matrix — the parallel path gathers the full matrix on the owner rank before NS — so the result is identical for any DP/TP/HSDP placement, including shard boundaries that cut mid-head. No communication changes are needed; `_muon_state.head_dim` just switches the compute call.
238
+
239
  ## Distributed Utilities
240
 
241
  **File:** `distributed/utils.py`
test/conftest.py CHANGED
@@ -4,7 +4,6 @@ import pytest
4
  import torch
5
  import torch.distributed as dist
6
  from packaging import version
7
- from transformers import AutoModelForCausalLM
8
 
9
  logger = logging.getLogger(__name__)
10
  logging.basicConfig(level=logging.INFO)
@@ -99,6 +98,10 @@ def inputs():
99
  - list[torch.Tensor]: A list of random gradients for each model parameter.
100
  - dict[int, torch.Tensor]: A dictionary mapping layer indices to random QK logits.
101
  """
 
 
 
 
102
  model_name = "Motif-Technologies/Motif-2.6B-4layer-random"
103
 
104
  torch.manual_seed(SEED)
 
4
  import torch
5
  import torch.distributed as dist
6
  from packaging import version
 
7
 
8
  logger = logging.getLogger(__name__)
9
  logging.basicConfig(level=logging.INFO)
 
98
  - list[torch.Tensor]: A list of random gradients for each model parameter.
99
  - dict[int, torch.Tensor]: A dictionary mapping layer indices to random QK logits.
100
  """
101
+ # Lazy import so test files that don't use this fixture (e.g. the
102
+ # synthetic per-head tests) can run in images without transformers.
103
+ from transformers import AutoModelForCausalLM
104
+
105
  model_name = "Motif-Technologies/Motif-2.6B-4layer-random"
106
 
107
  torch.manual_seed(SEED)
test/run_test_per_head.sh ADDED
@@ -0,0 +1 @@
 
 
1
+ torchrun --nproc-per-node=8 --local-ranks-filter=0 -m pytest test_muon_per_head.py
test/test_muon_per_head.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for Per-Head Muon (Kimi K3 tech report, §2.5).
2
+
3
+ Per-Head Muon partitions the momentum matrix of attention projections
4
+ along the head dimension (dim 0) and orthogonalizes each head's block
5
+ separately, instead of running Newton-Schulz on the full projection
6
+ matrix. These tests verify:
7
+
8
+ 1. Paper intent — per-head orthogonalization decouples heads (rescaling
9
+ one head's gradient does not change any head's update) and equalizes
10
+ the per-head update scale, unlike full-matrix Muon where heads with
11
+ larger momentum scale dominate the coupled update.
12
+ 2. Sharding correctness — the parallel (all2all) and distributed_muon
13
+ paths match the single-device base path bit-exactly under DP/TP/HSDP
14
+ placements, including shard boundaries that cut through the middle of
15
+ a head block.
16
+ 3. EP coexistence — expert_keys (per-expert NS) and head_keys (per-head
17
+ NS) work together in one optimizer instance.
18
+
19
+ Run: torchrun --nproc-per-node=8 --local-ranks-filter=0 -m pytest test_muon_per_head.py
20
+ """
21
+
22
+ import logging
23
+ import math
24
+
25
+ import pytest
26
+ import torch
27
+ import torch.distributed as dist
28
+ from optimizer.core import adjust_lr_for_muon
29
+ from optimizer.muon import Muon
30
+ from optimizer.newton_schulz import set_ns_compile
31
+ from torch.distributed.tensor import (DTensor, Replicate, Shard,
32
+ distribute_tensor)
33
+
34
+ logger = logging.getLogger(__name__)
35
+ logging.basicConfig(level=logging.INFO)
36
+
37
+ LR = 0.02
38
+ WD = 0.01
39
+
40
+
41
+ def _param_group(params, names, ns_steps=5, weight_decay=WD):
42
+ return [{
43
+ "params": params,
44
+ "names": names,
45
+ "use_muon": True,
46
+ "lr": LR,
47
+ "weight_decay": weight_decay,
48
+ "momentum": 0.95,
49
+ "nesterov": True,
50
+ "ns_steps": ns_steps,
51
+ "none_grad": False,
52
+ }]
53
+
54
+
55
+ def _run_muon(full_params,
56
+ step_grads,
57
+ names,
58
+ head_keys=None,
59
+ expert_keys=None,
60
+ ns_steps=5,
61
+ **muon_kwargs):
62
+ """Run Muon on fresh plain-tensor params (single-device base path)."""
63
+ params = [torch.nn.Parameter(t.clone()) for t in full_params]
64
+ optim = Muon(params=_param_group(params, list(names), ns_steps=ns_steps),
65
+ head_keys=head_keys,
66
+ expert_keys=expert_keys,
67
+ **muon_kwargs)
68
+ for grads in step_grads:
69
+ for p, g in zip(params, grads):
70
+ p.grad = g.clone()
71
+ optim.step()
72
+ return [p.data for p in params]
73
+
74
+
75
+ def _run_muon_sharded(full_params,
76
+ step_grads,
77
+ names,
78
+ mesh,
79
+ placements,
80
+ head_keys=None,
81
+ expert_keys=None,
82
+ ns_steps=5,
83
+ **muon_kwargs):
84
+ """Run Muon on DTensor-sharded params, return gathered full tensors."""
85
+ params = []
86
+ for t in full_params:
87
+ dt = distribute_tensor(t.clone(), mesh, placements)
88
+ params.append(torch.nn.Parameter(dt))
89
+ optim = Muon(params=_param_group(params, list(names), ns_steps=ns_steps),
90
+ head_keys=head_keys,
91
+ expert_keys=expert_keys,
92
+ **muon_kwargs)
93
+ for grads in step_grads:
94
+ for p, g in zip(params, grads):
95
+ p.grad = distribute_tensor(g.clone(), mesh, p.data.placements)
96
+ optim.step()
97
+ return [p.data.full_tensor() for p in params]
98
+
99
+
100
+ def _make_attention_like_tensors(num_steps=2, seed=42):
101
+ """Attention-like params: shard boundaries deliberately cut mid-head
102
+ for q_proj (head_dim=40, 8-way row shard → 30 rows per rank), and
103
+ v_proj has tall head blocks (head_dim 96 > in_dim 48) to exercise the
104
+ transpose path of the batched Newton-Schulz.
105
+ """
106
+ torch.manual_seed(seed)
107
+ specs = [
108
+ ("layers.0.self_attn.q_proj.weight", (240, 64)), # 6 heads x 40
109
+ ("layers.0.self_attn.k_proj.weight", (128, 64)), # 8 heads x 16
110
+ ("layers.0.self_attn.v_proj.weight", (192, 48)), # 2 heads x 96
111
+ ("layers.0.mlp.up_proj.weight", (144, 96)), # control: full-matrix
112
+ ]
113
+ names = [n for n, _ in specs]
114
+ fulls = [torch.randn(s, device="cuda") for _, s in specs]
115
+ step_grads = [[torch.randn(s, device="cuda") for _, s in specs]
116
+ for _ in range(num_steps)]
117
+ return names, fulls, step_grads
118
+
119
+
120
+ HEAD_KEYS = {"q_proj": 40, "k_proj": 16, "v_proj": 96}
121
+
122
+ # Note: plain nested same-dim sharding ([Shard(0), Shard(0)]) is rejected
123
+ # by construct_shard_mesh (ambiguous layout). The real FSDP2 + colwise-TP
124
+ # nested dim-0 layout (_StridedShard) is covered by
125
+ # test_per_head_fsdp2_tp_colwise below.
126
+ SHARDING_CASES = [
127
+ pytest.param(((8, ), ("dp_shard", ), [Shard(0)]), id="fsdp"),
128
+ pytest.param(((8, ), ("tp", ), [Shard(1)]), id="tp_row"),
129
+ pytest.param(
130
+ ((2, 4),
131
+ ("dp_replicate", "dp_shard"), [Replicate(), Shard(0)]),
132
+ id="hsdp"),
133
+ pytest.param(((2, 4), ("dp_shard", "tp"), [Shard(0), Shard(1)]),
134
+ id="fsdp+tp_row"),
135
+ ]
136
+
137
+ # ======================================================================
138
+ # Sharding correctness (TP / DP / HSDP)
139
+ # ======================================================================
140
+
141
+
142
+ @pytest.mark.parametrize("sharding", SHARDING_CASES)
143
+ @pytest.mark.parametrize("use_distributed_muon", [False, True],
144
+ ids=["parallel", "distributed"])
145
+ def test_per_head_sharded_matches_base(init_dist, sharding,
146
+ use_distributed_muon):
147
+ """Sharded per-head Muon must match the single-device base path
148
+ bit-exactly. Runs 2 steps so the second step exercises head_dim
149
+ propagation through the _parallel_cache cached-state path.
150
+ """
151
+ mesh_shape, mesh_dim_names, placements = sharding
152
+ set_ns_compile(False)
153
+ try:
154
+ names, fulls, step_grads = _make_attention_like_tensors()
155
+ base_result = _run_muon(fulls, step_grads, names, head_keys=HEAD_KEYS)
156
+
157
+ mesh = dist.init_device_mesh("cuda",
158
+ mesh_shape,
159
+ mesh_dim_names=mesh_dim_names)
160
+ sharded_result = _run_muon_sharded(
161
+ fulls,
162
+ step_grads,
163
+ names,
164
+ mesh,
165
+ placements,
166
+ head_keys=HEAD_KEYS,
167
+ chunk_size=2,
168
+ warmup_step=1,
169
+ use_distributed_muon=use_distributed_muon)
170
+
171
+ for name, b, s in zip(names, base_result, sharded_result):
172
+ torch.testing.assert_close(s,
173
+ b,
174
+ atol=0,
175
+ rtol=0,
176
+ msg=lambda m, name=name: f"{name}: {m}")
177
+ finally:
178
+ set_ns_compile(True)
179
+
180
+
181
+ @pytest.mark.parametrize("sharding", [SHARDING_CASES[0], SHARDING_CASES[-1]])
182
+ def test_per_head_sharded_matches_base_compiled(init_dist, sharding):
183
+ """Same parity check with torch.compile + CUDA graphs enabled for the
184
+ batched per-head Newton-Schulz (tolerances match test_muon.py's
185
+ compiled runs)."""
186
+ mesh_shape, mesh_dim_names, placements = sharding
187
+ set_ns_compile(True)
188
+
189
+ names, fulls, step_grads = _make_attention_like_tensors()
190
+ base_result = _run_muon(fulls, step_grads, names, head_keys=HEAD_KEYS)
191
+
192
+ mesh = dist.init_device_mesh("cuda",
193
+ mesh_shape,
194
+ mesh_dim_names=mesh_dim_names)
195
+ sharded_result = _run_muon_sharded(fulls,
196
+ step_grads,
197
+ names,
198
+ mesh,
199
+ placements,
200
+ head_keys=HEAD_KEYS,
201
+ chunk_size=2,
202
+ warmup_step=1)
203
+
204
+ for name, b, s in zip(names, base_result, sharded_result):
205
+ torch.testing.assert_close(s,
206
+ b,
207
+ atol=1e-5,
208
+ rtol=1e-2,
209
+ msg=lambda m, name=name: f"{name}: {m}")
210
+
211
+
212
+ # ======================================================================
213
+ # Paper-intent verification (Kimi K3 §2.5)
214
+ # ======================================================================
215
+
216
+
217
+ def _single_param_update(name, head_keys, grad, ns_steps=10):
218
+ """One Muon step on a zero-init param with wd=0.
219
+
220
+ With p0 = 0 and weight_decay = 0, the post-step param is exactly
221
+ ``-adjusted_lr * u``, i.e. proportional to the orthogonalized update.
222
+ """
223
+ p = torch.nn.Parameter(torch.zeros_like(grad))
224
+ optim = Muon(params=_param_group([p], [name],
225
+ ns_steps=ns_steps,
226
+ weight_decay=0.0),
227
+ head_keys=head_keys)
228
+ p.grad = grad.clone()
229
+ optim.step()
230
+ return p.data
231
+
232
+
233
+ def test_per_head_decouples_heads(init_dist):
234
+ """K3 §2.5: full-matrix orthogonalization treats all heads as a single
235
+ coupled block, so heads with larger momentum scale dominate the shared
236
+ update; per-head orthogonalization removes that coupling.
237
+
238
+ Rescaling one head's gradient by 1024 (a power of two — exact in
239
+ fp32/bf16, and each head block is normalized by its own Frobenius
240
+ norm) must leave every per-head update bit-identical, while the
241
+ full-matrix update of the *other* heads changes materially.
242
+ """
243
+ set_ns_compile(False)
244
+ try:
245
+ torch.manual_seed(7)
246
+ H, d, N = 8, 32, 128
247
+ scaled_head = 3
248
+ name = "layers.0.self_attn.q_proj.weight"
249
+
250
+ g = torch.randn(H * d, N, device="cuda")
251
+ g_scaled = g.clone()
252
+ g_scaled[scaled_head * d:(scaled_head + 1) * d] *= 1024.0
253
+
254
+ u1 = _single_param_update(name, {"q_proj": d}, g).view(H, d, N)
255
+ u2 = _single_param_update(name, {"q_proj": d}, g_scaled).view(H, d, N)
256
+ torch.testing.assert_close(
257
+ u2,
258
+ u1,
259
+ atol=0,
260
+ rtol=0,
261
+ msg="per-head updates must be invariant to per-head rescaling")
262
+
263
+ f1 = _single_param_update(name, None, g).view(H, d, N)
264
+ f2 = _single_param_update(name, None, g_scaled).view(H, d, N)
265
+ other = [h for h in range(H) if h != scaled_head]
266
+ rel = ((f2[other] - f1[other]).norm() / f1[other].norm()).item()
267
+ # The per-head diff above is exactly zero; any clearly nonzero
268
+ # coupling on the full-matrix side proves the contrast (measured
269
+ # ~0.4 for this configuration).
270
+ assert rel > 0.1, (
271
+ f"full-matrix NS should couple heads, got rel diff {rel:.3f}")
272
+ logger.info("decoupling: full-matrix rel diff on other heads = %.3f",
273
+ rel)
274
+ finally:
275
+ set_ns_compile(True)
276
+
277
+
278
+ def test_per_head_equalizes_update_scale(init_dist):
279
+ """K3 §2.5: with imbalanced per-head momentum scales, full-matrix NS
280
+ leaves small-scale heads insufficiently normalized, while per-head NS
281
+ gives every head a near semi-orthogonal update block (Frobenius norm
282
+ ≈ sqrt(head_dim) for all heads).
283
+
284
+ Uses a tall full matrix (H*d > N, the K3 regime) — for a wide matrix
285
+ the polar factor has orthonormal rows and the imbalance would be
286
+ invisible.
287
+ """
288
+ set_ns_compile(False)
289
+ try:
290
+ torch.manual_seed(11)
291
+ H, d, N = 16, 32, 256 # H*d = 512 > N = 256
292
+ name = "layers.0.self_attn.q_proj.weight"
293
+ head_scales = torch.logspace(-2, 2, H, device="cuda")
294
+ g = torch.randn(H * d, N, device="cuda")
295
+ g = (g.view(H, d, N) * head_scales.view(H, 1, 1)).reshape(H * d, N)
296
+
297
+ # --- per-head ---
298
+ adj_ph = adjust_lr_for_muon(LR, (d, N))
299
+ u_ph = (-_single_param_update(name, {"q_proj": d}, g) / adj_ph).view(
300
+ H, d, N)
301
+ norms_ph = u_ph.norm(dim=(-2, -1))
302
+ ratio_ph = (norms_ph.max() / norms_ph.min()).item()
303
+
304
+ # Every head block ~ semi-orthogonal: ||U_h||_F ≈ sqrt(d), and
305
+ # U_h @ U_h^T ≈ I.
306
+ assert ratio_ph < 1.2, (
307
+ f"per-head update scale not equalized: max/min = {ratio_ph:.3f}")
308
+ torch.testing.assert_close(norms_ph,
309
+ torch.full_like(norms_ph, math.sqrt(d)),
310
+ rtol=0.15,
311
+ atol=0)
312
+ gram = torch.bmm(u_ph, u_ph.transpose(-2, -1))
313
+ eye = torch.eye(d, device="cuda").expand(H, d, d)
314
+ max_dev = (gram - eye).abs().max().item()
315
+ assert max_dev < 0.2, (
316
+ f"per-head update blocks not row-orthogonal: max dev {max_dev}")
317
+
318
+ # --- full-matrix (control) ---
319
+ adj_full = adjust_lr_for_muon(LR, (H * d, N))
320
+ u_full = (-_single_param_update(name, None, g) / adj_full).view(
321
+ H, d, N)
322
+ norms_full = u_full.norm(dim=(-2, -1))
323
+ ratio_full = (norms_full.max() / norms_full.min()).item()
324
+ assert ratio_full > 3.0, (
325
+ f"expected full-matrix NS to under-normalize dominated heads, "
326
+ f"got max/min = {ratio_full:.3f}")
327
+ logger.info("update-scale max/min ratio: per-head=%.3f, full=%.1f",
328
+ ratio_ph, ratio_full)
329
+ finally:
330
+ set_ns_compile(True)
331
+
332
+
333
+ def test_per_head_fsdp2_tp_colwise(init_dist):
334
+ """Real FSDP2 + colwise-TP layout — the production layout for Q/K/V.
335
+
336
+ ColwiseParallel gives Shard(0) on the tp mesh dim, then fully_shard
337
+ adds a strided shard on dim 0 over the dp mesh dim (_StridedShard):
338
+ nested dim-0 sharding where the per-rank pieces interleave head
339
+ blocks. Per-head NS must still match the single-device base path
340
+ bit-exactly because the pipeline gathers the full global matrix
341
+ before orthogonalization.
342
+ """
343
+ import torch.nn as nn
344
+ from torch.distributed.fsdp import fully_shard
345
+ from torch.distributed.tensor.parallel import (ColwiseParallel,
346
+ parallelize_module)
347
+
348
+ set_ns_compile(False)
349
+ try:
350
+ torch.manual_seed(21)
351
+
352
+ class Blk(nn.Module):
353
+
354
+ def __init__(self):
355
+ super().__init__()
356
+ # weight shapes: (240, 64), (128, 64), (192, 48), (144, 96)
357
+ self.q_proj = nn.Linear(64, 240, bias=False)
358
+ self.k_proj = nn.Linear(64, 128, bias=False)
359
+ self.v_proj = nn.Linear(48, 192, bias=False)
360
+ self.up_proj = nn.Linear(96, 144, bias=False)
361
+
362
+ model = Blk().cuda()
363
+ names = [n for n, _ in model.named_parameters()]
364
+ fulls = [p.data.clone() for p in model.parameters()]
365
+ step_grads = [[torch.randn_like(t) for t in fulls] for _ in range(2)]
366
+
367
+ base_result = _run_muon(fulls, step_grads, names, head_keys=HEAD_KEYS)
368
+
369
+ mesh = dist.init_device_mesh("cuda", (2, 4),
370
+ mesh_dim_names=("dp_shard", "tp"))
371
+ parallelize_module(
372
+ model, mesh["tp"], {
373
+ "q_proj": ColwiseParallel(),
374
+ "k_proj": ColwiseParallel(),
375
+ "v_proj": ColwiseParallel(),
376
+ "up_proj": ColwiseParallel(),
377
+ })
378
+ fully_shard(model, mesh=mesh["dp_shard"])
379
+
380
+ params = list(model.parameters())
381
+ logger.info("FSDP2+TP placements: %s", params[0].data.placements)
382
+ optim = Muon(params=_param_group(params, names),
383
+ head_keys=HEAD_KEYS,
384
+ chunk_size=2,
385
+ warmup_step=1)
386
+ for grads in step_grads:
387
+ for p, g in zip(params, grads):
388
+ full = DTensor.from_local(g.clone(),
389
+ device_mesh=p.data.device_mesh,
390
+ placements=[Replicate()] *
391
+ p.data.device_mesh.ndim)
392
+ p.grad = full.redistribute(device_mesh=p.data.device_mesh,
393
+ placements=p.data.placements)
394
+ optim.step()
395
+
396
+ for name, b, p in zip(names, base_result, model.parameters()):
397
+ torch.testing.assert_close(p.data.full_tensor(),
398
+ b,
399
+ atol=0,
400
+ rtol=0,
401
+ msg=lambda m, name=name: f"{name}: {m}")
402
+ finally:
403
+ set_ns_compile(True)
404
+
405
+
406
+ # ======================================================================
407
+ # EP coexistence and validation
408
+ # ======================================================================
409
+
410
+
411
+ def test_per_head_with_expert_keys(init_dist):
412
+ """head_keys (attention, per-head NS) and expert_keys (MoE, per-expert
413
+ NS) coexist in one optimizer. Expert params take the batched-expert
414
+ path (which pre-launches the first A2A gather), attention params take
415
+ the parallel per-head path. Sharded result must match the
416
+ single-device baseline.
417
+ """
418
+ world_size = dist.get_world_size()
419
+ set_ns_compile(False)
420
+ try:
421
+ torch.manual_seed(13)
422
+ E = world_size # one expert per rank under Shard(0) — EP-style
423
+ specs = [
424
+ ("layers.0.moe.experts.w1", (E, 48, 32)),
425
+ ("layers.0.self_attn.q_proj.weight", (160, 64)), # 5 heads x 32
426
+ ("layers.0.mlp.up_proj.weight", (96, 64)),
427
+ ]
428
+ names = [n for n, _ in specs]
429
+ fulls = [torch.randn(s, device="cuda") for _, s in specs]
430
+ step_grads = [[torch.randn_like(t) for t in fulls] for _ in range(2)]
431
+ head_keys = {"q_proj": 32}
432
+ expert_keys = ["experts"]
433
+
434
+ base_result = _run_muon(fulls,
435
+ step_grads,
436
+ names,
437
+ head_keys=head_keys,
438
+ expert_keys=expert_keys)
439
+
440
+ mesh = dist.init_device_mesh("cuda", (world_size, ),
441
+ mesh_dim_names=("dp", ))
442
+ sharded_result = _run_muon_sharded(fulls,
443
+ step_grads,
444
+ names,
445
+ mesh, [Shard(0)],
446
+ head_keys=head_keys,
447
+ expert_keys=expert_keys,
448
+ chunk_size=1,
449
+ warmup_step=1)
450
+
451
+ for name, b, s in zip(names, base_result, sharded_result):
452
+ torch.testing.assert_close(s,
453
+ b,
454
+ atol=0,
455
+ rtol=0,
456
+ msg=lambda m, name=name: f"{name}: {m}")
457
+ finally:
458
+ set_ns_compile(True)
459
+
460
+
461
+ def test_head_keys_validation(init_dist):
462
+ """Misconfigured head_keys fail loudly, before touching comms."""
463
+ # dim-0 not divisible by head_dim → ValueError at step time.
464
+ set_ns_compile(False)
465
+ try:
466
+ p = torch.nn.Parameter(torch.randn(30, 16, device="cuda"))
467
+ p.grad = torch.randn_like(p)
468
+ optim = Muon(params=_param_group([p],
469
+ ["layers.0.self_attn.q_proj.weight"]),
470
+ head_keys={"q_proj": 7})
471
+ with pytest.raises(ValueError, match="not divisible"):
472
+ optim.step()
473
+ finally:
474
+ set_ns_compile(True)
475
+
476
+ # head_dim must be a positive int.
477
+ p = torch.nn.Parameter(torch.randn(8, 8, device="cuda"))
478
+ with pytest.raises(ValueError, match="positive int"):
479
+ Muon(params=_param_group([p], ["w"]), head_keys={"q_proj": 0})
480
+
481
+ # head_keys and expert_keys must be disjoint.
482
+ with pytest.raises(ValueError, match="disjoint"):
483
+ Muon(params=_param_group([p], ["w"]),
484
+ head_keys={"experts": 4},
485
+ expert_keys=["experts"])
torch-ext/optimizer/core.py CHANGED
@@ -29,6 +29,9 @@ class _muon_state:
29
  rank_numels: dict[int, int] # local_rank -> numel
30
  name: str
31
  qk_clip_state: torch.Tensor | None = None
 
 
 
32
 
33
 
34
  def _batch_momentum(
@@ -157,6 +160,34 @@ def is_expert_param(name, expert_keys):
157
  return any(_match_key(parts, key) for key in expert_keys)
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def default_is_muon(name, x, expert_keys=None):
161
  normalized = normalize_fqn(name)
162
  parts = normalized.split(".")
 
29
  rank_numels: dict[int, int] # local_rank -> numel
30
  name: str
31
  qk_clip_state: torch.Tensor | None = None
32
+ # Per-Head Muon: dim-0 row stride of one attention head. When set,
33
+ # Newton-Schulz runs per head block instead of on the full matrix.
34
+ head_dim: int | None = None
35
 
36
 
37
  def _batch_momentum(
 
160
  return any(_match_key(parts, key) for key in expert_keys)
161
 
162
 
163
+ def get_head_dim(name, head_keys):
164
+ """Return the per-head dim-0 row stride for a param matching head_keys.
165
+
166
+ ``head_keys`` maps name keys (component-level matching, same rule as
167
+ ``expert_keys``) to the number of dim-0 rows each attention head
168
+ occupies. Returns None if the param matches no key.
169
+ """
170
+ if not head_keys:
171
+ return None
172
+ parts = normalize_fqn(name).split(".")
173
+ for key, head_dim in head_keys.items():
174
+ if _match_key(parts, key):
175
+ return head_dim
176
+ return None
177
+
178
+
179
+ def resolve_head_dim(name, shape, head_keys):
180
+ """``get_head_dim`` plus dim-0 divisibility validation."""
181
+ head_dim = get_head_dim(name, head_keys)
182
+ if head_dim is None:
183
+ return None
184
+ if shape[0] % head_dim != 0:
185
+ raise ValueError(
186
+ f"Param {name}: dim-0 size {shape[0]} is not divisible by "
187
+ f"head_dim={head_dim} from head_keys.")
188
+ return head_dim
189
+
190
+
191
  def default_is_muon(name, x, expert_keys=None):
192
  normalized = normalize_fqn(name)
193
  parts = normalized.split(".")
torch-ext/optimizer/distributed/utils.py CHANGED
@@ -205,7 +205,11 @@ def construct_shard_mesh(
205
  else:
206
  shard_meshes = [sorted_mesh]
207
  shard_placements = sorted_placements[num_rep:]
208
- assert len(shard_placements) == len(set(shard_placements))
 
 
 
 
209
 
210
  # -- Step 4: Create/retrieve ProcessGroup for current rank's sub-mesh. --
211
  # Each rank only creates the group it belongs to, using
 
205
  else:
206
  shard_meshes = [sorted_mesh]
207
  shard_placements = sorted_placements[num_rep:]
208
+ assert len(shard_placements) == len(set(shard_placements)), (
209
+ f"Duplicate shard placements {shard_placements}: plain nested "
210
+ f"same-dim sharding (e.g. [Shard(0), Shard(0)]) is ambiguous and "
211
+ f"unsupported. Real FSDP2 + TP layouts use _StridedShard for the "
212
+ f"nested dim, which is supported.")
213
 
214
  # -- Step 4: Create/retrieve ProcessGroup for current rank's sub-mesh. --
215
  # Each rank only creates the group it belongs to, using
torch-ext/optimizer/muon.py CHANGED
@@ -11,14 +11,17 @@ from torch.profiler import record_function
11
  from .adamw import _placement_cache, _tensor_cache, step_adamw
12
  from .async_utils import run_pipeline
13
  from .core import (_muon_state, adjust_lr_for_muon, batch_pre_ortho,
14
- get_default_muon_param_groups, is_expert_param, update_p)
 
15
  from .cpu_offload import CPUOffloadPool
16
  from .distributed.utils import (_is_shard, construct_shard_mesh,
17
  get_slices_of_dtensor)
18
  from .newton_schulz import (COMM_DTYPE, DEFAULT_CHUNK_SIZE_RATIO,
19
  _zeropower_via_newtonschulz5,
 
20
  zeropower_via_newtonschulz5,
21
- zeropower_via_newtonschulz5_batched)
 
22
  from .pipeline import muon_chunk_pipeline, prelaunch_first_gather
23
  from .qk_clip import compute_scales, get_qk_clip_info, qk_clip
24
 
@@ -190,6 +193,22 @@ class Muon(torch.optim.Optimizer):
190
  ``expert_keys=["experts"]`` matches any param whose name
191
  contains "experts". 3D+ params not matched by any key
192
  will raise an error.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  """
194
 
195
  def __init__(self,
@@ -207,7 +226,8 @@ class Muon(torch.optim.Optimizer):
207
  warmup_step=5,
208
  chunk_size=-1,
209
  use_distributed_muon=False,
210
- expert_keys=None):
 
211
  defaults = dict(
212
  lr=lr,
213
  weight_decay=weight_decay,
@@ -241,6 +261,16 @@ class Muon(torch.optim.Optimizer):
241
  self.chunk_size = chunk_size
242
  self.use_distributed_muon = use_distributed_muon
243
  self.expert_keys = expert_keys
 
 
 
 
 
 
 
 
 
 
244
  self.cpu_offload = False
245
  self.manual_offload = False
246
  self._cpu_offload_pool: CPUOffloadPool | None = None
@@ -318,6 +348,7 @@ class Muon(torch.optim.Optimizer):
318
  worker_rank = shard_mesh_flattened[round_robin].item() % num_ranks
319
  round_robin = (round_robin + 1) % len(shard_mesh_flattened)
320
  qk_clip_state = get_qk_clip_info(self.clip_config, n, qk_logits)
 
321
 
322
  # Precompute per-rank indices and numels for all-to-all.
323
  rank_indices: dict[int, tuple] = {}
@@ -342,6 +373,7 @@ class Muon(torch.optim.Optimizer):
342
  rank_numels=rank_numels,
343
  name=n,
344
  qk_clip_state=qk_clip_state,
 
345
  )
346
 
347
  return param_to_state, ordered_params
@@ -353,10 +385,15 @@ class Muon(torch.optim.Optimizer):
353
  if g is None:
354
  continue
355
 
356
- u = zeropower_via_newtonschulz5(g.to(COMM_DTYPE),
357
- steps=group["ns_steps"])
358
-
359
- adjusted_lr = adjust_lr_for_muon(lr, p.shape)
 
 
 
 
 
360
  update_p(p, u, lr, adjusted_lr, weight_decay)
361
 
362
  qk_clip_state = get_qk_clip_info(self.clip_config, n, qk_logits)
@@ -386,6 +423,20 @@ class Muon(torch.optim.Optimizer):
386
  # Momentum is already applied by _step_muon before this method.
387
  ns_steps = group["ns_steps"]
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  # Separate plain tensors (no communication) from DTensors.
390
  plain_names, plain_params = [], []
391
  dtensor_names, dtensor_params = [], []
@@ -401,9 +452,8 @@ class Muon(torch.optim.Optimizer):
401
 
402
  # Process plain tensors per-param (no communication).
403
  for n, p in zip(plain_names, plain_params):
404
- u = _zeropower_via_newtonschulz5(p.grad.to(COMM_DTYPE),
405
- steps=ns_steps)
406
- adjusted_lr = adjust_lr_for_muon(lr, p.shape)
407
  update_p(p, u, lr, adjusted_lr, weight_decay)
408
 
409
  qk_clip_state = get_qk_clip_info(self.clip_config, n,
@@ -461,11 +511,10 @@ class Muon(torch.optim.Optimizer):
461
  for n, p in zip(single_names, single_params):
462
  with record_function("distributed_muon::newton_schulz"):
463
  g_full = p.grad.full_tensor().to(COMM_DTYPE)
464
- u_full = _zeropower_via_newtonschulz5(g_full,
465
- steps=ns_steps)
466
  del g_full
467
  with record_function("distributed_muon::update"):
468
- adjusted_lr = adjust_lr_for_muon(lr, p.shape)
469
  p._local_tensor.mul_(1 - lr * weight_decay)
470
  local_indices = get_slices_of_dtensor(
471
  p, rank, shard_mesh, shard_placements)
@@ -551,12 +600,11 @@ class Muon(torch.optim.Optimizer):
551
  g_full[indices] = shard.reshape(
552
  g_full[indices].shape)
553
 
554
- u_full = _zeropower_via_newtonschulz5(g_full,
555
- steps=ns_steps)
556
  del g_full
557
 
558
  with record_function("distributed_muon::update"):
559
- adjusted_lr = adjust_lr_for_muon(lr, p.shape)
560
  p._local_tensor.mul_(1 - lr * weight_decay)
561
  local_indices = get_slices_of_dtensor(
562
  p, rank, shard_mesh, shard_placements)
@@ -650,6 +698,7 @@ class Muon(torch.optim.Optimizer):
650
  name=n,
651
  qk_clip_state=get_qk_clip_info(self.clip_config, n,
652
  qk_logits),
 
653
  )
654
 
655
  return ordered_params, param_to_state, rank, chunk_size
 
11
  from .adamw import _placement_cache, _tensor_cache, step_adamw
12
  from .async_utils import run_pipeline
13
  from .core import (_muon_state, adjust_lr_for_muon, batch_pre_ortho,
14
+ get_default_muon_param_groups, is_expert_param,
15
+ resolve_head_dim, update_p)
16
  from .cpu_offload import CPUOffloadPool
17
  from .distributed.utils import (_is_shard, construct_shard_mesh,
18
  get_slices_of_dtensor)
19
  from .newton_schulz import (COMM_DTYPE, DEFAULT_CHUNK_SIZE_RATIO,
20
  _zeropower_via_newtonschulz5,
21
+ _zeropower_via_newtonschulz5_per_head,
22
  zeropower_via_newtonschulz5,
23
+ zeropower_via_newtonschulz5_batched,
24
+ zeropower_via_newtonschulz5_per_head)
25
  from .pipeline import muon_chunk_pipeline, prelaunch_first_gather
26
  from .qk_clip import compute_scales, get_qk_clip_info, qk_clip
27
 
 
193
  ``expert_keys=["experts"]`` matches any param whose name
194
  contains "experts". 3D+ params not matched by any key
195
  will raise an error.
196
+ head_keys: Per-Head Muon (Kimi K3 tech report, §2.5). Mapping from
197
+ parameter-name keys (component-level matching, same rule
198
+ as ``expert_keys``) to the dim-0 row stride of one
199
+ attention head. A matching ``[H * head_dim, in]`` param
200
+ has its momentum matrix partitioned along dim 0 into
201
+ ``H`` head blocks that are orthogonalized independently,
202
+ equalizing the update scale across heads. The
203
+ size-adjusted LR uses the head-block shape.
204
+ Example (MHA/GQA): ``head_keys={"q_proj": 128,
205
+ "k_proj": 128, "v_proj": 128}``.
206
+ For MLA, pass each projection's per-head row stride,
207
+ e.g. ``{"wq_b": qk_nope_head_dim + qk_rope_head_dim,
208
+ "wkv_b": qk_nope_head_dim + v_head_dim}``.
209
+ Only dim-0 (output-side) head layouts are supported —
210
+ Q/K/V projections, not o_proj. Keys must be disjoint
211
+ from ``expert_keys``.
212
  """
213
 
214
  def __init__(self,
 
226
  warmup_step=5,
227
  chunk_size=-1,
228
  use_distributed_muon=False,
229
+ expert_keys=None,
230
+ head_keys=None):
231
  defaults = dict(
232
  lr=lr,
233
  weight_decay=weight_decay,
 
261
  self.chunk_size = chunk_size
262
  self.use_distributed_muon = use_distributed_muon
263
  self.expert_keys = expert_keys
264
+ if head_keys:
265
+ for key, hd in head_keys.items():
266
+ if not isinstance(hd, int) or hd <= 0:
267
+ raise ValueError(f"head_keys[{key!r}] must be a positive "
268
+ f"int (got {hd!r}).")
269
+ if expert_keys and key in expert_keys:
270
+ raise ValueError(
271
+ f"Key {key!r} appears in both expert_keys and "
272
+ f"head_keys; they must be disjoint.")
273
+ self.head_keys = head_keys
274
  self.cpu_offload = False
275
  self.manual_offload = False
276
  self._cpu_offload_pool: CPUOffloadPool | None = None
 
348
  worker_rank = shard_mesh_flattened[round_robin].item() % num_ranks
349
  round_robin = (round_robin + 1) % len(shard_mesh_flattened)
350
  qk_clip_state = get_qk_clip_info(self.clip_config, n, qk_logits)
351
+ head_dim = resolve_head_dim(n, p.shape, self.head_keys)
352
 
353
  # Precompute per-rank indices and numels for all-to-all.
354
  rank_indices: dict[int, tuple] = {}
 
373
  rank_numels=rank_numels,
374
  name=n,
375
  qk_clip_state=qk_clip_state,
376
+ head_dim=head_dim,
377
  )
378
 
379
  return param_to_state, ordered_params
 
385
  if g is None:
386
  continue
387
 
388
+ head_dim = resolve_head_dim(n, p.shape, self.head_keys)
389
+ if head_dim is not None:
390
+ u = zeropower_via_newtonschulz5_per_head(
391
+ g.to(COMM_DTYPE), head_dim, steps=group["ns_steps"])
392
+ adjusted_lr = adjust_lr_for_muon(lr, (head_dim, p.shape[1]))
393
+ else:
394
+ u = zeropower_via_newtonschulz5(g.to(COMM_DTYPE),
395
+ steps=group["ns_steps"])
396
+ adjusted_lr = adjust_lr_for_muon(lr, p.shape)
397
  update_p(p, u, lr, adjusted_lr, weight_decay)
398
 
399
  qk_clip_state = get_qk_clip_info(self.clip_config, n, qk_logits)
 
423
  # Momentum is already applied by _step_muon before this method.
424
  ns_steps = group["ns_steps"]
425
 
426
+ def _ns_full(n, g_full):
427
+ """Full-matrix or per-head NS depending on head_keys."""
428
+ head_dim = resolve_head_dim(n, g_full.shape, self.head_keys)
429
+ if head_dim is not None:
430
+ return _zeropower_via_newtonschulz5_per_head(
431
+ g_full, head_dim, ns_steps)
432
+ return _zeropower_via_newtonschulz5(g_full, steps=ns_steps)
433
+
434
+ def _adjusted_lr(n, shape):
435
+ head_dim = resolve_head_dim(n, shape, self.head_keys)
436
+ if head_dim is not None:
437
+ return adjust_lr_for_muon(lr, (head_dim, shape[1]))
438
+ return adjust_lr_for_muon(lr, shape)
439
+
440
  # Separate plain tensors (no communication) from DTensors.
441
  plain_names, plain_params = [], []
442
  dtensor_names, dtensor_params = [], []
 
452
 
453
  # Process plain tensors per-param (no communication).
454
  for n, p in zip(plain_names, plain_params):
455
+ u = _ns_full(n, p.grad.to(COMM_DTYPE))
456
+ adjusted_lr = _adjusted_lr(n, p.shape)
 
457
  update_p(p, u, lr, adjusted_lr, weight_decay)
458
 
459
  qk_clip_state = get_qk_clip_info(self.clip_config, n,
 
511
  for n, p in zip(single_names, single_params):
512
  with record_function("distributed_muon::newton_schulz"):
513
  g_full = p.grad.full_tensor().to(COMM_DTYPE)
514
+ u_full = _ns_full(n, g_full)
 
515
  del g_full
516
  with record_function("distributed_muon::update"):
517
+ adjusted_lr = _adjusted_lr(n, p.shape)
518
  p._local_tensor.mul_(1 - lr * weight_decay)
519
  local_indices = get_slices_of_dtensor(
520
  p, rank, shard_mesh, shard_placements)
 
600
  g_full[indices] = shard.reshape(
601
  g_full[indices].shape)
602
 
603
+ u_full = _ns_full(n, g_full)
 
604
  del g_full
605
 
606
  with record_function("distributed_muon::update"):
607
+ adjusted_lr = _adjusted_lr(n, p.shape)
608
  p._local_tensor.mul_(1 - lr * weight_decay)
609
  local_indices = get_slices_of_dtensor(
610
  p, rank, shard_mesh, shard_placements)
 
698
  name=n,
699
  qk_clip_state=get_qk_clip_info(self.clip_config, n,
700
  qk_logits),
701
+ head_dim=cached_state.head_dim,
702
  )
703
 
704
  return ordered_params, param_to_state, rank, chunk_size
torch-ext/optimizer/newton_schulz.py CHANGED
@@ -197,6 +197,27 @@ def _zeropower_via_newtonschulz5_batched(G, steps):
197
  return X
198
 
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  _ns_per_shape: dict[tuple[int, ...], callable] = {}
201
  _use_compile = True
202
 
@@ -235,3 +256,17 @@ def zeropower_via_newtonschulz5_batched(G, steps=5):
235
  })
236
  torch.compiler.cudagraph_mark_step_begin()
237
  return _ns_per_shape[key](G, steps).clone()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  return X
198
 
199
 
200
+ @torch.no_grad()
201
+ def _zeropower_via_newtonschulz5_per_head(G, head_dim, steps):
202
+ """Per-head polar factor for attention projection matrices.
203
+
204
+ Implements Per-Head Muon (Kimi K3 tech report, §2.5): instead of
205
+ orthogonalizing the full ``[H * head_dim, N]`` projection matrix as a
206
+ single coupled block, the matrix is viewed as ``H`` independent
207
+ ``[head_dim, N]`` head blocks (dim-0 partition) and each block is
208
+ orthogonalized separately via the batched Newton-Schulz path. Each
209
+ block is normalized by its own Frobenius norm, so heads with large
210
+ momentum scale no longer dominate the shared update direction and
211
+ the update scale is equalized across heads.
212
+ """
213
+ assert len(G.shape) == 2
214
+ M, N = G.shape
215
+ assert M % head_dim == 0, (
216
+ f"dim-0 size {M} is not divisible by head_dim {head_dim}")
217
+ X = G.reshape(M // head_dim, head_dim, N)
218
+ return _zeropower_via_newtonschulz5_batched(X, steps).reshape(M, N)
219
+
220
+
221
  _ns_per_shape: dict[tuple[int, ...], callable] = {}
222
  _use_compile = True
223
 
 
256
  })
257
  torch.compiler.cudagraph_mark_step_begin()
258
  return _ns_per_shape[key](G, steps).clone()
259
+
260
+
261
+ def zeropower_via_newtonschulz5_per_head(G, head_dim, steps=5):
262
+ """Compile-cached per-head Newton-Schulz (Per-Head Muon).
263
+
264
+ See ``_zeropower_via_newtonschulz5_per_head``. Dispatches to the
265
+ compile-cached batched kernel keyed by the ``[H, head_dim, N]``
266
+ view shape.
267
+ """
268
+ M, N = G.shape
269
+ assert M % head_dim == 0, (
270
+ f"dim-0 size {M} is not divisible by head_dim {head_dim}")
271
+ X = G.reshape(M // head_dim, head_dim, N)
272
+ return zeropower_via_newtonschulz5_batched(X, steps=steps).reshape(M, N)
torch-ext/optimizer/pipeline.py CHANGED
@@ -7,7 +7,8 @@ from torch.distributed.tensor import DTensor
7
  from torch.profiler import record_function
8
 
9
  from .core import _muon_state, adjust_lr_for_muon
10
- from .newton_schulz import COMM_DTYPE, zeropower_via_newtonschulz5
 
11
  from .qk_clip import compute_scales
12
 
13
  logger = logging.getLogger(__name__)
@@ -143,15 +144,27 @@ def _compute_ns(
143
  owned_params: list[DTensor],
144
  gathered_grads: dict[int, torch.Tensor | None],
145
  ns_steps: int,
 
146
  ) -> dict[int, torch.Tensor | None]:
147
  """Run Newton-Schulz orthogonalization on owned parameters.
148
 
 
 
 
 
 
 
149
  Returns:
150
  computed_us: ``{id(p): orthogonalized_update}`` for owned params.
151
  """
152
  computed_us: dict[int, torch.Tensor | None] = {}
153
  for p in owned_params:
154
- u = zeropower_via_newtonschulz5(gathered_grads[id(p)], ns_steps)
 
 
 
 
 
155
  gathered_grads[id(p)] = None # free gathered grad
156
  computed_us[id(p)] = u
157
  return computed_us
@@ -305,9 +318,15 @@ def _update_params(
305
 
306
  # Group params by adjusted_lr so _foreach_add_ can use a single
307
  # alpha per group (preserves float32 precision for alpha scaling).
 
 
308
  lr_groups: dict[float, tuple[list, list]] = {}
309
  for p in params:
310
- adjusted_lr = adjust_lr_for_muon(lr, p.shape)
 
 
 
 
311
  if adjusted_lr not in lr_groups:
312
  lr_groups[adjusted_lr] = ([], [])
313
  lr_groups[adjusted_lr][0].append(p._local_tensor)
@@ -443,7 +462,8 @@ def muon_chunk_pipeline(
443
 
444
  # Stage 3: Newton-Schulz orthogonalization.
445
  with record_function("muon::newton_schulz"):
446
- computed_us = _compute_ns(owned_params, gathered_grads, ns_steps)
 
447
  gathered_grads.clear()
448
 
449
  # Stages 4-5: launch async scatter.
 
7
  from torch.profiler import record_function
8
 
9
  from .core import _muon_state, adjust_lr_for_muon
10
+ from .newton_schulz import (COMM_DTYPE, zeropower_via_newtonschulz5,
11
+ zeropower_via_newtonschulz5_per_head)
12
  from .qk_clip import compute_scales
13
 
14
  logger = logging.getLogger(__name__)
 
144
  owned_params: list[DTensor],
145
  gathered_grads: dict[int, torch.Tensor | None],
146
  ns_steps: int,
147
+ param_to_state: dict[int, _muon_state],
148
  ) -> dict[int, torch.Tensor | None]:
149
  """Run Newton-Schulz orthogonalization on owned parameters.
150
 
151
+ Parameters whose ``_muon_state.head_dim`` is set are orthogonalized
152
+ per attention-head block (Per-Head Muon) instead of as one full
153
+ matrix. The gathered grad is always the full global matrix, so the
154
+ head partition is well-defined regardless of how the parameter is
155
+ sharded across ranks.
156
+
157
  Returns:
158
  computed_us: ``{id(p): orthogonalized_update}`` for owned params.
159
  """
160
  computed_us: dict[int, torch.Tensor | None] = {}
161
  for p in owned_params:
162
+ head_dim = param_to_state[id(p)].head_dim
163
+ if head_dim is not None:
164
+ u = zeropower_via_newtonschulz5_per_head(gathered_grads[id(p)],
165
+ head_dim, ns_steps)
166
+ else:
167
+ u = zeropower_via_newtonschulz5(gathered_grads[id(p)], ns_steps)
168
  gathered_grads[id(p)] = None # free gathered grad
169
  computed_us[id(p)] = u
170
  return computed_us
 
318
 
319
  # Group params by adjusted_lr so _foreach_add_ can use a single
320
  # alpha per group (preserves float32 precision for alpha scaling).
321
+ # Per-head params scale by the head-block shape: each block is the
322
+ # Newton-Schulz unit, mirroring the per-expert path.
323
  lr_groups: dict[float, tuple[list, list]] = {}
324
  for p in params:
325
+ head_dim = param_to_state[id(p)].head_dim
326
+ if head_dim is not None:
327
+ adjusted_lr = adjust_lr_for_muon(lr, (head_dim, p.shape[1]))
328
+ else:
329
+ adjusted_lr = adjust_lr_for_muon(lr, p.shape)
330
  if adjusted_lr not in lr_groups:
331
  lr_groups[adjusted_lr] = ([], [])
332
  lr_groups[adjusted_lr][0].append(p._local_tensor)
 
462
 
463
  # Stage 3: Newton-Schulz orthogonalization.
464
  with record_function("muon::newton_schulz"):
465
+ computed_us = _compute_ns(owned_params, gathered_grads, ns_steps,
466
+ param_to_state)
467
  gathered_grads.clear()
468
 
469
  # Stages 4-5: launch async scatter.