NVIDIA-NeMo/Speech · #16427
feat(core): add the Muon optimizer with an AdamW fallback
docs/source/core/core.rst32 + / 0 −
@@ -445,6 +445,38 @@ Optimizers name: rmsprop opt: <class 'torch.optim.rmsprop.RMSprop'> name: rprop opt: <class 'torch.optim.rprop.Rprop'> name: novograd opt: <class 'nemo.core.optim.novograd.Novograd'>+ name: muon opt: <class 'nemo.core.optim.muon.Muon'>++``muon`` (Muon with an AdamW fallback) decides per parameter by shape:+a matrix below ``adam_dim_threshold`` in both dimensions takes the Muon update, every other parameter AdamW.+The shape alone does not tell an embedding from a hidden matrix,+so exclude small-vocabulary embeddings and tied output projections explicitly with ``use_muon`` on their parameter group,+in Python or, for a model attribute, through ``optim_param_groups``:++.. code-block:: python++ from nemo.core.optim import Muon++ optimizer = Muon(+ [+ {"params": hidden_matrices},+ {"params": [model.embedding.weight], "use_muon": False, "weight_decay": 0.0},+ ],+ lr=0.02,+ weight_decay=0.01,+ )++.. code-block:: yaml++ model:+ optim:+ name: muon+ lr: 0.02+ weight_decay: 0.01+ optim_param_groups:+ embedding:+ use_muon: false+ weight_decay: 0.0 Optimizer Params ~~~~~~~~~~~~~~~~nemo/core/config/__init__.py1 + / 0 −
@@ -21,6 +21,7 @@ AdamaxParams, AdamParams, AdamWParams,+ MuonParams, NovogradParams, OptimizerParams, RMSpropParams,nemo/core/config/optimizers.py33 + / 0 −
@@ -28,6 +28,7 @@ 'AdamaxParams', 'AdagradParams', 'AdamWParams',+ 'MuonParams', 'RMSpropParams', 'RpropParams', ]@@ -235,6 +236,37 @@ class AdafactorParams(OptimizerParams): warmup_init: bool = False +@dataclass+class MuonParams(OptimizerParams):+ """+ Configuration of the Muon optimizer with an AdamW fallback (``nemo.core.optim.muon.Muon``).++ Args:+ lr: learning rate of the Muon update+ momentum: momentum factor of the gradient buffer+ nesterov: apply the momentum buffer in Nesterov form+ ns_steps: number of Newton-Schulz iterations+ weight_decay: decoupled weight decay+ scale_mode: shape factor of the orthogonalized update, ``'fan_in'``, ``'fan_out'`` or ``'none'``+ adam_betas: coefficients of the AdamW moving averages+ adam_eps: term added to the AdamW denominator+ adam_lr_ratio: the AdamW learning rate is ``lr * adam_lr_ratio``+ adam_dim_threshold: matrices with a dimension of at least this size use AdamW+ use_muon: force or exclude the Muon update; None selects by shape+ """++ momentum: float = 0.95+ nesterov: bool = True+ ns_steps: int = 5+ weight_decay: float = 0+ scale_mode: str = 'fan_in'+ adam_betas: Tuple[float, float] = (0.9, 0.95)+ adam_eps: float = 1e-8+ adam_lr_ratio: float = 0.025+ adam_dim_threshold: int = 8192+ use_muon: Optional[bool] = None++ def register_optimizer_params(name: str, optimizer_params: OptimizerParams): """ Checks if the optimizer param name exists in the registry, and if it doesnt, adds it.@@ -293,4 +325,5 @@ def get_optimizer_config(name: str, **kwargs: Optional[Dict[str, Any]]) -> Optim 'rmsprop_params': RMSpropParams, 'rprop_params': RpropParams, 'adafactor_params': AdafactorParams,+ 'muon_params': MuonParams, }nemo/core/optim/__init__.py1 + / 0 −
@@ -34,6 +34,7 @@ WarmupPolicy, prepare_lr_scheduler, )+from nemo.core.optim.muon import Muon from nemo.core.optim.novograd import Novograd from nemo.core.optim.optimizer_with_main_params import MainParamsOptimizerWrapper from nemo.core.optim.optimizers import get_optimizer, parse_optimizer_args, register_optimizernemo/core/optim/muon.pyadded314 + / 0 −
@@ -0,0 +1,314 @@+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.+# SPDX-License-Identifier: Apache-2.0+#+# Licensed under the Apache License, Version 2.0 (the "License");+# you may not use this file except in compliance with the License.+# You may obtain a copy of the License at+#+# http://www.apache.org/licenses/LICENSE-2.0+#+# Unless required by applicable law or agreed to in writing, software+# distributed under the License is distributed on an "AS IS" BASIS,+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+# See the License for the specific language governing permissions and+# limitations under the License.++"""Muon optimizer (MomentUm Orthogonalized by Newton-Schulz) with an AdamW fallback.++Muon (Keller Jordan et al., 2024, https://kellerjordan.github.io/posts/muon/) updates a weight matrix+with an approximately semi-orthogonal matrix derived from the momentum-averaged gradient,+computed with a quintic Newton-Schulz iteration in bfloat16.+Parameters that are not matrices (biases, normalization scales, convolution filters)+and matrices with a dimension of at least ``adam_dim_threshold`` (large embeddings and output projections)+are updated with AdamW at a fixed fraction of the learning rate,+as the public reference trainings (modded-nanogpt, nanochat) do with their embeddings and heads.+An embedding below the threshold is a matrix like any other and takes the Muon update+unless its parameter group sets ``use_muon=False``, see :class:`Muon`.+One optimizer instance handles both kinds,+so one learning-rate schedule drives both with a fixed ratio.+"""++from typing import Any, Callable, Dict, Iterable, Optional, Sequence, Tuple, Union++import torch+from torch.optim.optimizer import Optimizer++__all__ = ['Muon', 'update_scale', 'zeropower_via_newtonschulz5']++SCALE_MODES = ('fan_in', 'fan_out', 'none')+++class Muon(Optimizer):+ """Muon for weight matrices with an AdamW fallback for the other parameters, see the module docstring.++ Which parameters take the Muon update is decided per parameter:+ with the default ``use_muon=None`` a parameter takes it+ when it is a matrix whose larger dimension is below ``adam_dim_threshold``,+ so biases, normalization scales, convolution filters and wide embeddings or output projections fall back to AdamW.+ The shape alone does not tell an embedding from a hidden matrix:+ a small-vocabulary embedding (``1026 x 512``) is below the default threshold and takes the Muon update,+ so put embeddings and tied output projections that are to stay on AdamW+ into a parameter group with ``use_muon=False`` (and usually ``weight_decay=0.0``)::++ Muon([{'params': hidden}, {'params': [embedding.weight], 'use_muon': False, 'weight_decay': 0.0}], lr=0.02)++ A parameter group can force the choice either way with ``use_muon=True`` or ``use_muon=False``;+ a forced parameter with more than two dimensions (a convolution filter)+ is orthogonalized as the matrix ``(out_channels, -1)``.++ The orthogonalized update of a ``(rows, columns)`` matrix is multiplied by a shape-dependent factor.+ ``scale_mode='fan_in'`` (the default) uses ``sqrt(max(1, rows / columns))``,+ which for the ``(out, in)`` layout of ``torch.nn.Linear``+ gives every update the root mean square ``1 / sqrt(fan_in)`` (the modded-nanogpt convention).+ ``'fan_out'`` uses ``sqrt(max(1, columns / rows))``, the root mean square ``1 / sqrt(fan_out)``,+ which is the same rule applied to the transposed ``(in, out)`` layout of other frameworks.+ ``'none'`` applies no factor.+ The same matrix in the two layouts receives the same update, transposed, when the two modes are paired,+ up to the bfloat16 rounding of the Newton-Schulz iteration.++ Weight decay is decoupled:+ it multiplies the parameter by ``1 - lr * weight_decay`` for Muon parameters+ and by ``1 - lr * adam_lr_ratio * weight_decay`` for AdamW parameters,+ so the relative decay per step is the same fraction of the respective learning rate;+ set ``weight_decay: 0.0`` on a parameter group to exclude it.++ Args:+ params: iterable of parameters or of parameter-group dicts+ lr: learning rate of the Muon update+ momentum: momentum factor of the gradient buffer+ nesterov: apply the momentum buffer in Nesterov form+ ns_steps: number of Newton-Schulz iterations+ weight_decay: decoupled weight decay+ scale_mode: ``'fan_in'``, ``'fan_out'`` or ``'none'``, see above+ adam_betas: coefficients of the AdamW moving averages+ adam_eps: term added to the AdamW denominator+ adam_lr_ratio: the AdamW learning rate is ``lr * adam_lr_ratio``+ adam_dim_threshold: matrices with a dimension of at least this size fall back to AdamW+ use_muon: force (``True``) or exclude (``False``) the Muon update; ``None`` selects by shape+ """++ def __init__(+ self,+ params: Union[Iterable[torch.Tensor], Iterable[Dict[str, Any]]],+ lr: float = 2e-2,+ momentum: float = 0.95,+ nesterov: bool = True,+ ns_steps: int = 5,+ weight_decay: float = 0.0,+ scale_mode: str = 'fan_in',+ adam_betas: Tuple[float, float] = (0.9, 0.95),+ adam_eps: float = 1e-8,+ adam_lr_ratio: float = 0.025,+ adam_dim_threshold: int = 8192,+ use_muon: Optional[bool] = None,+ ) -> None:+ if lr < 0.0:+ raise ValueError(f"Invalid learning rate: {lr}")+ if not 0.0 <= momentum < 1.0:+ raise ValueError(f"Invalid momentum: {momentum}")+ if ns_steps < 1:+ raise ValueError(f"Invalid number of Newton-Schulz steps: {ns_steps}")+ if weight_decay < 0.0:+ raise ValueError(f"Invalid weight decay: {weight_decay}")+ if not (0.0 <= adam_betas[0] < 1.0 and 0.0 <= adam_betas[1] < 1.0):+ raise ValueError(f"Invalid AdamW betas: {adam_betas}")+ if adam_eps < 0.0:+ raise ValueError(f"Invalid AdamW epsilon: {adam_eps}")+ if adam_lr_ratio < 0.0:+ raise ValueError(f"Invalid AdamW learning rate ratio: {adam_lr_ratio}")+ if adam_dim_threshold < 1:+ raise ValueError(f"Invalid AdamW dimension threshold: {adam_dim_threshold}")+ if scale_mode not in SCALE_MODES:+ raise ValueError(f"Unknown scale_mode {scale_mode!r}, expected one of {SCALE_MODES}")+ defaults = dict(+ lr=lr,+ momentum=momentum,+ nesterov=nesterov,+ ns_steps=ns_steps,+ weight_decay=weight_decay,+ scale_mode=scale_mode,+ adam_betas=tuple(adam_betas),+ adam_eps=adam_eps,+ adam_lr_ratio=adam_lr_ratio,+ adam_dim_threshold=adam_dim_threshold,+ use_muon=use_muon,+ )+ super().__init__(params, defaults)+ for group in self.param_groups:+ if group['scale_mode'] not in SCALE_MODES:+ raise ValueError(f"Unknown scale_mode {group['scale_mode']!r}, expected one of {SCALE_MODES}")+ if group['use_muon']:+ for param in group['params']:+ if param.ndim < 2:+ raise ValueError(+ f"use_muon=True needs at least two dimensions, got shape {tuple(param.shape)}"+ )++ @staticmethod+ def selects_muon(param: torch.Tensor, *, use_muon: Optional[bool], adam_dim_threshold: int) -> bool:+ """Whether ``param`` takes the Muon update under the given group options.++ Args:+ param: the parameter+ use_muon: the group's ``use_muon`` option, see the class docstring+ adam_dim_threshold: the group's ``adam_dim_threshold`` option++ Returns:+ True for the Muon update, False for the AdamW fallback+ """+ if use_muon is not None:+ return bool(use_muon)+ return param.ndim == 2 and max(param.shape) < adam_dim_threshold++ @torch.no_grad()+ def step(self, closure: Optional[Callable[[], float]] = None) -> Optional[float]:+ """Performs a single optimization step.++ Args:+ closure: re-evaluates the model and returns the loss++ Returns:+ the loss returned by ``closure``, or None+ """+ loss = None+ if closure is not None:+ with torch.enable_grad():+ loss = closure()++ for group in self.param_groups:+ for param in group['params']:+ if param.grad is None:+ continue+ state = self.state[param]+ if self.selects_muon(+ param, use_muon=group['use_muon'], adam_dim_threshold=group['adam_dim_threshold']+ ):+ _muon_update(+ param,+ param.grad,+ state,+ lr=group['lr'],+ momentum=group['momentum'],+ nesterov=group['nesterov'],+ ns_steps=group['ns_steps'],+ weight_decay=group['weight_decay'],+ scale_mode=group['scale_mode'],+ )+ else:+ _adamw_update(+ param,+ param.grad,+ state,+ lr=group['lr'] * group['adam_lr_ratio'],+ betas=group['adam_betas'],+ eps=group['adam_eps'],+ weight_decay=group['weight_decay'],+ )+ return loss+++def zeropower_via_newtonschulz5(grad: torch.Tensor, steps: int = 5) -> torch.Tensor:+ """Approximate the orthogonal factor of a matrix with a quintic Newton-Schulz iteration.++ For the singular value decomposition ``grad = U S V^T`` the result approximates ``U V^T``:+ the iteration pushes the singular values towards one, not exactly onto it,+ with the modded-nanogpt coefficients,+ which are tuned for a fast start of the iteration rather than for convergence to exactly orthogonal factors.+ The iteration runs in bfloat16 on the wide orientation of the matrix (rows <= columns),+ so transposing the input transposes the result;+ the result has the dtype of the input.++ Args:+ grad: a matrix of shape (rows, columns), any float dtype+ steps: number of Newton-Schulz iterations++ Returns:+ the approximately semi-orthogonal matrix, same shape and dtype as ``grad``+ """+ if grad.ndim != 2:+ raise ValueError(f"Expected a matrix, got a tensor of shape {tuple(grad.shape)}")+ a, b, c = 3.4445, -4.7750, 2.0315+ x = grad.bfloat16()+ transpose = grad.size(0) > grad.size(1)+ if transpose:+ x = x.T+ x = x / (x.norm() + 1e-7)+ for _ in range(steps):+ aa = x @ x.T+ bb = b * aa + c * (aa @ aa)+ x = a * x + bb @ x+ if transpose:+ x = x.T+ return x.to(grad.dtype)+++def update_scale(shape: Sequence[int], scale_mode: str) -> float:+ """Factor on the orthogonalized update of a ``(rows, columns)`` matrix.++ Args:+ shape: the two dimensions of the matrix+ scale_mode: ``'fan_in'``, ``'fan_out'`` or ``'none'``, see :class:`Muon`++ Returns:+ the factor+ """+ rows, columns = shape+ if scale_mode == 'fan_in':+ return max(1.0, rows / columns) ** 0.5+ if scale_mode == 'fan_out':+ return max(1.0, columns / rows) ** 0.5+ if scale_mode == 'none':+ return 1.0+ raise ValueError(f"Unknown scale_mode {scale_mode!r}, expected one of {SCALE_MODES}")+++def _muon_update(+ param: torch.Tensor,+ grad: torch.Tensor,+ state: Dict[str, Any],+ *,+ lr: float,+ momentum: float,+ nesterov: bool,+ ns_steps: int,+ weight_decay: float,+ scale_mode: str,+) -> None:+ if 'momentum_buffer' not in state:+ state['momentum_buffer'] = torch.zeros_like(param)+ buf = state['momentum_buffer']+ buf.mul_(momentum).add_(grad)+ update = grad.add(buf, alpha=momentum) if nesterov else buf+ matrix = update if update.ndim == 2 else update.reshape(update.size(0), -1)+ orthogonal = zeropower_via_newtonschulz5(matrix, steps=ns_steps)+ if weight_decay != 0:+ param.mul_(1 - lr * weight_decay)+ param.add_(orthogonal.reshape_as(param), alpha=-lr * update_scale(matrix.shape, scale_mode))+++def _adamw_update(+ param: torch.Tensor,+ grad: torch.Tensor,+ state: Dict[str, Any],+ *,+ lr: float,+ betas: Tuple[float, float],+ eps: float,+ weight_decay: float,+) -> None:+ if 'exp_avg' not in state:+ state['step'] = 0+ state['exp_avg'] = torch.zeros_like(param)+ state['exp_avg_sq'] = torch.zeros_like(param)+ state['step'] += 1+ step = state['step']+ beta1, beta2 = betas+ exp_avg, exp_avg_sq = state['exp_avg'], state['exp_avg_sq']+ exp_avg.mul_(beta1).add_(grad, alpha=1 - beta1)+ exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1 - beta2)+ bias_correction1 = 1 - beta1**step+ bias_correction2 = 1 - beta2**step+ denom = (exp_avg_sq.sqrt() / (bias_correction2**0.5)).add_(eps)+ if weight_decay != 0:+ param.mul_(1 - lr * weight_decay)+ param.addcdiv_(exp_avg, denom, value=-lr / bias_correction1)nemo/core/optim/optimizers.py2 + / 0 −
@@ -27,6 +27,7 @@ from nemo.core.config.optimizers import OptimizerParams, get_optimizer_config, register_optimizer_params from nemo.core.optim.adafactor import Adafactor from nemo.core.optim.adan import Adan+from nemo.core.optim.muon import Muon from nemo.core.optim.novograd import Novograd from nemo.utils.model_utils import maybe_update_config_version@@ -43,6 +44,7 @@ 'novograd': Novograd, 'adafactor': Adafactor, 'adan': Adan,+ 'muon': Muon, } try:tests/core/test_muon.pyadded281 + / 0 −
@@ -0,0 +1,281 @@+# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.+# SPDX-License-Identifier: Apache-2.0+#+# Licensed under the Apache License, Version 2.0 (the "License");+# you may not use this file except in compliance with the License.+# You may obtain a copy of the License at+#+# http://www.apache.org/licenses/LICENSE-2.0+#+# Unless required by applicable law or agreed to in writing, software+# distributed under the License is distributed on an "AS IS" BASIS,+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+# See the License for the specific language governing permissions and+# limitations under the License.++import copy++import pytest+import torch++from nemo.core import config, optim+from nemo.core.optim.muon import Muon, update_scale, zeropower_via_newtonschulz5+from nemo.core.optim.optimizers import AVAILABLE_OPTIMIZERS++LR = 0.02+WEIGHT_DECAY = 0.01+++def _parameters(seed: int = 0) -> torch.nn.ParameterDict:+ """Parameters of every kind the optimizer distinguishes, in a fixed order."""+ generator = torch.Generator().manual_seed(seed)++ def param(*shape):+ return torch.nn.Parameter(torch.randn(*shape, generator=generator))++ # assigned one by one: ParameterDict sorts the keys of a plain dict+ parameters = torch.nn.ParameterDict()+ parameters['hidden'] = param(48, 32) # Muon+ parameters['tall'] = param(64, 16) # Muon, fan_in scale 2+ parameters['bias'] = param(48) # AdamW+ parameters['conv'] = param(8, 4, 3, 3) # AdamW by shape+ parameters['embedding'] = param(8200, 16) # AdamW, wide+ return parameters+++def _set_gradients(parameters: torch.nn.ParameterDict, seed: int) -> None:+ generator = torch.Generator().manual_seed(seed)+ for param in parameters.values():+ param.grad = torch.randn(param.shape, generator=generator)+++def _run(optimizer: Muon, parameters: torch.nn.ParameterDict, steps: int, first_seed: int = 100) -> None:+ for step in range(steps):+ _set_gradients(parameters, first_seed + step)+ optimizer.step()+++class TestMuon:+ @pytest.mark.unit+ def test_registry(self):+ assert AVAILABLE_OPTIMIZERS['muon'] is Muon+ parameters = _parameters()+ optimizer = optim.get_optimizer('muon')(parameters.parameters(), lr=LR)+ assert isinstance(optimizer, Muon)++ parsed = optim.parse_optimizer_args('muon', {'name': 'auto', 'momentum': 0.9, 'weight_decay': WEIGHT_DECAY})+ assert parsed['momentum'] == 0.9+ assert parsed['weight_decay'] == WEIGHT_DECAY+ assert parsed['adam_lr_ratio'] == config.optimizers.MuonParams().adam_lr_ratio+ assert parsed['scale_mode'] == 'fan_in'+ assert parsed['use_muon'] is None++ @pytest.mark.unit+ def test_parameter_selection(self):+ parameters = _parameters()+ selected = {+ name: Muon.selects_muon(param, use_muon=None, adam_dim_threshold=8192)+ for name, param in parameters.items()+ }+ assert selected == {'hidden': True, 'tall': True, 'bias': False, 'conv': False, 'embedding': False}+ assert not Muon.selects_muon(parameters['hidden'], use_muon=None, adam_dim_threshold=48)+ assert not Muon.selects_muon(parameters['hidden'], use_muon=False, adam_dim_threshold=8192)+ assert Muon.selects_muon(parameters['conv'], use_muon=True, adam_dim_threshold=8192)+ assert Muon.selects_muon(parameters['embedding'], use_muon=True, adam_dim_threshold=8192)++ with pytest.raises(ValueError, match="at least two dimensions"):+ Muon([{'params': [parameters['bias']], 'use_muon': True}], lr=LR)+ with pytest.raises(ValueError, match="scale_mode"):+ Muon(parameters.parameters(), lr=LR, scale_mode='rows')++ @pytest.mark.unit+ def test_newton_schulz_orthogonalizes(self):+ generator = torch.Generator().manual_seed(1)+ grad = torch.randn(16, 40, generator=generator)+ orthogonal = zeropower_via_newtonschulz5(grad)+ assert orthogonal.shape == grad.shape+ assert orthogonal.dtype == grad.dtype+ singular_values = torch.linalg.svdvals(orthogonal.double())+ assert singular_values.min() > 0.5+ assert singular_values.max() < 1.5+ # the input is far from orthogonal+ normalized = torch.linalg.svdvals(grad.double() / grad.double().norm())+ assert normalized.min() < 0.5++ # the iteration runs on the wide orientation, so a transposed input gives exactly the transposed result+ assert torch.equal(zeropower_via_newtonschulz5(grad.T), orthogonal.T)+ with pytest.raises(ValueError, match="matrix"):+ zeropower_via_newtonschulz5(torch.zeros(3, 4, 5))++ @pytest.mark.unit+ def test_muon_step_matches_definition(self):+ parameters = _parameters()+ optimizer = Muon(parameters.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)+ initial = {name: param.detach().clone() for name, param in parameters.items()}+ _run(optimizer, parameters, steps=2)++ expected = initial['hidden'].clone()+ buf = torch.zeros_like(expected)+ for step in range(2):+ generator = torch.Generator().manual_seed(100 + step)+ grad = torch.randn(48, 32, generator=generator)+ buf = buf * 0.95 + grad+ nesterov = grad + 0.95 * buf+ orthogonal = zeropower_via_newtonschulz5(nesterov, steps=5)+ expected = expected * (1 - LR * WEIGHT_DECAY) - LR * max(1.0, 48 / 32) ** 0.5 * orthogonal+ assert torch.allclose(parameters['hidden'], expected, atol=1e-7, rtol=1e-6)+ assert torch.equal(optimizer.state[parameters['hidden']]['momentum_buffer'], buf)++ @pytest.mark.unit+ def test_adamw_fallback_matches_torch_adamw(self):+ parameters = _parameters()+ reference = copy.deepcopy(parameters)+ betas, eps = (0.9, 0.95), 1e-8+ optimizer = Muon(+ parameters.parameters(),+ lr=LR,+ weight_decay=WEIGHT_DECAY,+ adam_betas=betas,+ adam_eps=eps,+ adam_lr_ratio=1.0,+ )+ adamw_names = ['bias', 'conv', 'embedding']+ adamw = torch.optim.AdamW(+ [reference[name] for name in adamw_names], lr=LR, betas=betas, eps=eps, weight_decay=WEIGHT_DECAY+ )+ for step in range(3):+ _set_gradients(parameters, 200 + step)+ _set_gradients(reference, 200 + step)+ optimizer.step()+ adamw.step()+ for name in adamw_names:+ torch.testing.assert_close(parameters[name], reference[name], atol=1e-7, rtol=1e-6)+ assert optimizer.state[parameters[name]]['step'] == 3++ @pytest.mark.unit+ def test_weight_decay_uses_the_group_learning_rate(self):+ parameters = _parameters()+ initial = {name: param.detach().clone() for name, param in parameters.items()}+ optimizer = Muon(+ [+ {'params': [parameters['hidden'], parameters['bias']]},+ {'params': [parameters['tall'], parameters['conv']], 'weight_decay': 0.0},+ ],+ lr=LR,+ weight_decay=WEIGHT_DECAY,+ adam_lr_ratio=0.1,+ )+ for param in parameters.values():+ param.grad = torch.zeros_like(param)+ optimizer.step()+ # with zero gradients both updates vanish and only the decay acts+ torch.testing.assert_close(parameters['hidden'], initial['hidden'] * (1 - LR * WEIGHT_DECAY))+ torch.testing.assert_close(parameters['bias'], initial['bias'] * (1 - LR * 0.1 * WEIGHT_DECAY))+ assert torch.equal(parameters['tall'], initial['tall'])+ assert torch.equal(parameters['conv'], initial['conv'])++ @pytest.mark.unit+ def test_scale_modes(self):+ assert update_scale((64, 16), 'fan_in') == 2.0+ assert update_scale((64, 16), 'fan_out') == 1.0+ assert update_scale((16, 64), 'fan_in') == 1.0+ assert update_scale((16, 64), 'fan_out') == 2.0+ assert update_scale((64, 16), 'none') == 1.0++ updates = {}+ for mode in ('fan_in', 'fan_out', 'none'):+ parameters = _parameters()+ initial = parameters['tall'].detach().clone()+ optimizer = Muon([parameters['tall']], lr=LR, scale_mode=mode)+ _run(optimizer, parameters, steps=1)+ updates[mode] = initial - parameters['tall']+ torch.testing.assert_close(updates['fan_in'], 2 * updates['fan_out'])+ assert torch.equal(updates['fan_out'], updates['none'])++ # the same matrix stored transposed receives the transposed update when the modes are paired+ parameters = _parameters()+ transposed = torch.nn.Parameter(parameters['tall'].detach().T.contiguous())+ optimizer = Muon([parameters['tall']], lr=LR, scale_mode='fan_in')+ optimizer_t = Muon([transposed], lr=LR, scale_mode='fan_out')+ for step in range(3):+ _set_gradients(parameters, 300 + step)+ transposed.grad = parameters['tall'].grad.T.contiguous()+ optimizer.step()+ optimizer_t.step()+ assert torch.equal(transposed, parameters['tall'].T)++ @pytest.mark.unit+ def test_forced_muon_flattens_filters(self):+ parameters = _parameters()+ flat = torch.nn.Parameter(parameters['conv'].detach().reshape(8, -1).clone())+ optimizer = Muon([{'params': [parameters['conv']], 'use_muon': True}], lr=LR, weight_decay=WEIGHT_DECAY)+ optimizer_flat = Muon([flat], lr=LR, weight_decay=WEIGHT_DECAY)+ for step in range(2):+ _set_gradients(parameters, 400 + step)+ flat.grad = parameters['conv'].grad.reshape(8, -1).clone()+ optimizer.step()+ optimizer_flat.step()+ assert torch.equal(parameters['conv'].reshape(8, -1), flat)+ assert 'momentum_buffer' in optimizer.state[parameters['conv']]++ @pytest.mark.unit+ def test_state_dict_round_trip(self):+ parameters = _parameters()+ optimizer = Muon(parameters.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)+ _run(optimizer, parameters, steps=2)+ saved_parameters = copy.deepcopy(parameters)+ saved_state = copy.deepcopy(optimizer.state_dict())+ _run(optimizer, parameters, steps=1, first_seed=102)++ restored = Muon(saved_parameters.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)+ restored.load_state_dict(saved_state)+ _run(restored, saved_parameters, steps=1, first_seed=102)+ for name in parameters:+ assert torch.equal(saved_parameters[name], parameters[name]), name+ assert restored.state[saved_parameters['bias']]['step'] == 3++ @pytest.mark.unit+ def test_scheduler_drives_both_learning_rates(self):+ parameters = _parameters()+ optimizer = Muon(parameters.parameters(), lr=1.0, adam_lr_ratio=0.1)+ scheduler = optim.lr_scheduler.WarmupPolicy(optimizer, warmup_steps=4, max_steps=10)+ initial = {name: param.detach().clone() for name, param in parameters.items()}+ for param in parameters.values():+ param.grad = torch.zeros_like(param)+ optimizer.param_groups[0]['weight_decay'] = 0.5+ optimizer.step()+ scheduler.step()+ lr = scheduler.get_last_lr()[0]+ assert lr == pytest.approx(2 / 5)+ # the first step ran at the warmup learning rate 1/5+ torch.testing.assert_close(parameters['hidden'], initial['hidden'] * (1 - 0.2 * 0.5))+ torch.testing.assert_close(parameters['bias'], initial['bias'] * (1 - 0.02 * 0.5))++ @pytest.mark.unit+ def test_production_shape_update_rms(self):+ # the first feed-forward matrix of a width-1024 Conformer, in torch's (out, in) layout+ matrix = torch.nn.Parameter(torch.randn(4096, 1024, generator=torch.Generator().manual_seed(5)))+ assert Muon.selects_muon(matrix, use_muon=None, adam_dim_threshold=8192)+ initial = matrix.detach().clone()+ optimizer = Muon([matrix], lr=1.0)+ matrix.grad = torch.randn(matrix.shape, generator=torch.Generator().manual_seed(6))+ optimizer.step()+ update = initial - matrix.detach()+ # fan_in: the update of an (out, in) matrix has root mean square 1 / sqrt(fan_in),+ # up to the Newton-Schulz approximation of the singular values+ assert update.pow(2).mean().sqrt().item() == pytest.approx(1 / 1024**0.5, rel=0.15)+ assert update.shape == (4096, 1024)++ @pytest.mark.unit+ def test_small_vocabulary_embedding_needs_an_explicit_exclusion(self):+ # a 1026 x 512 embedding is below the default threshold, so the shape heuristic selects Muon for it+ embedding = torch.nn.Embedding(1026, 512)+ assert Muon.selects_muon(embedding.weight, use_muon=None, adam_dim_threshold=8192)+ optimizer = Muon([{'params': [embedding.weight], 'use_muon': False, 'weight_decay': 0.0}], lr=LR)+ embedding.weight.grad = torch.ones_like(embedding.weight)+ optimizer.step()+ state = optimizer.state[embedding.weight]+ assert 'exp_avg' in state+ assert 'momentum_buffer' not in state+ # a wide embedding falls back by shape alone+ assert not Muon.selects_muon(torch.empty(8192, 16), use_muon=None, adam_dim_threshold=8192)