Repository navigation
Expand file tree
/
Copy pathoptimizers.py
More file actions
539 lines (420 loc) · 19.5 KB
/
Copy pathoptimizers.py
File metadata and controls
539 lines (420 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
"""Centralized (single-node) implementations of the eight methods in the paper
"SignMuon, MuonSign, and the Role of Error Feedback".
Naming follows the paper exactly:
=================== =========================================================
Method Matrix-parameter update ``d_t`` (step is ``X -= eta*d_t``)
=================== =========================================================
``Muon`` ``polar(M)`` (full precision)
``SignSGD`` ``sign(M)`` (reference)
``SignMuon`` ``sign(polar(M))`` sign AFTER the LMO
``MuonUSign`` ``polar(sign(M))`` sign BEFORE the LMO
``MuonSign`` ``sign(polar(sign(M)))`` sign on BOTH sides
``EF21SignMuon`` EF21 on ``polar(M)`` (diverges, Thm 4)
``EF21MuonUSign`` ``polar(g_est)``, ``g_est ~ M`` uplink EF21
``EF21MuonSign`` as above + downlink EF21-P on X bidirectional EF21
=================== =========================================================
Here ``M`` is the effective momentum direction ``M_tilde`` and
``polar(Y) = -A(Y) = U V^T`` is the Muon LMO direction.
Momentum uses the EMA form of the paper's algorithm boxes,
``M_t = mu*M_{t-1} + (1-mu)*G_t``, with the Nesterov look-ahead
``M_tilde = (1-mu)*G_t + mu*M_t``. This is *exactly* equivalent to the
heavy-ball form ``mu*M_{t-1} + G_t`` used in the paper's main text -- the two
buffers differ by the constant factor ``(1-mu)`` and every method above is
positively homogeneous in ``M`` (``sign``, ``polar`` and the EF21 recursion all
commute with multiplication by a positive scalar), so the iterate trajectory is
identical. The EMA form is used here only to match the pseudocode boxes.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Optional
import torch
from torch import Tensor
from torch.optim import Optimizer
from common.lr_scaling import FAMILY_LMO, FAMILY_SIGN
__all__ = [
"zeropower_via_newtonschulz5",
"muon_lmo",
"sign_pm1",
"Muon",
"SignSGD",
"SignMuon",
"MuonUSign",
"MuonSign",
"EF21SignMuon",
"EF21MuonUSign",
"EF21MuonSign",
"OPTIMIZERS",
"FAMILY_LMO",
"FAMILY_SIGN",
]
# 5th-order Newton-Schulz coefficients of Algorithm 1 (Y <- a*Y - b*A*Y + c*A^2*Y).
NS_COEFFS = (3.4445, 4.7750, 2.0315)
# --------------------------------------------------------------------------
# Building blocks
# --------------------------------------------------------------------------
def zeropower_via_newtonschulz5(
G: Tensor,
steps: int = 5,
eps: float = 1e-7,
dtype: Optional[torch.dtype] = torch.bfloat16,
) -> Tensor:
"""Approximate the orthogonal polar factor ``U V^T`` of ``G`` (Algorithm 1).
``dtype`` is the working precision of the iteration. The default
``bfloat16`` matches the reference Muon implementation and is what the
reported experiments used; pass ``dtype=None`` to iterate in the input dtype
(e.g. float32), which matters whenever the *sign pattern* of the result is
the quantity of interest -- bfloat16 carries ~3 decimal digits, so entries of
``polar(G)`` close to zero can flip sign.
Supports tensors with ``ndim >= 2``; batched leading dimensions are allowed.
"""
assert G.ndim >= 2, "Muon orthogonalization expects at least 2D tensors."
a, b, c = NS_COEFFS
# ``.to()`` is a no-op returning ``G`` itself when the dtype already matches,
# so clone before the in-place normalization below or we would scribble on
# the caller's gradient buffer.
X = G.to(dtype=dtype) if dtype is not None else G
if X is G:
X = X.clone()
transposed = False
if X.size(-2) > X.size(-1):
X = X.mT
transposed = True
X = X / (X.norm(dim=(-2, -1), keepdim=True) + eps)
for _ in range(steps):
A = X @ X.mT
B = -b * A + c * (A @ A)
X = a * X + B @ X
if transposed:
X = X.mT
return X.to(device=G.device, dtype=G.dtype)
def muon_lmo(
grad: Tensor,
ns_steps: int = 5,
dtype: Optional[torch.dtype] = torch.bfloat16,
scale_aspect: bool = True,
) -> Tensor:
"""Muon LMO direction ``polar(grad) = -A(grad)`` for a parameter tensor.
* 4D convolution filters are flattened to ``[out_channels, -1]``, orthogonalized,
and reshaped back.
* 2D matrices are used directly.
* ``ndim < 2`` tensors are returned unchanged (no matrix structure to exploit).
``scale_aspect`` applies Muon's aspect-ratio factor ``sqrt(max(1, m/n))``.
It is a practical heuristic, not part of the LMO itself: it rescales the step
and is therefore invisible to any method that signs the LMO *output*
(``SignMuon``, ``MuonSign``), but it does change the step of ``Muon``,
``MuonUSign`` and ``EF21MuonUSign``. Set ``False`` for the exact
``U V^T`` of the theory.
"""
if grad.ndim < 2:
return grad
orig_shape = grad.shape if grad.ndim > 2 else None
G = grad.reshape(grad.shape[0], -1) if orig_shape is not None else grad
orth = zeropower_via_newtonschulz5(G, steps=ns_steps, dtype=dtype)
if scale_aspect:
orth = orth * max(1.0, orth.size(-2) / orth.size(-1)) ** 0.5
return orth.reshape(orig_shape) if orig_shape is not None else orth
def sign_pm1(Y: Tensor) -> Tensor:
"""Elementwise sign with exact zeros mapped to independent random ``+-1``.
The paper's convention: every transmitted sign message is exactly one bit
per parameter. Zeros are not exotic -- a channel inactive over a whole local
batch zeroes a row of the gradient and hence of the momentum, and the exact
zero survives both the LMO's rank truncation and the (odd) Newton-Schulz
polynomial -- so ``sign(0) = 0`` would make the alphabet ternary. Randomizing
costs nothing in expected descent (a zeroed entry carries no directional
information) and keeps the scaled-sign contraction lemma, whose identity
``||C(Y) - Y||_F^2 = ||Y||_F^2 - ||Y||_1^2/d`` holds for any tie-breaking.
Draws from the global torch RNG *only when zeros are present*, so dense
inputs leave the RNG stream untouched and runs stay reproducible under
``seed_everything``.
"""
s = torch.sign(Y)
zero = s == 0
if bool(zero.any()):
r = torch.randint(0, 2, s.shape, device=s.device).to(s.dtype).mul_(2).sub_(1)
s = torch.where(zero, r, s)
return s
# --------------------------------------------------------------------------
# Shared optimizer skeleton
# --------------------------------------------------------------------------
class _BaseMethod(Optimizer):
"""Momentum / weight-decay bookkeeping shared by every method.
Subclasses implement :meth:`_direction`, which maps the effective momentum
direction ``m_tilde`` to the step direction ``d_t`` (the update is always
``p <- p - lr * lambda_mult * d_t``), and may override :meth:`_post_step`.
Weight decay
------------
``decoupled_weight_decay=True`` shrinks the parameter multiplicatively
(``p *= 1 - lr*wd``) and leaves the gradient -- and hence the LMO geometry --
untouched. Both drivers pass ``True``, because it is the only well-posed
choice here: every ``_direction`` in this module is positively homogeneous of
degree **zero** (``sign(cM) = sign(M)``, ``polar(cM) = polar(M)``), so folding
``wd * p`` into the gradient cannot change the step length by so much as a
percent -- it only rotates the direction, by an amount governed by the ratio
``wd * ||p|| / ||g||``, which drifts over training and differs between methods.
``False`` reproduces that coupled convention for the ablation in the appendix.
"""
method_name = "base"
#: Step family, see ``common.lr_scaling``. Together with the parameter shape
#: this fixes the per-layer learning-rate multiplier.
family = FAMILY_LMO
#: When true, :meth:`step` stashes a copy of each parameter's step direction
#: ``d_t`` in ``state["last_direction"]``. Off by default -- it costs one
#: clone per parameter per step, and the tensor must be copied because the
#: EF21 methods return a state buffer that they mutate in place next step.
#: ``synthetic.benchmark --mode alignment`` turns it on to measure
#: ``<grad F, d_t>``, the quantity the descent lemma rests on and the one the
#: divergence theorems drive negative.
capture_direction = False
def __init__(
self,
params,
lr: float = 1e-3,
momentum: float = 0.0,
nesterov: bool = False,
weight_decay: float = 0.0,
decoupled_weight_decay: bool = False,
lambda_mult: float = 1.0,
ns_steps: int = 5,
lmo_dtype: Optional[torch.dtype] = torch.bfloat16,
scale_aspect: bool = True,
):
if lr < 0.0:
raise ValueError(f"Invalid learning rate: {lr}")
if not 0.0 <= momentum < 1.0:
raise ValueError(f"momentum must lie in [0, 1), got {momentum}")
if weight_decay < 0.0:
raise ValueError(f"Invalid weight_decay value: {weight_decay}")
if nesterov and momentum <= 0.0:
raise ValueError("Nesterov momentum requires a positive momentum")
defaults = dict(
lr=lr,
momentum=momentum,
nesterov=nesterov,
weight_decay=weight_decay,
decoupled_weight_decay=decoupled_weight_decay,
lambda_mult=lambda_mult,
ns_steps=ns_steps,
lmo_dtype=lmo_dtype,
scale_aspect=scale_aspect,
)
super().__init__(params, defaults)
# -- hooks ------------------------------------------------------------
def _direction(self, m_tilde: Tensor, state: dict, group: dict) -> Tensor:
raise NotImplementedError
def _post_step(self, p, state: dict, group: dict) -> None:
"""Called after ``p`` has been updated (used by the downlink EF loop)."""
def _lmo(self, Y: Tensor, group: dict) -> Tensor:
return muon_lmo(
Y,
ns_steps=group["ns_steps"],
dtype=group["lmo_dtype"],
scale_aspect=group["scale_aspect"],
)
# -- driver -----------------------------------------------------------
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
for group in self.param_groups:
lr = group["lr"]
mu = group["momentum"]
nesterov = group["nesterov"]
wd = group["weight_decay"]
decoupled = group["decoupled_weight_decay"]
lambda_mult = group["lambda_mult"]
for p in group["params"]:
if p.grad is None:
continue
if p.grad.is_sparse:
raise RuntimeError(f"{type(self).__name__} does not support sparse gradients")
state = self.state[p]
g = p.grad
# The bidirectional method differentiates the model the gradient
# was taken at (W = p.data) from the exact model X it advances.
ref = self._weight_decay_target(p, state)
if wd != 0 and not decoupled:
g = g.add(ref, alpha=wd)
# 1) momentum (EMA form of the paper's algorithm boxes)
if "momentum_buffer" not in state:
state["momentum_buffer"] = torch.zeros_like(g)
buf = state["momentum_buffer"]
buf.mul_(mu).add_(g, alpha=1.0 - mu)
m_tilde = g.mul(1.0 - mu).add_(buf, alpha=mu) if nesterov else buf
# 2) method-specific step direction
d_t = self._direction(m_tilde, state, group)
if self.capture_direction:
state["last_direction"] = d_t.clone()
# 3) decoupled weight decay + parameter step
target = self._step_target(p, state)
if wd != 0 and decoupled:
target.mul_(1.0 - lr * wd)
target.add_(d_t, alpha=-lr * lambda_mult)
self._post_step(p, state, group)
return loss
# -- overridable notion of "the model" --------------------------------
def _weight_decay_target(self, p, state: dict) -> Tensor:
return p.data
def _step_target(self, p, state: dict) -> Tensor:
return p.data
class _EF21Mixin:
"""Scaled-sign EF21 estimator kept in the per-parameter state.
``g_{t} = g_{t-1} + alpha_t * sign(Delta_t)`` with
``Delta_t = target_t - g_{t-1}`` and ``alpha_t = mean|Delta_t|``: exactly the
compressor of Equation (16), applied to whichever ``target`` the method
tracks (the momentum for the MuonUSign family, the LMO output for
EF21-SignMuon).
"""
@staticmethod
def _ef21_update(target: Tensor, state: dict, key: str) -> Tensor:
if key not in state:
state[key] = torch.zeros_like(target)
est = state[key]
delta = target - est
est.add_(delta.abs().mean() * sign_pm1(delta))
return est
# --------------------------------------------------------------------------
# Reference methods
# --------------------------------------------------------------------------
class Muon(_BaseMethod):
"""Full-precision Muon: ``d_t = polar(M_tilde)``."""
method_name = "muon"
family = FAMILY_LMO
def _direction(self, m_tilde, state, group):
return self._lmo(m_tilde, group)
class SignSGD(_BaseMethod):
"""SignSGD with momentum: ``d_t = sign(M_tilde)``.
Applies to tensors of any shape (no matrix structure is used).
"""
method_name = "signsgd"
family = FAMILY_SIGN
def _direction(self, m_tilde, state, group):
return sign_pm1(m_tilde)
# --------------------------------------------------------------------------
# Sign around the LMO (Theorems 1-3: all three can diverge)
# --------------------------------------------------------------------------
class SignMuon(_BaseMethod):
"""SignMuon -- sign AFTER the LMO: ``d_t = sign(polar(M_tilde))`` (Thm 1)."""
method_name = "signmuon"
family = FAMILY_SIGN
def _direction(self, m_tilde, state, group):
return sign_pm1(self._lmo(m_tilde, group))
class MuonUSign(_BaseMethod):
"""MuonUSign -- sign BEFORE the LMO: ``d_t = polar(sign(M_tilde))`` (Thm 2).
The uplink carries one bit per parameter; the LMO runs on the (uncompressed)
receiving side, so the step itself is full precision.
"""
method_name = "muonusign"
family = FAMILY_LMO
def _direction(self, m_tilde, state, group):
return self._lmo(sign_pm1(m_tilde), group)
class MuonSign(_BaseMethod):
"""MuonSign -- sign on BOTH sides: ``d_t = sign(polar(sign(M_tilde)))`` (Thm 3).
Both the uplink and the downlink cost one bit per parameter.
"""
method_name = "muonsign"
family = FAMILY_SIGN
def _direction(self, m_tilde, state, group):
return sign_pm1(self._lmo(sign_pm1(m_tilde), group))
# --------------------------------------------------------------------------
# Error-feedback methods
# --------------------------------------------------------------------------
class EF21SignMuon(_BaseMethod, _EF21Mixin):
"""EF21 on the LMO *output* -- the method that still diverges (Theorem 4).
``D_t = polar(M_tilde)`` is tracked by a scaled-sign EF21 estimator and the
step is ``d_t = d_est``. Included because the paper needs it as the
counterexample: a single shared magnitude ``alpha_t`` rescales all entries at
once, so a large sign-flipping off-diagonal pins ``alpha_t`` and the
estimate of a small target locks onto the wrong sign.
"""
method_name = "ef21signmuon"
family = FAMILY_LMO
def _direction(self, m_tilde, state, group):
return self._ef21_update(self._lmo(m_tilde, group), state, "dir_estimator")
class EF21MuonUSign(_BaseMethod, _EF21Mixin):
"""EF21-MuonUSign -- EF21 on the momentum, full Muon LMO after it.
Single-node reduction of EF21-Muon (Gruntkowska et al., 2025) with the
scaled-sign compressor on the uplink and the identity on the downlink.
Compressing *before* the oracle is what restores the descent property:
``g_est -> M_tilde`` and hence ``d_t -> polar(M_tilde)``.
"""
method_name = "ef21muonusign"
family = FAMILY_LMO
def _direction(self, m_tilde, state, group):
g_est = self._ef21_update(m_tilde, state, "grad_estimator")
return self._lmo(g_est, group)
class EF21MuonSign(_BaseMethod, _EF21Mixin):
"""EF21-MuonSign -- bidirectional EF21 (scaled sign on both channels).
Two error-feedback loops:
* uplink, on the gradient residual: ``g_est += mean|M-g_est| * sign(M-g_est)``
* downlink, on the model increment: ``W += mean|X-W| * sign(X-W)``
``p.data`` holds the *broadcast* model ``W`` -- so ``p.grad`` is the gradient
at ``W``, as the algorithm requires -- while the exact server model ``X``
lives in the ``exact_model`` state buffer. Use :meth:`using_exact` (or
:meth:`restore_exact`) to evaluate on ``X``, which is the iterate the
convergence theory bounds.
"""
method_name = "ef21muonsign"
family = FAMILY_LMO
def _weight_decay_target(self, p, state):
return self._exact(p)
def _step_target(self, p, state):
return self._exact(p)
def _exact(self, p) -> Tensor:
state = self.state[p]
if "exact_model" not in state:
state["exact_model"] = p.data.clone()
return state["exact_model"]
def _direction(self, m_tilde, state, group):
g_est = self._ef21_update(m_tilde, state, "grad_estimator")
return self._lmo(g_est, group)
def _post_step(self, p, state, group):
# Downlink EF21-P: broadcast a scaled sign of the model shift X - W.
delta = state["exact_model"] - p.data
p.data.add_(delta.abs().mean() * sign_pm1(delta))
# -- exposing the exact model ----------------------------------------
@torch.no_grad()
def restore_exact(self, params=None) -> None:
"""Copy the exact server model ``X`` back into ``p.data``, permanently."""
groups = self.param_groups if params is None else [{"params": params}]
for group in groups:
for p in group["params"]:
st = self.state.get(p, {})
if "exact_model" in st:
p.data.copy_(st["exact_model"])
@contextmanager
def using_exact(self):
"""Temporarily expose ``X`` in ``p.data``, restoring ``W`` afterwards.
Wrap evaluation in this so metrics are computed on the exact model while
the invariant "``p.grad`` is the gradient at ``W``" survives for the next
training step.
"""
saved = {}
for group in self.param_groups:
for p in group["params"]:
st = self.state.get(p, {})
if "exact_model" in st:
saved[p] = p.data.clone()
p.data.copy_(st["exact_model"])
try:
yield
finally:
for p, w in saved.items():
p.data.copy_(w)
# --------------------------------------------------------------------------
# Registry
# --------------------------------------------------------------------------
OPTIMIZERS = {
"signmuon": SignMuon,
"ef21signmuon": EF21SignMuon,
"muonusign": MuonUSign,
"muonsign": MuonSign,
"ef21muonusign": EF21MuonUSign,
"ef21muonsign": EF21MuonSign,
"muon": Muon,
"signsgd": SignSGD,
}
PAPER_METHODS = ["signmuon", "ef21signmuon", "muonusign", "muonsign",
"ef21muonusign", "ef21muonsign"]
REFERENCE_METHODS = ["muon", "signsgd"]