Skip to content

Commit 23eb7f3

Browse files
committed
add rendered markdown
Signed-off-by: Hao Wu <skyw@nvidia.com>
1 parent 32f09c6 commit 23eb7f3

7 files changed

Lines changed: 784 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Gradient spikes and the SOAP preconditioner
2+
3+
A set of executed-notebook walkthroughs of how different optimizers respond to a sudden gradient spike, and how that exposed a TF32 precision bug in the SOAP/REKLS KL-Shampoo preconditioner. Read in order:
4+
5+
1. **Optimizer update comparison** — per-step update magnitude and spike-recovery behavior of AdamW, LaProp, Muon, SOAP, and REKLS.
6+
2. **Preconditioner eigenbasis rotation** — how the SOAP/REKLS eigenbasis `Q_L` rotates around a spike, and how it depends on `fp32_matmul_prec`.
7+
3. **TF32 eigenvalue precision loss** — a standalone demo of the underlying `diag(Qᵀ L Q)` precision failure that drives the rotation.
8+
9+
```{toctree}
10+
:caption: Gradient spike
11+
:hidden:
12+
13+
optimizer-update-comparison.md
14+
preconditioner-eigenbasis-spike.md
15+
tf32-eigval-precision-loss.md
16+
```
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
# Comparing optimizer updates: warmup and spike response
2+
3+
This notebook visualizes the per-step parameter update produced by **Muon**, **SOAP**, **REKLS**, **LaProp**, and the reference **AdamW** in two regimes:
4+
5+
1. **Per-step trajectory** at a single gradient scale — how quickly each optimizer reaches its steady-state update magnitude.
6+
2. **Spike response** — how each optimizer reacts to a single gradient that is 1000× the normal scale, and how many steps it takes to recover.
7+
8+
Setup:
9+
10+
- A single 2-D parameter of shape `(128, 64)` is initialized once and cloned per optimizer/run.
11+
- Each run feeds the parameter a sequence of i.i.d. Gaussian gradients.
12+
- After every step we record the **update**, defined as `p_before - p_after` (the negative of `lr * effective_update`).
13+
- All optimizers use `lr = 1.0`, `weight_decay = 0.0` so the recorded value is the raw per-step update.
14+
15+
What to look for:
16+
17+
- **AdamW**: update RMS saturates to roughly `lr` regardless of gradient scale — Adam normalizes by the running second moment, behaving like a signed gradient at steady state.
18+
- **LaProp**: saturates to ~`lr` like AdamW (it also normalizes by the running second moment, but does so on the gradient *before* the first-moment EMA). Configured here with `β₂ = 0.65` (vs `0.95` for AdamW) so its second-moment EMA forgets a spike in ~3 steps instead of ~20 — spike recovery is therefore much faster.
19+
- **Muon**: the update is an orthogonal matrix times a shape-dependent constant. Its spectral norm is determined entirely by `scale_mode`, not by the gradient magnitude.
20+
- **SOAP / REKLS**: Adam-like in the eigenbasis of the gradient covariance. Update RMS is similar to AdamW once the Kronecker factors warm up, but the singular-value spectrum is shaped by the preconditioner.
21+
22+
Dependencies beyond the repo: `matplotlib` (install with `uv pip install matplotlib` or `pip install matplotlib`).
23+
24+
25+
26+
```python
27+
import os
28+
import matplotlib.pyplot as plt
29+
import numpy as np
30+
import torch
31+
32+
from emerging_optimizers.orthogonalized_optimizers.muon import Muon
33+
from emerging_optimizers.scalar_optimizers.laprop import LaProp
34+
from emerging_optimizers.soap.rekls import REKLS
35+
from emerging_optimizers.soap.soap import SOAP
36+
37+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
38+
# device = "cpu"
39+
dtype = torch.float32
40+
print(f"device={device}")
41+
42+
# os.environ["TORCH_ALLOW_TF32_CUBLAS_OVERRIDE"] = "0"
43+
```
44+
45+
device=cuda
46+
47+
48+
49+
```python
50+
PARAM_SHAPE = (128, 64)
51+
N_STEPS = 100
52+
SEED = 0
53+
54+
"""
55+
All optimizers share lr=1.0 and weight_decay=0.0 so the recorded update is the raw
56+
update before learning-rate scaling.
57+
58+
Beta choices:
59+
- AdamW / SOAP / REKLS: betas=(0.9, 0.95) — same second-moment time constant for
60+
apples-to-apples comparison.
61+
- LaProp: betas=(0.9, 0.65) — deliberately shorter beta2 so its `v` forgets a spike
62+
quickly. Steady-state behavior is similar to AdamW; spike recovery is much faster
63+
because the inflated `v` decays in ~3 steps (vs ~20 at beta2=0.95).
64+
- Muon: momentum=0.95 (analogous to beta1 of the Adam-like optimizers).
65+
"""
66+
LR = 1.0
67+
68+
69+
def make_optimizers(param: torch.Tensor) -> dict[str, torch.optim.Optimizer]:
70+
return {
71+
"AdamW": torch.optim.AdamW([param], lr=LR, betas=(0.9, 0.95), eps=1e-8, weight_decay=0.0),
72+
"LaProp": LaProp([param], lr=LR, betas=(0.9, 0.65), eps=1e-8, weight_decay=0.0),
73+
"Muon": Muon([param], lr=LR, momentum=0.95, weight_decay=0.0),
74+
"SOAP": SOAP([param], lr=LR, betas=(0.9, 0.95), shampoo_beta=0.95, weight_decay=0.0, fp32_matmul_prec="highest", use_kl_shampoo=True),
75+
"REKLS": REKLS([param], lr=LR, betas=(0.9, 0.95), shampoo_beta=0.95, weight_decay=0.0),
76+
}
77+
78+
79+
OPTIMIZER_NAMES = list(make_optimizers(torch.zeros(PARAM_SHAPE, device=device)).keys())
80+
print("Optimizers:", OPTIMIZER_NAMES)
81+
82+
```
83+
84+
Optimizers: ['AdamW', 'LaProp', 'Muon', 'SOAP', 'REKLS']
85+
86+
87+
88+
```python
89+
def run_optimizer(
90+
optimizer_name: str,
91+
grad_scale: float,
92+
n_steps: int = N_STEPS,
93+
shape: tuple[int, int] = PARAM_SHAPE,
94+
seed: int = SEED,
95+
) -> torch.Tensor:
96+
"""Drive `optimizer_name` with i.i.d. Gaussian gradients of std=`grad_scale` and return the per-step updates.
97+
98+
The same `seed` is used for every optimizer, so they all see the exact same gradient sequence.
99+
The returned tensor has shape `(n_steps, *shape)` and contains `p_before - p_after` per step.
100+
"""
101+
g = torch.Generator(device=device).manual_seed(seed)
102+
103+
param = torch.zeros(shape, device=device, dtype=dtype, requires_grad=True)
104+
opt = make_optimizers(param)[optimizer_name]
105+
106+
updates = torch.empty((n_steps, *shape), device=device, dtype=dtype)
107+
for i in range(n_steps):
108+
with torch.no_grad():
109+
grad = torch.randn(shape, device=device, dtype=dtype, generator=g) * grad_scale
110+
param.grad = grad
111+
p_before = param.detach().clone()
112+
opt.step()
113+
updates[i] = p_before - param.detach()
114+
return updates.cpu()
115+
116+
117+
# Sanity check: a single short run.
118+
_smoke = run_optimizer("Muon", grad_scale=1.0, n_steps=5)
119+
print("smoke updates shape:", tuple(_smoke.shape))
120+
```
121+
122+
smoke updates shape: (5, 128, 64)
123+
124+
125+
## Per-step update trajectory
126+
127+
Drive every optimizer with the same i.i.d. Gaussian gradients (`std = 1`, same seed across optimizers) and record the per-step update. (All five optimizers are scale-invariant to the gradient magnitude in steady state — sweeping over magnitudes would just produce overlapping curves at the same value, so we run only at `grad_scale = 1.0` here.)
128+
129+
130+
131+
```python
132+
WARMUP = 30
133+
TRAJECTORY_SCALE = 1.0
134+
135+
136+
def update_rms(u: torch.Tensor) -> torch.Tensor:
137+
# u shape: (n_steps, m, n) → per-step RMS over the (m, n) elements.
138+
return u.flatten(1).square().mean(dim=1).sqrt()
139+
140+
141+
def update_spectral_norm(u: torch.Tensor) -> torch.Tensor:
142+
# Largest singular value per step.
143+
return torch.linalg.matrix_norm(u, ord=2)
144+
145+
146+
# trajectory_updates[name] is a tensor of shape (N_STEPS, *PARAM_SHAPE) on CPU.
147+
trajectory_updates: dict[str, torch.Tensor] = {}
148+
for name in OPTIMIZER_NAMES:
149+
trajectory_updates[name] = run_optimizer(name, grad_scale=TRAJECTORY_SCALE)
150+
print(f"finished {name}")
151+
152+
```
153+
154+
finished AdamW
155+
finished LaProp
156+
finished Muon
157+
finished SOAP
158+
finished REKLS
159+
160+
161+
162+
```python
163+
fig, ax = plt.subplots(figsize=(8, 4.5))
164+
for name in OPTIMIZER_NAMES:
165+
rms_traj = update_rms(trajectory_updates[name]).numpy()
166+
ax.plot(np.arange(1, N_STEPS + 1), rms_traj, label=name)
167+
168+
ax.set_yscale("log")
169+
ax.set_xlabel("step")
170+
ax.set_ylabel("update RMS")
171+
ax.set_title(f"Per-step update RMS at grad_scale={TRAJECTORY_SCALE}")
172+
ax.grid(True, which="both", alpha=0.3)
173+
ax.legend()
174+
fig.tight_layout()
175+
plt.show()
176+
177+
```
178+
179+
180+
181+
![png](optimizer-update-comparison_files/optimizer-update-comparison_6_0.png)
182+
183+
184+
185+
## Sudden gradient spike — sweep over spike timing and magnitude
186+
187+
Inject a single anomalously large gradient on step `spike_at` (one of `{10, 50, 100}` — covering during warmup, just past warmup, and deep in steady state), at magnitudes spanning `SPIKE_SCALE ∈ {1e2, 1e3, 1e4, 1e5}`. Continue with normal `randn()` gradients for the rest of the run and watch how each optimizer recovers.
188+
189+
Predictions:
190+
191+
- **AdamW / LaProp**: the spike step produces a large update because `exp_avg_sq` lags the gradient. After absorbing the spike into `exp_avg_sq`, the denominator stays inflated by `~SPIKE_SCALE`, suppressing subsequent updates until `exp_avg_sq` decays back over `~1/(1 - β₂)` steps. Larger spikes take proportionally longer to forget.
192+
- **Muon**: orthogonalization removes the magnitude entirely, so the spike's effect on the *update size* should be ≈ none, regardless of scale or timing.
193+
- **SOAP / REKLS**: both the Kronecker factors (`L`, `R`) and the inner Adam's `exp_avg_sq` ingest the spike, so recovery depends on `shampoo_beta` and `β₂`. Recovery time should grow with spike magnitude. Spiking during warmup (`spike_at=10`) may behave differently from spiking in steady state since the factors haven't fully accumulated yet.
194+
195+
196+
197+
```python
198+
SPIKE_AT_LIST = [10, 50, 100]
199+
SPIKE_SCALES = [1e2, 1e3, 1e4, 1e5]
200+
SPIKE_TOTAL_STEPS = 200
201+
NORMAL_SCALE = 1.0
202+
203+
204+
def run_with_spike(
205+
optimizer_name: str,
206+
spike_at: int,
207+
spike_scale: float,
208+
normal_scale: float = NORMAL_SCALE,
209+
n_steps: int = SPIKE_TOTAL_STEPS,
210+
shape: tuple[int, int] = PARAM_SHAPE,
211+
seed: int = SEED,
212+
) -> torch.Tensor:
213+
"""Same driver as `run_optimizer`, except step `spike_at` uses `spike_scale × normal_scale` instead of `normal_scale`."""
214+
g = torch.Generator(device=device).manual_seed(seed)
215+
216+
param = torch.zeros(shape, device=device, dtype=dtype, requires_grad=True)
217+
opt = make_optimizers(param)[optimizer_name]
218+
219+
updates = torch.empty((n_steps, *shape), device=device, dtype=dtype)
220+
for i in range(n_steps):
221+
scale = spike_scale * normal_scale if i == spike_at else normal_scale
222+
with torch.no_grad():
223+
grad = torch.randn(shape, device=device, dtype=dtype, generator=g) * scale
224+
param.grad = grad
225+
p_before = param.detach().clone()
226+
opt.step()
227+
updates[i] = p_before - param.detach()
228+
return updates.cpu()
229+
230+
231+
# spike_results[(spike_at, spike_scale)][name] is a tensor of shape (SPIKE_TOTAL_STEPS, *PARAM_SHAPE).
232+
spike_results: dict[tuple[int, float], dict[str, torch.Tensor]] = {}
233+
for spike_at in SPIKE_AT_LIST:
234+
for spike_scale in SPIKE_SCALES:
235+
spike_results[(spike_at, spike_scale)] = {}
236+
for name in OPTIMIZER_NAMES:
237+
spike_results[(spike_at, spike_scale)][name] = run_with_spike(name, spike_at, spike_scale)
238+
print(f"finished spike_at={spike_at}")
239+
240+
```
241+
242+
finished spike_at=10
243+
finished spike_at=50
244+
finished spike_at=100
245+
246+
247+
248+
```python
249+
fig, axes = plt.subplots(len(SPIKE_AT_LIST), len(SPIKE_SCALES), figsize=(16, 9), sharex=True, sharey=True)
250+
steps_x = np.arange(1, SPIKE_TOTAL_STEPS + 1)
251+
252+
for i, spike_at in enumerate(SPIKE_AT_LIST):
253+
for j, spike_scale in enumerate(SPIKE_SCALES):
254+
ax = axes[i, j]
255+
for name in OPTIMIZER_NAMES:
256+
rms = update_rms(spike_results[(spike_at, spike_scale)][name]).numpy()
257+
ax.plot(steps_x, rms, label=name)
258+
ax.axvline(spike_at + 1, color="k", linestyle="--", alpha=0.5)
259+
ax.set_yscale("log")
260+
ax.set_title(f"spike_at={spike_at}, scale=×{spike_scale:g}")
261+
ax.grid(True, which="both", alpha=0.3)
262+
if i == len(SPIKE_AT_LIST) - 1:
263+
ax.set_xlabel("step")
264+
if j == 0:
265+
ax.set_ylabel("update RMS")
266+
267+
handles, labels = axes[0, 0].get_legend_handles_labels()
268+
fig.legend(handles, labels, loc="upper center", ncol=len(OPTIMIZER_NAMES), bbox_to_anchor=(0.5, 1.02))
269+
fig.tight_layout()
270+
plt.show()
271+
272+
```
273+
274+
275+
276+
![png](optimizer-update-comparison_files/optimizer-update-comparison_9_0.png)
277+
278+
279+
280+
### Recovery summary
281+
282+
For each `(optimizer, spike_at, spike_scale)` cell, report the number of steps after the spike before the update RMS returns within 10% of the **post-spike steady-state RMS** (mean of the last 30 steps of the trajectory). Using the post-spike steady state as the reference makes the metric meaningful even when the spike happens during warmup, where the pre-spike "steady-state" isn't yet established.
283+
284+
285+
286+
```python
287+
TOLERANCE = 0.10
288+
STEADY_WINDOW = 30 # last N steps of trajectory used to define post-spike steady-state RMS
289+
290+
291+
def recovery_steps(rms_traj: np.ndarray, spike_at: int) -> int | str:
292+
target = rms_traj[-STEADY_WINDOW:].mean()
293+
post = rms_traj[spike_at + 1 :]
294+
within = np.where(np.abs(post - target) <= TOLERANCE * target)[0]
295+
return int(within[0]) + 1 if len(within) else f">{len(post)}"
296+
297+
298+
header_scales = " ".join(f"{s:>8.0e}" for s in SPIKE_SCALES)
299+
print("Recovery time (steps after spike to return within 10% of post-spike steady-state RMS):")
300+
print(f" {'optimizer':<8} {'spike_at':>8} {header_scales}")
301+
print(" " + "-" * (10 + 10 + len(header_scales)))
302+
for name in OPTIMIZER_NAMES:
303+
for k, spike_at in enumerate(SPIKE_AT_LIST):
304+
cells = []
305+
for spike_scale in SPIKE_SCALES:
306+
rms = update_rms(spike_results[(spike_at, spike_scale)][name]).numpy()
307+
cells.append(f"{recovery_steps(rms, spike_at):>8}")
308+
opt_label = name if k == 0 else ""
309+
print(f" {opt_label:<8} {spike_at:>8} " + " ".join(cells))
310+
print()
311+
312+
```
313+
314+
Recovery time (steps after spike to return within 10% of post-spike steady-state RMS):
315+
optimizer spike_at 1e+02 1e+03 1e+04 1e+05
316+
----------------------------------------------------------
317+
AdamW 10 7 13 26 41
318+
50 9 19 33 46
319+
100 15 28 44 56
320+
321+
LaProp 10 4 4 4 4
322+
50 1 1 1 1
323+
100 1 1 1 1
324+
325+
Muon 10 1 1 1 1
326+
50 1 1 1 1
327+
100 1 1 1 1
328+
329+
SOAP 10 25 43 68 89
330+
50 27 48 74 96
331+
100 32 56 77 80
332+
333+
REKLS 10 26 51 72 148
334+
50 26 54 77 98
335+
100 33 59 80 81
336+
337+
338+
339+
## Takeaways
340+
341+
- **AdamW** is approximately scale-invariant in steady state, but a 1000× spike is *not* free: the spike step itself produces a large update because `exp_avg_sq` lags the gradient, and the inflated `exp_avg_sq` then suppresses subsequent updates until it decays back over `~1/(1 - β₂)` steps. Recovery time scales roughly logarithmically with spike magnitude.
342+
- **LaProp** with the short `β₂ = 0.65` used here recovers in essentially one step at steady-state spikes (and ~4 steps if the spike lands during warmup). With matching `β₂ = 0.95` it would behave similarly to AdamW. The shorter second-moment time constant is the only knob doing the work — LaProp's pre-normalize-then-momentum order doesn't help much by itself.
343+
- **Muon** is exactly scale-invariant after the Newton–Schulz iteration: orthogonalization throws away the gradient's magnitude, leaving only the shape-dependent scaling factor (`spectral` mode → `sqrt(max(m, n))`). The 1000× spike barely registers in the update RMS, though it can rotate the momentum-driven update *direction* for a few steps.
344+
- **SOAP / REKLS** are scale-invariant in steady state (Adam runs in the preconditioned space), but the singular-value spectrum of the update is flatter than AdamW's because of the Kronecker-factored preconditioner. A gradient spike pollutes both the Kronecker factors and the inner Adam second moment, so they take roughly `1/(1 - shampoo_beta)` steps to forget it. On CUDA, the `fp32_matmul_prec="highest"` workaround (or the targeted fix in `update_kronecker_factors_kl_shampoo`) is required to avoid TF32-induced divergence under high-magnitude spikes; see `preconditioner-eigenbasis-spike.ipynb` for the diagnostic.
345+
346+
347+
```python
348+
349+
```
42.5 KB
Loading
272 KB
Loading

0 commit comments

Comments
 (0)