|
| 1 | +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | +# |
| 4 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +# you may not use this file except in compliance with the License. |
| 6 | +# You may obtain a copy of the License at |
| 7 | +# |
| 8 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +# |
| 10 | +# Unless required by applicable law or agreed to in writing, software |
| 11 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +# See the License for the specific language governing permissions and |
| 14 | +# limitations under the License. |
| 15 | + |
| 16 | +from typing import TYPE_CHECKING, Callable, override |
| 17 | + |
| 18 | + |
| 19 | +if TYPE_CHECKING: |
| 20 | + from typing import overload |
| 21 | + |
| 22 | +import torch |
| 23 | +from torch.optim.optimizer import ParamsT |
| 24 | + |
| 25 | +from emerging_optimizers import registry, utils |
| 26 | +from emerging_optimizers.orthogonalized_optimizers.muon import Muon, MuonScaleT |
| 27 | +from emerging_optimizers.orthogonalized_optimizers.muon_utils import NSCoeffT |
| 28 | +from emerging_optimizers.scalar_optimizers import update_functions |
| 29 | +from emerging_optimizers.utils import FP32MatmulPrecT |
| 30 | + |
| 31 | + |
| 32 | +__all__ = ["Muown"] |
| 33 | + |
| 34 | + |
| 35 | +@registry.register_optimizer("muown") |
| 36 | +class Muown(Muon): |
| 37 | + """Muown: Muon with internal weight normalization (row-norm control). |
| 38 | +
|
| 39 | + Muown (Lion et al., *Muown: Row-Norm Control for Muon Optimization*, arXiv:2605.10797) is a drop-in |
| 40 | + replacement for :class:`~emerging_optimizers.orthogonalized_optimizers.muon.Muon` that splits each 2D |
| 41 | + weight into a per-row magnitude and a direction, then optimizes them under their natural geometries: |
| 42 | +
|
| 43 | + - **Direction** ``v``: the Muon update (EMA momentum + Newton-Schulz orthogonalization), reusing the |
| 44 | + parent's ``scaled_orthogonalize_fn``. |
| 45 | + - **Magnitude** ``g`` (one entry per output row): Adam, which realizes the :math:`\\ell_\\infty` duality |
| 46 | + map for the diagonal per-neuron gain. |
| 47 | +
|
| 48 | + The reparameterization ``W = Diag(g / ||v||_row) v`` is held implicitly inside the optimizer, so the |
| 49 | + forward pass is unchanged. At init ``g``, ``v`` are seeded from ``W`` so Muown starts from the same |
| 50 | + point as Muon. The row magnitude is the empirical driver of spectral-norm drift under Muon; making it an |
| 51 | + explicit, separately-optimized variable controls that drift without the indiscriminate shrinkage of |
| 52 | + plain weight decay. |
| 53 | +
|
| 54 | + A single ``lr`` drives both halves: the direction step carries the ``0.2 * sqrt(max(m, n))`` scaling |
| 55 | + (via ``extra_scale_factor``) that matches Adam's update RMS norm, so no separate magnitude lr is needed. |
| 56 | +
|
| 57 | + State per parameter: |
| 58 | + - ``step`` |
| 59 | + - ``g``: per-row magnitude, shape ``[out, 1]``. |
| 60 | + - ``v_norm``: cached row norms ``||v||_row`` of the direction, shape ``[out, 1]``. |
| 61 | + - ``momentum_buffer``: EMA momentum of the direction gradient (the Muon momentum on ``v``). |
| 62 | + - ``m_g``, ``v_g``: Adam first/second moments of the magnitude gradient. |
| 63 | +
|
| 64 | + Note: |
| 65 | + Weight decay is decoupled and applied to the magnitude ``g`` (which is exactly the spectral-norm |
| 66 | + driver). Because ``W`` is recomposed from ``g`` after the decay, the invariant ``||W_row|| == g`` |
| 67 | + holds without a separate resync. |
| 68 | +
|
| 69 | + Warning: |
| 70 | + - This optimizer requires that all parameters passed in are 2D. |
| 71 | + - It should not be used for the embedding layer, the final fully connected layer, or any 1-D |
| 72 | + parameters; those can all be optimized by a standard method (e.g., AdamW). |
| 73 | + - This optimizer is experimental and may change in future versions. |
| 74 | +
|
| 75 | + Args: |
| 76 | + params: Iterable of parameters to optimize or dicts defining parameter groups. |
| 77 | + lr: Learning rate shared by the direction and magnitude updates. |
| 78 | + momentum: EMA momentum for the direction (Muon) update. |
| 79 | + betas: Adam ``(beta1, beta2)`` for the magnitude update. |
| 80 | + adam_eps: Adam epsilon for the magnitude update. |
| 81 | + weight_decay: Decoupled weight decay coefficient, applied to the magnitude ``g``. |
| 82 | + fp32_matmul_prec: Precision for the orthogonalization GEMM operations. |
| 83 | + coefficient_type: Newton-Schulz coefficient set (see :class:`Muon`). |
| 84 | + num_ns_steps: Number of Newton-Schulz iteration steps. |
| 85 | + scale_mode: Update scale mode (see :func:`~emerging_optimizers.orthogonalized_optimizers.muon.get_muon_scale_factor`). |
| 86 | + extra_scale_factor: Extra scale on the direction update; ``0.2`` matches Adam's update RMS norm. |
| 87 | + use_syrk: Whether to use the Triton SYRK kernel for Newton-Schulz. |
| 88 | + """ |
| 89 | + |
| 90 | + def __init__( |
| 91 | + self, |
| 92 | + params: ParamsT, |
| 93 | + lr: float = 3e-4, |
| 94 | + momentum: float = 0.95, |
| 95 | + weight_decay: float = 0.0, |
| 96 | + *, |
| 97 | + betas: tuple[float, float] = (0.9, 0.95), |
| 98 | + adam_eps: float = 1e-8, |
| 99 | + fp32_matmul_prec: FP32MatmulPrecT = "medium", |
| 100 | + coefficient_type: NSCoeffT = "quintic", |
| 101 | + num_ns_steps: int = 5, |
| 102 | + scale_mode: MuonScaleT = "spectral", |
| 103 | + extra_scale_factor: float = 1.0, |
| 104 | + use_syrk: bool = False, |
| 105 | + ) -> None: |
| 106 | + if not 0.0 <= betas[0] < 1.0: |
| 107 | + raise ValueError(f"Invalid beta1: {betas[0]}") |
| 108 | + if not 0.0 <= betas[1] < 1.0: |
| 109 | + raise ValueError(f"Invalid beta2: {betas[1]}") |
| 110 | + |
| 111 | + self.betas = betas |
| 112 | + self.adam_eps = adam_eps |
| 113 | + |
| 114 | + super().__init__( |
| 115 | + params, |
| 116 | + lr, |
| 117 | + momentum, |
| 118 | + weight_decay, |
| 119 | + nesterov=False, |
| 120 | + weight_decay_method="decoupled", |
| 121 | + fp32_matmul_prec=fp32_matmul_prec, |
| 122 | + coefficient_type=coefficient_type, |
| 123 | + num_ns_steps=num_ns_steps, |
| 124 | + scale_mode=scale_mode, |
| 125 | + extra_scale_factor=extra_scale_factor, |
| 126 | + use_syrk=use_syrk, |
| 127 | + ) |
| 128 | + |
| 129 | + @torch.no_grad() # type: ignore[misc] |
| 130 | + @override |
| 131 | + def _init_group(self, group: dict, skip_non_grad_params: bool = True) -> None: |
| 132 | + for p in group["params"]: |
| 133 | + if skip_non_grad_params and p.grad is None: |
| 134 | + continue |
| 135 | + if p.dim() != 2: |
| 136 | + raise TypeError("Muown is only supported for 2D parameters") |
| 137 | + state = self.state[p] |
| 138 | + if len(state) == 0: |
| 139 | + # Seed g, v from the current weight so Muown starts from the same point as Muon. |
| 140 | + row_norm = p.norm(dim=1, keepdim=True).to(torch.float32) |
| 141 | + state["step"] = 0 |
| 142 | + state["g"] = row_norm.clone() |
| 143 | + state["v_norm"] = row_norm.clone() |
| 144 | + state["momentum_buffer"] = torch.zeros_like(p, dtype=torch.float32) |
| 145 | + state["m_g"] = torch.zeros_like(row_norm) |
| 146 | + state["v_g"] = torch.zeros_like(row_norm) |
| 147 | + |
| 148 | + if TYPE_CHECKING: |
| 149 | + |
| 150 | + @overload |
| 151 | + def step(self, closure: None = ...) -> None: ... |
| 152 | + |
| 153 | + @overload |
| 154 | + def step(self, closure: Callable[[], float]) -> float: ... |
| 155 | + |
| 156 | + @torch.no_grad() # type: ignore[misc] |
| 157 | + @override |
| 158 | + def step(self, closure: Callable[[], float] | None = None) -> float | None: |
| 159 | + """Performs a single optimization step. |
| 160 | +
|
| 161 | + Args: |
| 162 | + closure: Unsupported; must be ``None``. |
| 163 | + """ |
| 164 | + if closure is not None: |
| 165 | + raise ValueError("closure is not supported") |
| 166 | + |
| 167 | + for group in self.param_groups: |
| 168 | + self._init_group(group) |
| 169 | + |
| 170 | + lr = group["lr"] |
| 171 | + momentum = group["momentum"] |
| 172 | + weight_decay = group["weight_decay"] |
| 173 | + for p in group["params"]: |
| 174 | + if p.grad is None: |
| 175 | + continue # pragma: no cover |
| 176 | + |
| 177 | + state = self.state[p] |
| 178 | + state["step"] += 1 |
| 179 | + g = state["g"] |
| 180 | + v_norm = state["v_norm"] |
| 181 | + |
| 182 | + grad = p.grad.to(torch.float32) |
| 183 | + weight = p.to(torch.float32) |
| 184 | + |
| 185 | + # Decompose grad_W into magnitude and direction gradients via the weight-norm Jacobian. |
| 186 | + # u = v / ||v||_row is O(1) per element; reconstruct the direction v from the live weight. |
| 187 | + u = weight / g |
| 188 | + v = u * v_norm |
| 189 | + grad_g = (grad * u).sum(dim=1, keepdim=True) |
| 190 | + grad_v = (g / v_norm) * (grad - u * grad_g) |
| 191 | + |
| 192 | + # Direction: Muon update on v (EMA momentum + Newton-Schulz), reusing the parent LMO. |
| 193 | + state["momentum_buffer"].lerp_(grad_v, 1 - momentum) |
| 194 | + with utils.fp32_matmul_precision(self.fp32_matmul_prec): |
| 195 | + direction_update = self.scaled_orthogonalize_fn(state["momentum_buffer"]) |
| 196 | + v_new = v.add(direction_update, alpha=-lr) |
| 197 | + |
| 198 | + # Magnitude: Adam on g (the l-infinity duality map for the diagonal gain), same lr. |
| 199 | + magnitude_update = update_functions.calculate_adam_update( |
| 200 | + grad_g, |
| 201 | + state["m_g"], |
| 202 | + state["v_g"], |
| 203 | + betas=self.betas, |
| 204 | + eps=self.adam_eps, |
| 205 | + correct_bias=True, |
| 206 | + nesterov=False, |
| 207 | + step=state["step"], |
| 208 | + ) |
| 209 | + g.add_(magnitude_update, alpha=-lr) |
| 210 | + |
| 211 | + # Decoupled weight decay on the magnitude (the spectral-norm driver). |
| 212 | + if weight_decay != 0.0: |
| 213 | + g.add_(g, alpha=-weight_decay * lr) |
| 214 | + |
| 215 | + # Recompose W = g * v_new / ||v_new||_row and refresh the cached direction norm. Recomposing |
| 216 | + # from the decayed g keeps the invariant ||W_row|| == g without a separate resync. |
| 217 | + v_norm_new = v_new.norm(dim=1, keepdim=True) |
| 218 | + p.copy_(g * (v_new / v_norm_new)) |
| 219 | + state["v_norm"] = v_norm_new |
| 220 | + |
| 221 | + return None |
0 commit comments