Skip to content

Commit 5e4852a

Browse files
authored
Merge branch 'main' into skyw/stacked-soap
2 parents fdb8c27 + 46eda5a commit 5e4852a

22 files changed

Lines changed: 889 additions & 201 deletions

docs/apidocs/orthogonalized-optimizers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ emerging_optimizers.orthogonalized_optimizers
4646
.. autoclass:: PolarGrad
4747
:members:
4848
49+
.. autofunction:: right_polargrad_orth_fn
50+
4951
5052
:hidden:`AdaptiveMuon`
5153
~~~~~~~~~~~~~~~~~~~~~~~
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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 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+
@torch.compile
36+
def _weight_norm_decompose(
37+
weight: torch.Tensor,
38+
grad: torch.Tensor,
39+
g: torch.Tensor,
40+
v_norm: torch.Tensor,
41+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
42+
r"""Reconstructs the direction and splits the gradient under the weight-norm reparameterization.
43+
44+
Args:
45+
weight: The current 2D weight ``W``.
46+
grad: The gradient ``grad_W`` with respect to ``W``.
47+
g: Per-row magnitude, shape ``[rows, 1]``.
48+
v_norm: Cached row norms ``||v||_row`` of the direction, shape ``[rows, 1]``.
49+
50+
Returns:
51+
``(v, grad_g, grad_v)``: the reconstructed direction and the magnitude and direction gradients.
52+
"""
53+
u = weight / g
54+
v = u * v_norm
55+
grad_g = (grad * u).sum(dim=1, keepdim=True)
56+
grad_v = (g / v_norm) * (grad - u * grad_g)
57+
return v, grad_g, grad_v
58+
59+
60+
class Muown(Muon):
61+
"""Muown: Muon with internal weight normalization (row-norm control).
62+
63+
Muown (Lion et al., *Muown: Row-Norm Control for Muon Optimization*, arXiv:2605.10797) is a drop-in
64+
replacement for :class:`~emerging_optimizers.orthogonalized_optimizers.muon.Muon` that splits each 2D
65+
weight into a per-row magnitude and a direction, then optimizes them under their natural geometries:
66+
67+
68+
Args:
69+
params: Iterable of parameters to optimize or dicts defining parameter groups.
70+
lr: Learning rate shared by the direction and magnitude updates.
71+
momentum: EMA momentum for the direction (Muon) update.
72+
betas: Adam ``(beta1, beta2)`` for the magnitude update.
73+
adam_eps: Adam epsilon for the magnitude update.
74+
weight_decay: Decoupled weight decay coefficient, applied to the magnitude ``g``.
75+
fp32_matmul_prec: Precision for the orthogonalization GEMM operations.
76+
coefficient_type: Newton-Schulz coefficient set (see :class:`Muon`).
77+
num_ns_steps: Number of Newton-Schulz iteration steps.
78+
scale_mode: Update scale mode (see :func:`~emerging_optimizers.orthogonalized_optimizers.muon.get_muon_scale_factor`).
79+
extra_scale_factor: Extra scale on the direction update; ``0.2`` matches Adam's update RMS norm.
80+
use_syrk: Whether to use the Triton SYRK kernel for Newton-Schulz.
81+
"""
82+
83+
def __init__(
84+
self,
85+
params: ParamsT,
86+
lr: float = 3e-4,
87+
momentum: float = 0.95,
88+
weight_decay: float = 0.0,
89+
*,
90+
betas: tuple[float, float] = (0.9, 0.95),
91+
adam_eps: float = 1e-8,
92+
fp32_matmul_prec: FP32MatmulPrecT = "medium",
93+
coefficient_type: NSCoeffT = "quintic",
94+
num_ns_steps: int = 5,
95+
scale_mode: MuonScaleT = "spectral",
96+
extra_scale_factor: float = 1.0,
97+
use_syrk: bool = False,
98+
) -> None:
99+
self.betas = betas
100+
self.adam_eps = adam_eps
101+
102+
super().__init__(
103+
params,
104+
lr,
105+
momentum,
106+
weight_decay,
107+
nesterov=False,
108+
weight_decay_method="decoupled",
109+
fp32_matmul_prec=fp32_matmul_prec,
110+
coefficient_type=coefficient_type,
111+
num_ns_steps=num_ns_steps,
112+
scale_mode=scale_mode,
113+
extra_scale_factor=extra_scale_factor,
114+
use_syrk=use_syrk,
115+
)
116+
117+
@torch.no_grad() # type: ignore[misc]
118+
@override
119+
def _init_group(self, group: dict, skip_non_grad_params: bool = True) -> None:
120+
for p in group["params"]:
121+
if skip_non_grad_params and p.grad is None:
122+
continue # pragma: no cover
123+
if p.dim() != 2:
124+
raise TypeError("Muown is only supported for 2D parameters")
125+
state = self.state[p]
126+
if len(state) == 0:
127+
# Seed g, v from the current weight so Muown starts from the same point as Muon. Floor the
128+
# row norm so an all-zero weight row does not give g=0, which would make u = weight / g a
129+
# 0/0 NaN on the first step.
130+
row_norm = p.norm(dim=1, keepdim=True).to(torch.float32).clamp_min(1e-12)
131+
state["step"] = 0
132+
state["g"] = row_norm.clone()
133+
state["v_norm"] = row_norm.clone()
134+
state["momentum_buffer"] = torch.zeros_like(p, dtype=torch.float32)
135+
state["m_g"] = torch.zeros_like(row_norm)
136+
state["v_g"] = torch.zeros_like(row_norm)
137+
138+
if TYPE_CHECKING:
139+
140+
@overload
141+
def step(self, closure: None = ...) -> None: ...
142+
143+
@overload
144+
def step(self, closure: Callable[[], float]) -> float: ...
145+
146+
@torch.no_grad() # type: ignore[misc]
147+
@override
148+
def step(self, closure: Callable[[], float] | None = None) -> float | None:
149+
"""Performs a single optimization step.
150+
151+
Args:
152+
closure: Unsupported; must be ``None``.
153+
"""
154+
if closure is not None:
155+
raise ValueError("closure is not supported")
156+
157+
for group in self.param_groups:
158+
self._init_group(group)
159+
160+
lr = group["lr"]
161+
momentum = group["momentum"]
162+
weight_decay = group["weight_decay"]
163+
for p in group["params"]:
164+
if p.grad is None:
165+
continue # pragma: no cover
166+
167+
state = self.state[p]
168+
curr_iter_1_based = state["step"] + 1
169+
g = state["g"]
170+
v_norm = state["v_norm"]
171+
172+
v, grad_g, grad_v = _weight_norm_decompose(p.to(torch.float32), p.grad.to(torch.float32), g, v_norm)
173+
174+
state["momentum_buffer"].lerp_(grad_v, 1 - momentum)
175+
with utils.fp32_matmul_precision(self.fp32_matmul_prec):
176+
direction_update = self.scaled_orthogonalize_fn(state["momentum_buffer"])
177+
v_new = v.add(direction_update, alpha=-lr)
178+
179+
magnitude_update = update_functions.calculate_adam_update(
180+
grad_g,
181+
state["m_g"],
182+
state["v_g"],
183+
betas=self.betas,
184+
eps=self.adam_eps,
185+
correct_bias=True,
186+
nesterov=False,
187+
step=curr_iter_1_based, # 1-based iteration index is used for bias correction
188+
)
189+
g.add_(magnitude_update, alpha=-lr)
190+
191+
# Decoupled weight decay on the magnitude (the spectral-norm driver).
192+
self._apply_weight_decay_inplace(g, grad_g, lr, weight_decay)
193+
194+
v_norm_new = v_new.norm(dim=1, keepdim=True)
195+
p.copy_(g * (v_new / v_norm_new))
196+
state["v_norm"] = v_norm_new
197+
198+
state["step"] += 1
199+
200+
return None

emerging_optimizers/orthogonalized_optimizers/polargrad.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323
from emerging_optimizers.orthogonalized_optimizers.muon_utils import NSCoeffT
2424
from emerging_optimizers.orthogonalized_optimizers.orthogonalized_optimizer import OrthogonalizedOptimizer, _args_doc
2525
from emerging_optimizers.utils import FP32MatmulPrecT
26+
from emerging_optimizers.utils.eig import eigh_with_fallback
2627

2728

28-
__all__ = ["PolarGrad"]
29+
__all__ = ["PolarGrad", "right_polargrad_orth_fn"]
2930

3031

3132
@registry.register_optimizer("polargrad")
@@ -103,3 +104,75 @@ def scaled_orthogonalize_fn(grad: torch.Tensor) -> torch.Tensor:
103104

104105

105106
PolarGrad.__doc__ = PolarGrad.__doc__.format(_args_doc=_args_doc) # type: ignore[union-attr]
107+
108+
109+
def right_polargrad_orth_fn(
110+
grad: torch.Tensor,
111+
*,
112+
alpha: float = 1.0,
113+
center_rows: bool = False,
114+
eps: float = 1e-15,
115+
extra_scale_factor: float = 1.0,
116+
) -> torch.Tensor:
117+
r"""Right-spectral (one-sided polar) orthogonalization for tall matrices.
118+
119+
Orthogonalizes only the right factor of a tall matrix ``G`` (e.g. an embedding or LM-head weight,
120+
``vocab x hidden``):
121+
122+
.. math::
123+
u = G \, (G^\top G)^{-1/2}, \qquad \text{update} = \lVert G \rVert_*^{\,\alpha} \, u
124+
125+
.. code-block:: python
126+
:caption: Define a ``RightPolarGrad`` by partially applying this orthogonalization
127+
128+
class RightPolarGrad(OrthogonalizedOptimizer):
129+
def __init__(
130+
self,
131+
params,
132+
lr: float = 3e-4,
133+
momentum: float = 0.95,
134+
weight_decay: float = 0.01,
135+
*,
136+
...
137+
alpha: float = 1.0,
138+
center_rows: bool = False,
139+
eps: float = 1e-15,
140+
extra_scale_factor: float = 1.0,
141+
) -> None:
142+
scaled_orthogonalize_fn = functools.partial(
143+
right_polargrad_orth_fn,
144+
alpha=alpha,
145+
center_rows=center_rows,
146+
eps=eps,
147+
extra_scale_factor=extra_scale_factor,
148+
)
149+
super().__init__(
150+
...
151+
)
152+
153+
Args:
154+
grad: The (momentum) tensor to orthogonalize.
155+
alpha: Exponent applied to the nuclear-norm scale factor.
156+
center_rows: If True, subtract the per-column mean (the average over the row / vocabulary axis,
157+
``dim=0``) before and after the update, so each column is zero-mean.
158+
eps: Floor on the right-Gram eigenvalues for the inverse sqrt and nuclear-norm computation.
159+
extra_scale_factor: Extra multiplier on the update.
160+
161+
Returns:
162+
The scaled right-polar update, same shape and dtype as ``grad``.
163+
"""
164+
m = grad.to(torch.float32)
165+
if center_rows:
166+
m = m - m.mean(dim=0, keepdim=True)
167+
168+
eigvals, eigvecs = eigh_with_fallback(m.transpose(-1, -2) @ m)
169+
eigvals.clamp_min_(eps)
170+
right_gram_inv_sqrt = (eigvecs * eigvals.rsqrt().unsqueeze(-2)) @ eigvecs.transpose(-1, -2)
171+
172+
u = m @ right_gram_inv_sqrt
173+
nuclear_norm = eigvals.sqrt().sum()
174+
update = u * nuclear_norm.pow(alpha) * extra_scale_factor
175+
176+
if center_rows:
177+
update = update - update.mean(dim=0, keepdim=True)
178+
return update.to(grad.dtype)

0 commit comments

Comments
 (0)