Andrej Karpathy commited on
Commit
41bb2ea
·
1 Parent(s): 64a651a

Combine AdamW and Muon into single MuonAdamW optimizer, cleaner, ty @chrisjmccormick for idea/help

Browse files
README.md CHANGED
@@ -135,7 +135,6 @@ python -m pytest tests/test_engine.py -v -s
135
  │ └── repackage_data_reference.py # Pretraining data shard generation
136
  ├── nanochat
137
  │ ├── __init__.py # empty
138
- │ ├── adamw.py # Distributed AdamW optimizer
139
  │ ├── checkpoint_manager.py # Save/Load model checkpoints
140
  │ ├── common.py # Misc small utilities, quality of life
141
  │ ├── core_eval.py # Evaluates base model CORE score (DCLM paper)
@@ -146,7 +145,7 @@ python -m pytest tests/test_engine.py -v -s
146
  │ ├── gpt.py # The GPT nn.Module Transformer
147
  │ ├── logo.svg
148
  │ ├── loss_eval.py # Evaluate bits per byte (instead of loss)
149
- │ ├── muon.py # Distributed Muon optimizer
150
  │ ├── report.py # Utilities for writing the nanochat Report
151
  │ ├── tokenizer.py # BPE Tokenizer wrapper in style of GPT-4
152
  │ └── ui.html # HTML/CSS/JS for nanochat frontend
 
135
  │ └── repackage_data_reference.py # Pretraining data shard generation
136
  ├── nanochat
137
  │ ├── __init__.py # empty
 
138
  │ ├── checkpoint_manager.py # Save/Load model checkpoints
139
  │ ├── common.py # Misc small utilities, quality of life
140
  │ ├── core_eval.py # Evaluates base model CORE score (DCLM paper)
 
145
  │ ├── gpt.py # The GPT nn.Module Transformer
146
  │ ├── logo.svg
147
  │ ├── loss_eval.py # Evaluate bits per byte (instead of loss)
148
+ │ ├── optim.py # AdamW + Muon optimizer, 1GPU and distributed
149
  │ ├── report.py # Utilities for writing the nanochat Report
150
  │ ├── tokenizer.py # BPE Tokenizer wrapper in style of GPT-4
151
  │ └── ui.html # HTML/CSS/JS for nanochat frontend
nanochat/adamw.py DELETED
@@ -1,143 +0,0 @@
1
- """
2
- Distributed AdamW optimizer with a fused step function.
3
- A bunch of ideas (e.g. dist comms in slices) are borrowed from modded-nanogpt.
4
- """
5
- import torch
6
- import torch.distributed as dist
7
- from torch import Tensor
8
-
9
- @torch.compile(dynamic=False, fullgraph=True)
10
- def adamw_step_fused(
11
- p: Tensor,
12
- grad: Tensor,
13
- exp_avg: Tensor,
14
- exp_avg_sq: Tensor,
15
- step_t: Tensor,
16
- lr_t: Tensor,
17
- beta1_t: Tensor,
18
- beta2_t: Tensor,
19
- eps_t: Tensor,
20
- wd_t: Tensor,
21
- ) -> None:
22
- """
23
- Fused AdamW step: weight_decay -> momentum_update -> bias_correction -> param_update
24
- All in one compiled graph to eliminate Python overhead between ops.
25
- The 0-D CPU tensors avoid recompilation when hyperparameter values change.
26
- """
27
- # Weight decay (decoupled, applied before the update)
28
- p.mul_(1 - lr_t * wd_t)
29
- # Update running averages (lerp_ is cleaner and fuses well)
30
- exp_avg.lerp_(grad, 1 - beta1_t)
31
- exp_avg_sq.lerp_(grad.square(), 1 - beta2_t)
32
- # Bias corrections
33
- bias1 = 1 - beta1_t ** step_t
34
- bias2 = 1 - beta2_t ** step_t
35
- # Compute update and apply
36
- denom = (exp_avg_sq / bias2).sqrt() + eps_t
37
- step_size = lr_t / bias1
38
- p.add_(exp_avg / denom, alpha=-step_size)
39
-
40
-
41
- class DistAdamW(torch.optim.Optimizer):
42
- """
43
- Distributed AdamW optimizer.
44
- In the style of ZeRO-2, i.e. sharded optimizer states and gradient reduction
45
- """
46
- def __init__(self, param_groups, lr: float = 1e-3, betas: tuple[float, float] = (0.9, 0.999), eps: float = 1e-8, weight_decay: float = 0.01):
47
- defaults = dict(lr=lr, betas=betas, eps=eps, weight_decay=weight_decay)
48
- rank = dist.get_rank()
49
- world_size = dist.get_world_size()
50
- # Validate
51
- if rank == 0:
52
- for group in param_groups:
53
- assert isinstance(group, dict), "expecting param_groups to be a list of dicts"
54
- assert isinstance(group['params'], list), "expecting group['params'] to be a list of tensors"
55
- for p in group['params']:
56
- sliced = p.numel() >= 1024
57
- print(f"AdamW: 1 param of shape {p.shape}, sliced={sliced}")
58
- if sliced: # large parameter tensors will be operated on in slices
59
- assert p.shape[0] % world_size == 0, f"First dim of parameter shape {p.shape} must be divisible by world size {world_size}"
60
- super().__init__(param_groups, defaults)
61
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
62
- self._step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
63
- self._lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
64
- self._beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
65
- self._beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
66
- self._eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
67
- self._wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
68
-
69
- @torch.no_grad()
70
- def step(self):
71
- rank = dist.get_rank()
72
- world_size = dist.get_world_size()
73
- reduce_futures: list[torch.Future] = []
74
- gather_futures: list[torch.Future] = []
75
- grad_slices = []
76
- is_small = [] # track which params are small (use all_reduce) vs large (use reduce_scatter)
77
-
78
- for group in self.param_groups:
79
- params: list[Tensor] = group["params"]
80
- for p in params:
81
- grad = p.grad
82
- # Small params: use all_reduce (no scatter/gather needed)
83
- if p.numel() < 1024:
84
- is_small.append(True)
85
- reduce_futures.append(dist.all_reduce(grad, op=dist.ReduceOp.AVG, async_op=True).get_future())
86
- grad_slices.append(grad)
87
- else:
88
- is_small.append(False)
89
- rank_size = grad.shape[0] // world_size # p.shape[0] % world_size == 0 is checked in __init__
90
- grad_slice = torch.empty_like(grad[:rank_size])
91
- reduce_futures.append(dist.reduce_scatter_tensor(grad_slice, grad, op=dist.ReduceOp.AVG, async_op=True).get_future())
92
- grad_slices.append(grad_slice)
93
-
94
- idx = 0
95
- for group in self.param_groups:
96
- beta1, beta2 = group['betas']
97
- eps = group['eps']
98
- wd = group['weight_decay']
99
- params = group['params']
100
- for p in params:
101
- reduce_futures[idx].wait()
102
- g_slice = grad_slices[idx]
103
- lr = group['lr'] * getattr(p, "lr_mul", 1.0)
104
- state = self.state[p]
105
-
106
- # For small params, operate on full param; for large, operate on slice
107
- if is_small[idx]:
108
- p_slice = p
109
- else:
110
- rank_size = p.shape[0] // world_size
111
- p_slice = p[rank * rank_size:(rank + 1) * rank_size]
112
-
113
- # State init
114
- if not state:
115
- state['step'] = 0
116
- state['exp_avg'] = torch.zeros_like(p_slice)
117
- state['exp_avg_sq'] = torch.zeros_like(p_slice)
118
- exp_avg = state['exp_avg']
119
- exp_avg_sq = state['exp_avg_sq']
120
- state['step'] += 1
121
-
122
- # Fill 0-D tensors with current values
123
- eff_wd = wd * getattr(p, "wd_mul", 1.0)
124
- self._step_t.fill_(state['step'])
125
- self._lr_t.fill_(lr)
126
- self._beta1_t.fill_(beta1)
127
- self._beta2_t.fill_(beta2)
128
- self._eps_t.fill_(eps)
129
- self._wd_t.fill_(eff_wd)
130
-
131
- # Fused update: weight_decay -> momentum -> bias_correction -> param_update
132
- adamw_step_fused(
133
- p_slice, g_slice, exp_avg, exp_avg_sq,
134
- self._step_t, self._lr_t, self._beta1_t, self._beta2_t, self._eps_t, self._wd_t,
135
- )
136
-
137
- # Only large params need all_gather
138
- if not is_small[idx]:
139
- gather_futures.append(dist.all_gather_into_tensor(p, p_slice, async_op=True).get_future())
140
- idx += 1
141
-
142
- if gather_futures:
143
- torch.futures.collect_all(gather_futures).wait()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/gpt.py CHANGED
@@ -20,8 +20,7 @@ import torch.nn as nn
20
  import torch.nn.functional as F
21
 
22
  from nanochat.common import get_dist_info, print0
23
- from nanochat.muon import Muon, DistMuon
24
- from nanochat.adamw import DistAdamW
25
 
26
  # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
27
  from nanochat.flash_attention import flash_attn
@@ -346,9 +345,10 @@ class GPT(nn.Module):
346
  'total': total,
347
  }
348
 
349
- def setup_optimizers(self, unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, weight_decay=0.0, adam_betas=(0.8, 0.95), scalar_lr=0.5):
350
  model_dim = self.config.n_embd
351
  ddp, rank, local_rank, world_size = get_dist_info()
 
352
  # Separate out all parameters into groups
353
  matrix_params = list(self.transformer.h.parameters())
354
  value_embeds_params = list(self.value_embeds.parameters())
@@ -357,30 +357,33 @@ class GPT(nn.Module):
357
  resid_params = [self.resid_lambdas]
358
  x0_params = [self.x0_lambdas]
359
  assert len(list(self.parameters())) == len(matrix_params) + len(embedding_params) + len(lm_head_params) + len(value_embeds_params) + len(resid_params) + len(x0_params)
360
- # Create the AdamW optimizer for the embedding, lm_head, and per-layer scalars
361
- # Scale the LR for the AdamW parameters by ∝1/√dmodel (having tuned the LRs for 768 dim model)
362
  dmodel_lr_scale = (model_dim / 768) ** -0.5
363
  print0(f"Scaling the LR for the AdamW parameters ∝1/√({model_dim}/768) = {dmodel_lr_scale:.6f}")
364
- adam_groups = [
365
- dict(params=lm_head_params, lr=unembedding_lr * dmodel_lr_scale),
366
- dict(params=embedding_params, lr=embedding_lr * dmodel_lr_scale),
367
- dict(params=value_embeds_params, lr=embedding_lr * dmodel_lr_scale), # same LR as token embedding
368
- dict(params=resid_params, lr=scalar_lr * 0.01), # these are a lot more sensitive because they accumulate in the residual stream
369
- dict(params=x0_params, lr=scalar_lr, betas=(0.96, 0.95)), # higher beta1 for x0 scalars
 
 
 
370
  ]
371
- adamw_kwargs = dict(betas=adam_betas, eps=1e-10, weight_decay=0.0) # NOTE: weight decay is hardcoded to 0.0 for AdamW, only used in Muon
372
- AdamWFactory = DistAdamW if ddp else partial(torch.optim.AdamW, fused=True)
373
- adamw_optimizer = AdamWFactory(adam_groups, **adamw_kwargs)
374
- # Create the Muon optimizer for the linear layers
375
- muon_kwargs = dict(lr=matrix_lr, momentum=0.95, weight_decay=weight_decay)
376
- MuonFactory = DistMuon if ddp else Muon
377
- muon_optimizer = MuonFactory(matrix_params, **muon_kwargs)
378
- # Combine them the two optimizers into one list
379
- optimizers = [adamw_optimizer, muon_optimizer]
380
- for opt in optimizers:
381
- for group in opt.param_groups:
382
- group["initial_lr"] = group["lr"]
383
- return optimizers
384
 
385
  def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
386
  B, T = idx.size()
 
20
  import torch.nn.functional as F
21
 
22
  from nanochat.common import get_dist_info, print0
23
+ from nanochat.optim import MuonAdamW, DistMuonAdamW
 
24
 
25
  # Our custom Flash Attention module that automatically uses FA3 on Hopper+ and SDPA fallback elsewhere
26
  from nanochat.flash_attention import flash_attn
 
345
  'total': total,
346
  }
347
 
348
+ def setup_optimizer(self, unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, weight_decay=0.0, adam_betas=(0.8, 0.95), scalar_lr=0.5):
349
  model_dim = self.config.n_embd
350
  ddp, rank, local_rank, world_size = get_dist_info()
351
+
352
  # Separate out all parameters into groups
353
  matrix_params = list(self.transformer.h.parameters())
354
  value_embeds_params = list(self.value_embeds.parameters())
 
357
  resid_params = [self.resid_lambdas]
358
  x0_params = [self.x0_lambdas]
359
  assert len(list(self.parameters())) == len(matrix_params) + len(embedding_params) + len(lm_head_params) + len(value_embeds_params) + len(resid_params) + len(x0_params)
360
+
361
+ # Scale the LR for the AdamW parameters by ∝1/√dmodel (tuned for 768 dim model)
362
  dmodel_lr_scale = (model_dim / 768) ** -0.5
363
  print0(f"Scaling the LR for the AdamW parameters ∝1/√({model_dim}/768) = {dmodel_lr_scale:.6f}")
364
+
365
+ # Build param_groups with all required fields explicit
366
+ param_groups = [
367
+ # AdamW groups (embeddings, lm_head, scalars)
368
+ dict(kind='adamw', params=lm_head_params, lr=unembedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
369
+ dict(kind='adamw', params=embedding_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
370
+ dict(kind='adamw', params=value_embeds_params, lr=embedding_lr * dmodel_lr_scale, betas=adam_betas, eps=1e-10, weight_decay=0.0),
371
+ dict(kind='adamw', params=resid_params, lr=scalar_lr * 0.01, betas=adam_betas, eps=1e-10, weight_decay=0.0),
372
+ dict(kind='adamw', params=x0_params, lr=scalar_lr, betas=(0.96, 0.95), eps=1e-10, weight_decay=0.0), # higher beta1 for x0
373
  ]
374
+ # Muon groups (matrix params, grouped by shape for stacking)
375
+ for shape in sorted({p.shape for p in matrix_params}):
376
+ group_params = [p for p in matrix_params if p.shape == shape]
377
+ param_groups.append(dict(
378
+ kind='muon', params=group_params, lr=matrix_lr,
379
+ momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=weight_decay,
380
+ ))
381
+
382
+ Factory = DistMuonAdamW if ddp else MuonAdamW
383
+ optimizer = Factory(param_groups)
384
+ for group in optimizer.param_groups:
385
+ group["initial_lr"] = group["lr"]
386
+ return optimizer
387
 
388
  def forward(self, idx, targets=None, kv_cache=None, loss_reduction='mean'):
389
  B, T = idx.size()
nanochat/muon.py DELETED
@@ -1,352 +0,0 @@
1
- """
2
- Muon optimizer adapted and simplified from modded-nanogpt.
3
- https://github.com/KellerJordan/modded-nanogpt
4
-
5
- Background:
6
- Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a
7
- quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose
8
- of minimizing steps, it turns out to be empirically effective to keep increasing the slope at
9
- zero even beyond the point where the iteration no longer converges all the way to one everywhere
10
- on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T
11
- where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model
12
- performance at all relative to UV^T, where USV^T = G is the SVD.
13
-
14
- Here, an alternative to Newton-Schulz iteration with potentially better convergence properties:
15
- Polar Express Sign Method for orthogonalization.
16
- https://arxiv.org/pdf/2505.16932
17
- by Noah Amsel, David Persson, Christopher Musco, Robert M. Gower.
18
-
19
- Some of the changes in nanochat implementation:
20
- - Uses a simpler, more general approach to parameter grouping and stacking
21
- - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
22
- - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
23
- """
24
-
25
- import torch
26
- from torch import Tensor
27
- import torch.distributed as dist
28
-
29
- # Coefficients for Polar Express (computed for num_iters=5, safety_factor=2e-2, cushion=2)
30
- # From https://arxiv.org/pdf/2505.16932
31
- polar_express_coeffs = [
32
- (8.156554524902461, -22.48329292557795, 15.878769915207462),
33
- (4.042929935166739, -2.808917465908714, 0.5000178451051316),
34
- (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
35
- (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
36
- (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
37
- ]
38
-
39
- @torch.compile(dynamic=False, fullgraph=True)
40
- def muon_step_fused(
41
- stacked_grads: Tensor,
42
- stacked_params: Tensor,
43
- momentum_buffer: Tensor,
44
- second_momentum_buffer: Tensor,
45
- momentum_t: Tensor,
46
- lr_t: Tensor,
47
- wd_t: Tensor,
48
- beta2_t: Tensor,
49
- ns_steps: int,
50
- red_dim: int,
51
- ) -> None:
52
- """
53
- Fused Muon step: momentum -> polar_express -> variance_reduction -> cautious_update
54
- All in one compiled graph to eliminate Python overhead between ops.
55
- Some of the constants are 0-D CPU tensors to avoid recompilation when values change.
56
- """
57
-
58
- # Nesterov momentum
59
- momentum = momentum_t.to(stacked_grads.dtype)
60
- momentum_buffer.lerp_(stacked_grads, 1 - momentum)
61
- g = stacked_grads.lerp_(momentum_buffer, momentum)
62
-
63
- # Polar express
64
- X = g.bfloat16()
65
- if g.size(-2) > g.size(-1):
66
- X = X.mT
67
- X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.02 + 1e-6)
68
- for a, b, c in polar_express_coeffs[:ns_steps]:
69
- A = X @ X.mT
70
- B = b * A + c * (A @ A)
71
- X = a * X + B @ X
72
- if g.size(-2) > g.size(-1):
73
- X = X.mT
74
- g = X
75
-
76
- # Variance reduction
77
- beta2 = beta2_t.to(g.dtype)
78
- v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
79
- red_dim_size = g.size(red_dim)
80
- v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
81
- v_norm = v_norm_sq.sqrt()
82
- second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
83
- step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
84
- scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
85
- v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
86
- final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
87
- g = g * final_scale.to(g.dtype)
88
-
89
- # Cautious weight decay + parameter update
90
- lr = lr_t.to(g.dtype)
91
- wd = wd_t.to(g.dtype)
92
- mask = (g * stacked_params) >= 0
93
- stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
94
-
95
- class Muon(torch.optim.Optimizer):
96
- """
97
- Muon - MomentUm Orthogonalized by Newton-schulz
98
-
99
- https://kellerjordan.github.io/posts/muon/
100
-
101
- Muon internally runs standard SGD-momentum, and then performs an orthogonalization post-
102
- processing step, in which each 2D parameter's update is replaced with the nearest orthogonal
103
- matrix. To efficiently orthogonalize each update, we use a Newton-Schulz iteration, which has
104
- the advantage that it can be stably run in bfloat16 on the GPU.
105
-
106
- Some warnings:
107
- - This optimizer should not be used for the embedding layer, the final fully connected layer,
108
- or any {0,1}-D parameters; those should all be optimized by a standard method (e.g., AdamW).
109
- - To use it with 4D convolutional filters, it works well to just flatten their last 3 dimensions.
110
-
111
- Arguments:
112
- lr: The learning rate used by the internal SGD.
113
- momentum: The momentum used by the internal SGD.
114
- ns_steps: The number of Newton-Schulz iteration steps to use.
115
- beta2: The decay rate for the second moment (variance) estimate. Set to None to disable.
116
- weight_decay: Cautious weight decay coefficient. Only decays where update and weight agree.
117
- """
118
- def __init__(self, params, lr=0.02, momentum=0.95, ns_steps=5, beta2=0.95, weight_decay=0.0):
119
- defaults = dict(lr=lr, momentum=momentum, ns_steps=ns_steps, beta2=beta2, weight_decay=weight_decay)
120
- assert all(p.ndim == 2 for p in params), "Muon expects 2D parameters only"
121
- params = list(params) # ensure we have a list, not an e.g. (exhaustible) iterator
122
- # Group by shape so we can stack tensors
123
- shapes = sorted({p.shape for p in params})
124
- param_groups = []
125
- for shape in shapes:
126
- group_params = [p for p in params if p.shape == shape]
127
- param_groups.append(dict(params=group_params))
128
- super().__init__(param_groups, defaults)
129
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
130
- self._momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
131
- self._lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
132
- self._wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
133
- self._beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
134
-
135
- @torch.no_grad()
136
- def step(self):
137
- for group in self.param_groups:
138
- params: list[Tensor] = group["params"]
139
- if not params:
140
- continue
141
-
142
- # Get or create group-level buffers (stored in first param's state for convenience)
143
- state = self.state[params[0]]
144
- num_params = len(params) # e.g.: 12 (for a d12 model)
145
- # e.g.: shape = (768, 3072), device = cuda:0, dtype = torch.float32, for one of the MLP projections
146
- shape, device, dtype = params[0].shape, params[0].device, params[0].dtype
147
-
148
- # Momentum for every individual parameter
149
- if "momentum_buffer" not in state:
150
- state["momentum_buffer"] = torch.zeros(num_params, *shape, dtype=dtype, device=device)
151
- momentum_buffer = state["momentum_buffer"] # e.g.: (12, 768, 3072)
152
-
153
- # Second momentum buffer is factored, either per-row or per-column
154
- if "second_momentum_buffer" not in state:
155
- if shape[-2] >= shape[-1]:
156
- state["second_momentum_buffer"] = torch.zeros(num_params, shape[-2], 1, dtype=dtype, device=device)
157
- else:
158
- state["second_momentum_buffer"] = torch.zeros(num_params, 1, shape[-1], dtype=dtype, device=device)
159
- second_momentum_buffer = state["second_momentum_buffer"] # (12, 1, 3072)
160
- red_dim = -1 if shape[-2] >= shape[-1] else -2 # e.g.: -2
161
-
162
- # Stack grads and params
163
- stacked_grads = torch.stack([p.grad for p in params]) # (12, 768, 3072)
164
- stacked_params = torch.stack(params) # (12, 768, 3072)
165
-
166
- # Fill all the 0-D tensors with current values
167
- self._momentum_t.fill_(group["momentum"])
168
- self._beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
169
- self._lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
170
- self._wd_t.fill_(group["weight_decay"])
171
-
172
- # Single fused kernel: momentum -> polar_express -> variance_reduction -> update
173
- muon_step_fused(
174
- stacked_grads,
175
- stacked_params,
176
- momentum_buffer,
177
- second_momentum_buffer,
178
- self._momentum_t,
179
- self._lr_t,
180
- self._wd_t,
181
- self._beta2_t,
182
- group["ns_steps"],
183
- red_dim,
184
- )
185
-
186
- # Copy back to original params: [(768, 3072), (768, 3072), ...] <- (12, 768, 3072)
187
- torch._foreach_copy_(params, list(stacked_params.unbind(0)))
188
-
189
-
190
- class DistMuon(torch.optim.Optimizer):
191
- """
192
- Distributed version of the Muon optimizer.
193
- """
194
- def __init__(self, params, lr: float = 0.02, momentum: float = 0.95,
195
- ns_steps: int = 5, beta2: float = 0.95, weight_decay: float = 0.0):
196
- defaults = dict(lr=lr, momentum=momentum, ns_steps=ns_steps, beta2=beta2, weight_decay=weight_decay)
197
- assert all(p.ndim == 2 for p in params), "Muon expects 2D parameters only"
198
- params = list(params)
199
- world_size = dist.get_world_size()
200
- rank = dist.get_rank()
201
- # Group all parameters by their shape
202
- shapes = sorted({p.shape for p in params}) # sort for deterministic ordering across ranks
203
- param_groups = []
204
- for shape in shapes:
205
- group_params = [p for p in params if p.shape == shape]
206
- device, dtype = group_params[0].device, group_params[0].dtype
207
- assert all(p.device == device for p in group_params)
208
- assert all(p.dtype == dtype for p in group_params)
209
- # Compute chunk size for this group (how many params each rank owns)
210
- chunk_size = (len(group_params) + world_size - 1) // world_size
211
- if rank == 0:
212
- print(f"Muon: {len(group_params)} params of shape {shape}, chunk_size={chunk_size}")
213
- param_groups.append(dict(params=group_params, chunk_size=chunk_size))
214
- super().__init__(param_groups, defaults)
215
- # 0-D CPU tensors to avoid torch.compile recompilation when values change
216
- self._momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
217
- self._lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
218
- self._wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
219
- self._beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
220
-
221
- @torch.no_grad()
222
- def step(self):
223
- rank = dist.get_rank()
224
- world_size = dist.get_world_size()
225
-
226
- # Ensure all grads exist
227
- assert all(p.grad is not None for group in self.param_groups for p in group["params"]), "All params must have grads"
228
-
229
- # First pass: stack grads and kick off reduce_scatter for each group
230
- group_infos = []
231
- for group in self.param_groups:
232
- params: list[Tensor] = group["params"]
233
- chunk_size = group["chunk_size"]
234
- padded_num_params = chunk_size * world_size
235
- shape = params[0].shape
236
- device, dtype = params[0].device, params[0].dtype
237
-
238
- # Stack all gradients into a single tensor (single kernel via torch.stack)
239
- grad_stack = torch.stack([p.grad for p in params])
240
- stacked_grads = torch.empty(padded_num_params, *shape, dtype=dtype, device=device)
241
- stacked_grads[:len(params)].copy_(grad_stack)
242
- # Zero-pad if we have fewer params than padded size
243
- if len(params) < padded_num_params:
244
- stacked_grads[len(params):].zero_()
245
-
246
- # Output buffer for this rank's chunk
247
- grad_chunk = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
248
-
249
- # Async reduce_scatter on the stacked tensor
250
- reduce_future = dist.reduce_scatter_tensor(
251
- grad_chunk, stacked_grads, op=dist.ReduceOp.AVG, async_op=True
252
- ).get_future()
253
-
254
- group_infos.append(dict(
255
- grad_chunk=grad_chunk,
256
- reduce_future=reduce_future,
257
- stacked_grads=stacked_grads, # reuse for all_gather output
258
- ))
259
-
260
- # Second pass: wait for reduce, compute batched updates, kick off all_gather
261
- all_gather_futures = []
262
- for group, info in zip(self.param_groups, group_infos):
263
- info["reduce_future"].wait()
264
-
265
- params = group["params"]
266
- chunk_size = group["chunk_size"]
267
- shape = params[0].shape
268
- device, dtype = params[0].device, params[0].dtype
269
- grad_chunk = info["grad_chunk"]
270
-
271
- # How many params does this rank actually own?
272
- start_idx = rank * chunk_size
273
- num_owned = min(chunk_size, max(0, len(params) - start_idx))
274
-
275
- # Get or create group-level state (stored keyed by first param)
276
- state = self.state[params[0]]
277
-
278
- # Momentum buffer
279
- if "momentum_buffer" not in state:
280
- state["momentum_buffer"] = torch.zeros(chunk_size, *shape, dtype=dtype, device=device)
281
- momentum_buffer = state["momentum_buffer"]
282
-
283
- # Second momentum buffer is factored, either per-row or per-column
284
- if "second_momentum_buffer" not in state:
285
- if shape[-2] >= shape[-1]:
286
- state["second_momentum_buffer"] = torch.zeros(chunk_size, shape[-2], 1, dtype=dtype, device=device)
287
- else:
288
- state["second_momentum_buffer"] = torch.zeros(chunk_size, 1, shape[-1], dtype=dtype, device=device)
289
- second_momentum_buffer = state["second_momentum_buffer"]
290
- red_dim = -1 if shape[-2] >= shape[-1] else -2
291
-
292
- # Build updated_params tensor for all_gather
293
- updated_params = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
294
-
295
- if num_owned > 0:
296
- # Stack owned params (single kernel via torch.stack)
297
- owned_params = [params[start_idx + i] for i in range(num_owned)]
298
- stacked_owned_params = torch.stack(owned_params)
299
-
300
- # Get owned slices of buffers and grads
301
- owned_grads = grad_chunk[:num_owned]
302
- owned_momentum = momentum_buffer[:num_owned]
303
- owned_second_momentum = second_momentum_buffer[:num_owned]
304
-
305
- # Fill 0-D tensors with current values
306
- self._momentum_t.fill_(group["momentum"])
307
- self._beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
308
- self._lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
309
- self._wd_t.fill_(group["weight_decay"])
310
-
311
- # Single fused kernel: momentum -> polar_express -> variance_reduction -> update
312
- muon_step_fused(
313
- owned_grads,
314
- stacked_owned_params,
315
- owned_momentum,
316
- owned_second_momentum,
317
- self._momentum_t,
318
- self._lr_t,
319
- self._wd_t,
320
- self._beta2_t,
321
- group["ns_steps"],
322
- red_dim,
323
- )
324
-
325
- # Copy updated params to output buffer
326
- updated_params[:num_owned].copy_(stacked_owned_params)
327
-
328
- # Zero-pad the rest (for ranks that own fewer params)
329
- if num_owned < chunk_size:
330
- updated_params[num_owned:].zero_()
331
-
332
- # Reuse stacked_grads buffer for all_gather output
333
- stacked_params = info["stacked_grads"]
334
-
335
- # Async all_gather to replicate updated params to all ranks
336
- gather_future = dist.all_gather_into_tensor(
337
- stacked_params, updated_params, async_op=True
338
- ).get_future()
339
-
340
- all_gather_futures.append(dict(
341
- gather_future=gather_future,
342
- stacked_params=stacked_params,
343
- params=params,
344
- ))
345
-
346
- # Final pass: wait for all_gather and copy back to params
347
- for info in all_gather_futures:
348
- info["gather_future"].wait()
349
- stacked_params = info["stacked_params"]
350
- params = info["params"]
351
- # Batched copy back (single kernel instead of N individual copies)
352
- torch._foreach_copy_(params, list(stacked_params[:len(params)].unbind(0)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanochat/optim.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A nice and efficient mixed AdamW/Muon Combined Optimizer.
3
+ Usually the embeddings and scalars go into AdamW, and the matrix parameters go into Muon.
4
+ Two versions are provided (MuonAdamW, DistMuonAdamW), for single GPU and distributed.
5
+
6
+ Addapted from: https://github.com/KellerJordan/modded-nanogpt
7
+ Further contributions from @karpathy and @chrisjmccormick.
8
+ """
9
+
10
+ import torch
11
+ import torch.distributed as dist
12
+ from torch import Tensor
13
+
14
+ # -----------------------------------------------------------------------------
15
+ """
16
+ Good old AdamW optimizer, fused kernel.
17
+ https://arxiv.org/abs/1711.05101
18
+ """
19
+
20
+ @torch.compile(dynamic=False, fullgraph=True)
21
+ def adamw_step_fused(
22
+ p: Tensor, # (32768, 768) - parameter tensor
23
+ grad: Tensor, # (32768, 768) - gradient, same shape as p
24
+ exp_avg: Tensor, # (32768, 768) - first moment, same shape as p
25
+ exp_avg_sq: Tensor, # (32768, 768) - second moment, same shape as p
26
+ step_t: Tensor, # () - 0-D CPU tensor, step count
27
+ lr_t: Tensor, # () - 0-D CPU tensor, learning rate
28
+ beta1_t: Tensor, # () - 0-D CPU tensor, beta1
29
+ beta2_t: Tensor, # () - 0-D CPU tensor, beta2
30
+ eps_t: Tensor, # () - 0-D CPU tensor, epsilon
31
+ wd_t: Tensor, # () - 0-D CPU tensor, weight decay
32
+ ) -> None:
33
+ """
34
+ Fused AdamW step: weight_decay -> momentum_update -> bias_correction -> param_update
35
+ All in one compiled graph to eliminate Python overhead between ops.
36
+ The 0-D CPU tensors avoid recompilation when hyperparameter values change.
37
+ """
38
+ # Weight decay (decoupled, applied before the update)
39
+ p.mul_(1 - lr_t * wd_t)
40
+ # Update running averages (lerp_ is cleaner and fuses well)
41
+ exp_avg.lerp_(grad, 1 - beta1_t)
42
+ exp_avg_sq.lerp_(grad.square(), 1 - beta2_t)
43
+ # Bias corrections
44
+ bias1 = 1 - beta1_t ** step_t
45
+ bias2 = 1 - beta2_t ** step_t
46
+ # Compute update and apply
47
+ denom = (exp_avg_sq / bias2).sqrt() + eps_t
48
+ step_size = lr_t / bias1
49
+ p.add_(exp_avg / denom, alpha=-step_size)
50
+
51
+ # -----------------------------------------------------------------------------
52
+ """
53
+ Muon optimizer adapted and simplified from modded-nanogpt.
54
+ https://github.com/KellerJordan/modded-nanogpt
55
+
56
+ Background:
57
+ Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a
58
+ quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose
59
+ of minimizing steps, it turns out to be empirically effective to keep increasing the slope at
60
+ zero even beyond the point where the iteration no longer converges all the way to one everywhere
61
+ on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T
62
+ where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model
63
+ performance at all relative to UV^T, where USV^T = G is the SVD.
64
+
65
+ Here, an alternative to Newton-Schulz iteration with potentially better convergence properties:
66
+ Polar Express Sign Method for orthogonalization.
67
+ https://arxiv.org/pdf/2505.16932
68
+ by Noah Amsel, David Persson, Christopher Musco, Robert M. Gower.
69
+
70
+ Some of the changes in nanochat implementation:
71
+ - Uses a simpler, more general approach to parameter grouping and stacking
72
+ - Uses a single fused kernel for the momentum -> polar_express -> variance_reduction -> update step
73
+ - Makes no assumptions about model architecture (e.g. that attention weights are fused into QKVO format)
74
+ """
75
+
76
+ # Coefficients for Polar Express (computed for num_iters=5, safety_factor=2e-2, cushion=2)
77
+ # From https://arxiv.org/pdf/2505.16932
78
+ polar_express_coeffs = [
79
+ (8.156554524902461, -22.48329292557795, 15.878769915207462),
80
+ (4.042929935166739, -2.808917465908714, 0.5000178451051316),
81
+ (3.8916678022926607, -2.772484153217685, 0.5060648178503393),
82
+ (3.285753657755655, -2.3681294933425376, 0.46449024233003106),
83
+ (2.3465413258596377, -1.7097828382687081, 0.42323551169305323),
84
+ ]
85
+
86
+ @torch.compile(dynamic=False, fullgraph=True)
87
+ def muon_step_fused(
88
+ stacked_grads: Tensor, # (12, 768, 3072) - stacked gradients
89
+ stacked_params: Tensor, # (12, 768, 3072) - stacked parameters
90
+ momentum_buffer: Tensor, # (12, 768, 3072) - first moment buffer
91
+ second_momentum_buffer: Tensor, # (12, 768, 1) or (12, 1, 3072) - factored second moment
92
+ momentum_t: Tensor, # () - 0-D CPU tensor, momentum coefficient
93
+ lr_t: Tensor, # () - 0-D CPU tensor, learning rate
94
+ wd_t: Tensor, # () - 0-D CPU tensor, weight decay
95
+ beta2_t: Tensor, # () - 0-D CPU tensor, beta2 for second moment
96
+ ns_steps: int, # 5 - number of Newton-Schulz/Polar Express iterations
97
+ red_dim: int, # -1 or -2 - reduction dimension for variance
98
+ ) -> None:
99
+ """
100
+ Fused Muon step: momentum -> polar_express -> variance_reduction -> cautious_update
101
+ All in one compiled graph to eliminate Python overhead between ops.
102
+ Some of the constants are 0-D CPU tensors to avoid recompilation when values change.
103
+ """
104
+
105
+ # Nesterov momentum
106
+ momentum = momentum_t.to(stacked_grads.dtype)
107
+ momentum_buffer.lerp_(stacked_grads, 1 - momentum)
108
+ g = stacked_grads.lerp_(momentum_buffer, momentum)
109
+
110
+ # Polar express
111
+ X = g.bfloat16()
112
+ X = X / (X.norm(dim=(-2, -1), keepdim=True) * 1.02 + 1e-6)
113
+ if g.size(-2) > g.size(-1): # Tall matrix
114
+ for a, b, c in polar_express_coeffs[:ns_steps]:
115
+ A = X.mT @ X
116
+ B = b * A + c * (A @ A)
117
+ X = a * X + X @ B
118
+ else: # Wide matrix (original math)
119
+ for a, b, c in polar_express_coeffs[:ns_steps]:
120
+ A = X @ X.mT
121
+ B = b * A + c * (A @ A)
122
+ X = a * X + B @ X
123
+ g = X
124
+
125
+ # Variance reduction
126
+ beta2 = beta2_t.to(g.dtype)
127
+ v_mean = g.float().square().mean(dim=red_dim, keepdim=True)
128
+ red_dim_size = g.size(red_dim)
129
+ v_norm_sq = v_mean.sum(dim=(-2, -1), keepdim=True) * red_dim_size
130
+ v_norm = v_norm_sq.sqrt()
131
+ second_momentum_buffer.lerp_(v_mean.to(dtype=second_momentum_buffer.dtype), 1 - beta2)
132
+ step_size = second_momentum_buffer.clamp_min(1e-10).rsqrt()
133
+ scaled_sq_sum = (v_mean * red_dim_size) * step_size.float().square()
134
+ v_norm_new = scaled_sq_sum.sum(dim=(-2, -1), keepdim=True).sqrt()
135
+ final_scale = step_size * (v_norm / v_norm_new.clamp_min(1e-10))
136
+ g = g * final_scale.to(g.dtype)
137
+
138
+ # Cautious weight decay + parameter update
139
+ lr = lr_t.to(g.dtype)
140
+ wd = wd_t.to(g.dtype)
141
+ mask = (g * stacked_params) >= 0
142
+ stacked_params.sub_(lr * g + lr * wd * stacked_params * mask)
143
+
144
+ # -----------------------------------------------------------------------------
145
+ # Single GPU version of the MuonAdamW optimizer.
146
+ # Used mostly for reference, debugging and testing.
147
+
148
+ class MuonAdamW(torch.optim.Optimizer):
149
+ """
150
+ Combined optimizer: Muon for 2D matrix params, AdamW for others, single GPU version.
151
+
152
+ AdamW - Fused AdamW optimizer step.
153
+
154
+ Muon - MomentUm Orthogonalized by Newton-schulz
155
+ https://kellerjordan.github.io/posts/muon/
156
+
157
+ Muon internally runs standard SGD-momentum, and then performs an orthogonalization post-
158
+ processing step, in which each 2D parameter's update is replaced with the nearest orthogonal
159
+ matrix. To efficiently orthogonalize each update, we use a Newton-Schulz iteration, which has
160
+ the advantage that it can be stably run in bfloat16 on the GPU.
161
+
162
+ Some warnings:
163
+ - The Muon optimizer should not be used for the embedding layer, the final fully connected layer,
164
+ or any {0,1}-D parameters; those should all be optimized by a standard method (e.g., AdamW).
165
+ - To use it with 4D convolutional filters, it works well to just flatten their last 3 dimensions.
166
+
167
+ Arguments:
168
+ param_groups: List of dicts, each containing:
169
+ - 'params': List of parameters
170
+ - 'kind': 'adamw' or 'muon'
171
+ - For AdamW groups: 'lr', 'betas', 'eps', 'weight_decay'
172
+ - For Muon groups: 'lr', 'momentum', 'ns_steps', 'beta2', 'weight_decay'
173
+ """
174
+ def __init__(self, param_groups: list[dict]):
175
+ super().__init__(param_groups, defaults={})
176
+ # 0-D CPU tensors to avoid torch.compile recompilation when values change
177
+ # AdamW tensors
178
+ self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
179
+ self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
180
+ self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
181
+ self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
182
+ self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
183
+ self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
184
+ # Muon tensors
185
+ self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
186
+ self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
187
+ self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
188
+ self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
189
+
190
+ def _step_adamw(self, group: dict) -> None:
191
+ """
192
+ AdamW update for each param in the group individually.
193
+ Lazy init the state, fill in all 0-D tensors, call the fused kernel.
194
+ """
195
+ for p in group['params']:
196
+ if p.grad is None:
197
+ continue
198
+ grad = p.grad
199
+ state = self.state[p]
200
+
201
+ # State init
202
+ if not state:
203
+ state['step'] = 0
204
+ state['exp_avg'] = torch.zeros_like(p)
205
+ state['exp_avg_sq'] = torch.zeros_like(p)
206
+ exp_avg = state['exp_avg']
207
+ exp_avg_sq = state['exp_avg_sq']
208
+ state['step'] += 1
209
+
210
+ # Fill 0-D tensors with current values
211
+ self._adamw_step_t.fill_(state['step'])
212
+ self._adamw_lr_t.fill_(group['lr'])
213
+ self._adamw_beta1_t.fill_(group['betas'][0])
214
+ self._adamw_beta2_t.fill_(group['betas'][1])
215
+ self._adamw_eps_t.fill_(group['eps'])
216
+ self._adamw_wd_t.fill_(group['weight_decay'])
217
+
218
+ # Fused update: weight_decay -> momentum -> bias_correction -> param_update
219
+ adamw_step_fused(
220
+ p, grad, exp_avg, exp_avg_sq,
221
+ self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
222
+ self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t,
223
+ )
224
+
225
+ def _step_muon(self, group: dict) -> None:
226
+ """
227
+ Muon update for all params in the group (stacked for efficiency).
228
+ Lazy init the state, fill in all 0-D tensors, call the fused kernel.
229
+ """
230
+ params: list[Tensor] = group['params']
231
+ if not params:
232
+ return
233
+
234
+ # Get or create group-level buffers (stored in first param's state for convenience)
235
+ p = params[0]
236
+ state = self.state[p]
237
+ num_params = len(params)
238
+ shape, device, dtype = p.shape, p.device, p.dtype
239
+
240
+ # Momentum for every individual parameter
241
+ if "momentum_buffer" not in state:
242
+ state["momentum_buffer"] = torch.zeros(num_params, *shape, dtype=dtype, device=device)
243
+ momentum_buffer = state["momentum_buffer"]
244
+
245
+ # Second momentum buffer is factored, either per-row or per-column
246
+ if "second_momentum_buffer" not in state:
247
+ state_shape = (num_params, shape[-2], 1) if shape[-2] >= shape[-1] else (num_params, 1, shape[-1])
248
+ state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
249
+ second_momentum_buffer = state["second_momentum_buffer"]
250
+ red_dim = -1 if shape[-2] >= shape[-1] else -2
251
+
252
+ # Stack grads and params (NOTE: this assumes all params have the same shape)
253
+ stacked_grads = torch.stack([p.grad for p in params])
254
+ stacked_params = torch.stack(params)
255
+
256
+ # Fill all the 0-D tensors with current values
257
+ self._muon_momentum_t.fill_(group["momentum"])
258
+ self._muon_beta2_t.fill_(group["beta2"] if group["beta2"] is not None else 0.0)
259
+ self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
260
+ self._muon_wd_t.fill_(group["weight_decay"])
261
+
262
+ # Single fused kernel: momentum -> polar_express -> variance_reduction -> update
263
+ muon_step_fused(
264
+ stacked_grads,
265
+ stacked_params,
266
+ momentum_buffer,
267
+ second_momentum_buffer,
268
+ self._muon_momentum_t,
269
+ self._muon_lr_t,
270
+ self._muon_wd_t,
271
+ self._muon_beta2_t,
272
+ group["ns_steps"],
273
+ red_dim,
274
+ )
275
+
276
+ # Copy back to original params
277
+ torch._foreach_copy_(params, list(stacked_params.unbind(0)))
278
+
279
+ @torch.no_grad()
280
+ def step(self):
281
+ for group in self.param_groups:
282
+ if group['kind'] == 'adamw':
283
+ self._step_adamw(group)
284
+ elif group['kind'] == 'muon':
285
+ self._step_muon(group)
286
+ else:
287
+ raise ValueError(f"Unknown optimizer kind: {group['kind']}")
288
+
289
+ # -----------------------------------------------------------------------------
290
+ # Distributed version of the MuonAdamW optimizer.
291
+ # Used for training on multiple GPUs.
292
+
293
+ class DistMuonAdamW(torch.optim.Optimizer):
294
+ """
295
+ Combined distributed optimizer: Muon for 2D matrix params, AdamW for others.
296
+
297
+ See MuonAdamW for the algorithmic details of each optimizer. This class adds
298
+ distributed communication to enable multi-GPU training without PyTorch DDP.
299
+
300
+ Design Goals:
301
+ - Overlap communication with computation (async ops)
302
+ - Minimize memory by sharding optimizer states across ranks (ZeRO-2 style)
303
+ - Batch small tensors into single comm ops where possible
304
+
305
+ Communication Pattern (3-phase async):
306
+ We use a 3-phase structure to maximize overlap between communication and compute:
307
+
308
+ Phase 1: Launch all async reduce ops
309
+ - Kick off all reduce_scatter/all_reduce operations
310
+ - Don't wait - let them run in background while we continue
311
+
312
+ Phase 2: Wait for reduces, compute updates, launch gathers
313
+ - For each group: wait for its reduce, compute the update, launch gather
314
+ - By processing groups in order, earlier gathers run while later computes happen
315
+
316
+ Phase 3: Wait for gathers, copy back
317
+ - Wait for all gathers to complete
318
+ - Copy updated params back to original tensors (Muon only)
319
+
320
+ AdamW Communication (ZeRO-2 style):
321
+ - Small params (<1024 elements): all_reduce gradients, update full param on each rank.
322
+ Optimizer state is replicated but these params are tiny (scalars, biases).
323
+ - Large params: reduce_scatter gradients so each rank gets 1/N of the grad, update
324
+ only that slice, then all_gather the updated slices. Optimizer state (exp_avg,
325
+ exp_avg_sq) is sharded - each rank only stores state for its slice.
326
+ Requires param.shape[0] divisible by world_size.
327
+
328
+ Muon Communication (stacked + chunked):
329
+ - All params in a Muon group must have the same shape (caller's responsibility).
330
+ - Stack all K params into a single (K, *shape) tensor for efficient comm.
331
+ - Divide K params across N ranks: each rank "owns" ceil(K/N) params.
332
+ - reduce_scatter the stacked grads so each rank gets its chunk.
333
+ - Each rank computes Muon update only for params it owns.
334
+ - all_gather the updated params back to all ranks.
335
+ - Optimizer state (momentum_buffer, second_momentum_buffer) is sharded by chunk.
336
+ - Padding: if K doesn't divide evenly, we zero-pad to (ceil(K/N) * N) for comm,
337
+ then ignore the padding when copying back.
338
+
339
+ Buffer Reuse:
340
+ - For Muon, we allocate stacked_grads for reduce_scatter input, then reuse the
341
+ same buffer as the output for all_gather (stacked_params). This saves memory
342
+ since we don't need both buffers simultaneously.
343
+
344
+ Arguments:
345
+ param_groups: List of dicts, each containing:
346
+ - 'params': List of parameters
347
+ - 'kind': 'adamw' or 'muon'
348
+ - For AdamW groups: 'lr', 'betas', 'eps', 'weight_decay'
349
+ - For Muon groups: 'lr', 'momentum', 'ns_steps', 'beta2', 'weight_decay'
350
+ """
351
+ def __init__(self, param_groups: list[dict]):
352
+ super().__init__(param_groups, defaults={})
353
+ # 0-D CPU tensors to avoid torch.compile recompilation when values change
354
+ self._adamw_step_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
355
+ self._adamw_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
356
+ self._adamw_beta1_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
357
+ self._adamw_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
358
+ self._adamw_eps_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
359
+ self._adamw_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
360
+ self._muon_momentum_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
361
+ self._muon_lr_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
362
+ self._muon_wd_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
363
+ self._muon_beta2_t = torch.tensor(0.0, dtype=torch.float32, device="cpu")
364
+
365
+ def _reduce_adamw(self, group: dict, world_size: int) -> dict:
366
+ """Launch async reduce ops for AdamW group. Returns info dict with per-param infos."""
367
+ param_infos = {}
368
+ for p in group['params']:
369
+ grad = p.grad
370
+ if p.numel() < 1024:
371
+ # Small params: all_reduce (no scatter/gather needed)
372
+ future = dist.all_reduce(grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
373
+ param_infos[p] = dict(future=future, grad_slice=grad, is_small=True)
374
+ else:
375
+ # Large params: reduce_scatter
376
+ rank_size = grad.shape[0] // world_size
377
+ grad_slice = torch.empty_like(grad[:rank_size])
378
+ future = dist.reduce_scatter_tensor(grad_slice, grad, op=dist.ReduceOp.AVG, async_op=True).get_future()
379
+ param_infos[p] = dict(future=future, grad_slice=grad_slice, is_small=False)
380
+ return dict(param_infos=param_infos)
381
+
382
+ def _reduce_muon(self, group: dict, world_size: int) -> dict:
383
+ """Launch async reduce op for Muon group. Returns info dict."""
384
+ params = group['params']
385
+ chunk_size = (len(params) + world_size - 1) // world_size
386
+ padded_num_params = chunk_size * world_size
387
+ p = params[0]
388
+ shape, device, dtype = p.shape, p.device, p.dtype
389
+
390
+ # Stack grads and zero-pad to padded_num_params
391
+ grad_stack = torch.stack([p.grad for p in params])
392
+ stacked_grads = torch.empty(padded_num_params, *shape, dtype=dtype, device=device)
393
+ stacked_grads[:len(params)].copy_(grad_stack)
394
+ if len(params) < padded_num_params:
395
+ stacked_grads[len(params):].zero_()
396
+
397
+ # Reduce_scatter to get this rank's chunk
398
+ grad_chunk = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
399
+ future = dist.reduce_scatter_tensor(grad_chunk, stacked_grads, op=dist.ReduceOp.AVG, async_op=True).get_future()
400
+
401
+ return dict(future=future, grad_chunk=grad_chunk, stacked_grads=stacked_grads, chunk_size=chunk_size)
402
+
403
+ def _compute_adamw(self, group: dict, info: dict, gather_list: list, rank: int, world_size: int) -> None:
404
+ """Wait for reduce, compute AdamW updates, launch gathers for large params."""
405
+ param_infos = info['param_infos']
406
+ for p in group['params']:
407
+ pinfo = param_infos[p]
408
+ pinfo['future'].wait()
409
+ grad_slice = pinfo['grad_slice']
410
+ state = self.state[p]
411
+
412
+ # For small params, operate on full param; for large, operate on slice
413
+ if pinfo['is_small']:
414
+ p_slice = p
415
+ else:
416
+ rank_size = p.shape[0] // world_size
417
+ p_slice = p[rank * rank_size:(rank + 1) * rank_size]
418
+
419
+ # State init
420
+ if not state:
421
+ state['step'] = 0
422
+ state['exp_avg'] = torch.zeros_like(p_slice)
423
+ state['exp_avg_sq'] = torch.zeros_like(p_slice)
424
+ state['step'] += 1
425
+
426
+ # Fill 0-D tensors and run fused kernel
427
+ self._adamw_step_t.fill_(state['step'])
428
+ self._adamw_lr_t.fill_(group['lr'])
429
+ self._adamw_beta1_t.fill_(group['betas'][0])
430
+ self._adamw_beta2_t.fill_(group['betas'][1])
431
+ self._adamw_eps_t.fill_(group['eps'])
432
+ self._adamw_wd_t.fill_(group['weight_decay'])
433
+ adamw_step_fused(
434
+ p_slice, grad_slice, state['exp_avg'], state['exp_avg_sq'],
435
+ self._adamw_step_t, self._adamw_lr_t, self._adamw_beta1_t,
436
+ self._adamw_beta2_t, self._adamw_eps_t, self._adamw_wd_t,
437
+ )
438
+
439
+ # Large params need all_gather
440
+ if not pinfo['is_small']:
441
+ future = dist.all_gather_into_tensor(p, p_slice, async_op=True).get_future()
442
+ gather_list.append(dict(future=future, params=None))
443
+
444
+ def _compute_muon(self, group: dict, info: dict, gather_list: list, rank: int) -> None:
445
+ """Wait for reduce, compute Muon updates, launch gather."""
446
+ info['future'].wait()
447
+ params = group['params']
448
+ chunk_size = info['chunk_size']
449
+ grad_chunk = info['grad_chunk']
450
+ p = params[0]
451
+ shape, device, dtype = p.shape, p.device, p.dtype
452
+
453
+ # How many params does this rank own?
454
+ start_idx = rank * chunk_size
455
+ num_owned = min(chunk_size, max(0, len(params) - start_idx))
456
+
457
+ # Get or create group-level state
458
+ state = self.state[p]
459
+ if "momentum_buffer" not in state:
460
+ state["momentum_buffer"] = torch.zeros(chunk_size, *shape, dtype=dtype, device=device)
461
+ if "second_momentum_buffer" not in state:
462
+ state_shape = (chunk_size, shape[-2], 1) if shape[-2] >= shape[-1] else (chunk_size, 1, shape[-1])
463
+ state["second_momentum_buffer"] = torch.zeros(state_shape, dtype=dtype, device=device)
464
+ red_dim = -1 if shape[-2] >= shape[-1] else -2
465
+
466
+ # Build output buffer for all_gather
467
+ updated_params = torch.empty(chunk_size, *shape, dtype=dtype, device=device)
468
+
469
+ if num_owned > 0:
470
+ owned_params = [params[start_idx + i] for i in range(num_owned)]
471
+ stacked_owned = torch.stack(owned_params)
472
+
473
+ # Fill 0-D tensors and run fused kernel
474
+ self._muon_momentum_t.fill_(group["momentum"])
475
+ self._muon_beta2_t.fill_(group["beta2"])
476
+ self._muon_lr_t.fill_(group["lr"] * max(1.0, shape[-2] / shape[-1])**0.5)
477
+ self._muon_wd_t.fill_(group["weight_decay"])
478
+ muon_step_fused(
479
+ grad_chunk[:num_owned], stacked_owned,
480
+ state["momentum_buffer"][:num_owned], state["second_momentum_buffer"][:num_owned],
481
+ self._muon_momentum_t, self._muon_lr_t, self._muon_wd_t, self._muon_beta2_t,
482
+ group["ns_steps"], red_dim,
483
+ )
484
+ updated_params[:num_owned].copy_(stacked_owned)
485
+
486
+ if num_owned < chunk_size:
487
+ updated_params[num_owned:].zero_()
488
+
489
+ # Reuse stacked_grads buffer for all_gather output
490
+ stacked_params = info["stacked_grads"]
491
+ future = dist.all_gather_into_tensor(stacked_params, updated_params, async_op=True).get_future()
492
+ gather_list.append(dict(future=future, stacked_params=stacked_params, params=params))
493
+
494
+ def _finish_gathers(self, gather_list: list) -> None:
495
+ """Wait for all gathers and copy Muon params back."""
496
+ for info in gather_list:
497
+ info["future"].wait()
498
+ if info["params"] is not None:
499
+ # Muon: copy from stacked buffer back to individual params
500
+ torch._foreach_copy_(info["params"], list(info["stacked_params"][:len(info["params"])].unbind(0)))
501
+
502
+ @torch.no_grad()
503
+ def step(self):
504
+ rank = dist.get_rank()
505
+ world_size = dist.get_world_size()
506
+
507
+ # Phase 1: launch all async reduce ops
508
+ reduce_infos: list[dict] = []
509
+ for group in self.param_groups:
510
+ if group['kind'] == 'adamw':
511
+ reduce_infos.append(self._reduce_adamw(group, world_size))
512
+ elif group['kind'] == 'muon':
513
+ reduce_infos.append(self._reduce_muon(group, world_size))
514
+ else:
515
+ raise ValueError(f"Unknown optimizer kind: {group['kind']}")
516
+
517
+ # Phase 2: wait for reduces, compute updates, launch gathers
518
+ gather_list: list[dict] = []
519
+ for group, info in zip(self.param_groups, reduce_infos):
520
+ if group['kind'] == 'adamw':
521
+ self._compute_adamw(group, info, gather_list, rank, world_size)
522
+ elif group['kind'] == 'muon':
523
+ self._compute_muon(group, info, gather_list, rank)
524
+ else:
525
+ raise ValueError(f"Unknown optimizer kind: {group['kind']}")
526
+
527
+ # Phase 3: wait for gathers, copy back
528
+ self._finish_gathers(gather_list)
scripts/base_train.py CHANGED
@@ -211,9 +211,9 @@ print0(f"Tokens : Scaling params ratio: {args.total_batch_size * num_iterations
211
  print0(f"Total training FLOPs estimate: {num_flops_per_token * total_tokens:e}")
212
 
213
  # -----------------------------------------------------------------------------
214
- # Initialize the Optimizer (Muon for Linear layers, AdamW for embedding and lm_head)
215
  adam_betas = (args.adam_beta1, args.adam_beta2)
216
- optimizers = model.setup_optimizers(
217
  unembedding_lr=args.unembedding_lr * batch_lr_scale,
218
  embedding_lr=args.embedding_lr * batch_lr_scale,
219
  matrix_lr=args.matrix_lr * batch_lr_scale,
@@ -221,12 +221,10 @@ optimizers = model.setup_optimizers(
221
  adam_betas=adam_betas,
222
  scalar_lr=args.scalar_lr * batch_lr_scale,
223
  )
224
- adamw_optimizer, muon_optimizer = optimizers
225
 
226
  if resuming:
227
- for opt, dat in zip(optimizers, optimizer_data):
228
- opt.load_state_dict(dat)
229
- del optimizer_data # free up the memory
230
 
231
  # -----------------------------------------------------------------------------
232
  # Initialize the DataLoaders for train/val
@@ -344,7 +342,7 @@ while True:
344
  checkpoint_dir,
345
  step,
346
  orig_model.state_dict(), # model parameters
347
- [opt.state_dict() for opt in optimizers], # optimizer states
348
  { # metadata saved as json
349
  "step": step,
350
  "val_bpb": val_bpb, # loss at last step
@@ -378,18 +376,16 @@ while True:
378
  loss = loss / grad_accum_steps # each .backward() is a grad sum => normalize loss here
379
  loss.backward()
380
  x, y, dataloader_state_dict = next(train_loader) # prefetch the next batch while the GPU is busy with forward/backward
381
- # step the optimizers
382
  lrm = get_lr_multiplier(step)
383
- for opt in optimizers:
384
- for group in opt.param_groups:
385
- group["lr"] = group["initial_lr"] * lrm
386
  muon_momentum = get_muon_momentum(step)
387
  muon_weight_decay = get_weight_decay(step)
388
- for group in muon_optimizer.param_groups:
389
- group["momentum"] = muon_momentum
390
- group["weight_decay"] = muon_weight_decay
391
- for opt in optimizers:
392
- opt.step()
 
393
  model.zero_grad(set_to_none=True)
394
  train_loss_f = train_loss.item() # .item() is a CPU-GPU sync point
395
  synchronize()
 
211
  print0(f"Total training FLOPs estimate: {num_flops_per_token * total_tokens:e}")
212
 
213
  # -----------------------------------------------------------------------------
214
+ # Initialize the Optimizer (combined MuonAdamW: Muon for matrix params, AdamW for rest)
215
  adam_betas = (args.adam_beta1, args.adam_beta2)
216
+ optimizer = model.setup_optimizer(
217
  unembedding_lr=args.unembedding_lr * batch_lr_scale,
218
  embedding_lr=args.embedding_lr * batch_lr_scale,
219
  matrix_lr=args.matrix_lr * batch_lr_scale,
 
221
  adam_betas=adam_betas,
222
  scalar_lr=args.scalar_lr * batch_lr_scale,
223
  )
 
224
 
225
  if resuming:
226
+ optimizer.load_state_dict(optimizer_data)
227
+ del optimizer_data
 
228
 
229
  # -----------------------------------------------------------------------------
230
  # Initialize the DataLoaders for train/val
 
342
  checkpoint_dir,
343
  step,
344
  orig_model.state_dict(), # model parameters
345
+ optimizer.state_dict(), # optimizer state
346
  { # metadata saved as json
347
  "step": step,
348
  "val_bpb": val_bpb, # loss at last step
 
376
  loss = loss / grad_accum_steps # each .backward() is a grad sum => normalize loss here
377
  loss.backward()
378
  x, y, dataloader_state_dict = next(train_loader) # prefetch the next batch while the GPU is busy with forward/backward
379
+ # step the optimizer
380
  lrm = get_lr_multiplier(step)
 
 
 
381
  muon_momentum = get_muon_momentum(step)
382
  muon_weight_decay = get_weight_decay(step)
383
+ for group in optimizer.param_groups:
384
+ group["lr"] = group["initial_lr"] * lrm
385
+ if group['kind'] == 'muon':
386
+ group["momentum"] = muon_momentum
387
+ group["weight_decay"] = muon_weight_decay
388
+ optimizer.step()
389
  model.zero_grad(set_to_none=True)
390
  train_loss_f = train_loss.item() # .item() is a CPU-GPU sync point
391
  synchronize()
scripts/chat_rl.py CHANGED
@@ -201,7 +201,7 @@ def run_gsm8k_eval(task, tokenizer, engine,
201
  # Training loop
202
 
203
  # Init the optimizer
204
- optimizers = model.setup_optimizers(
205
  unembedding_lr=args.unembedding_lr,
206
  embedding_lr=args.embedding_lr,
207
  matrix_lr=args.matrix_lr,
@@ -209,10 +209,9 @@ optimizers = model.setup_optimizers(
209
  )
210
 
211
  # Set the initial learning rate as a fraction of the base learning rate
212
- for opt in optimizers:
213
- for group in opt.param_groups:
214
- group["lr"] = group["lr"] * args.init_lr_frac
215
- group["initial_lr"] = group["lr"] # save the initial learning so we can decay easily later
216
 
217
  # Learning rate scheduler: simple rampdown to zero over num_steps
218
  def get_lr_multiplier(it):
@@ -305,11 +304,9 @@ for step in range(num_steps):
305
 
306
  # Update the model parameters
307
  lrm = get_lr_multiplier(step)
308
- for opt in optimizers: # first set the learning rate
309
- for group in opt.param_groups:
310
- group["lr"] = group["initial_lr"] * lrm
311
- for opt in optimizers: # then step the optimizers
312
- opt.step()
313
  model.zero_grad(set_to_none=True)
314
  wandb_run.log({
315
  "step": step,
 
201
  # Training loop
202
 
203
  # Init the optimizer
204
+ optimizer = model.setup_optimizer(
205
  unembedding_lr=args.unembedding_lr,
206
  embedding_lr=args.embedding_lr,
207
  matrix_lr=args.matrix_lr,
 
209
  )
210
 
211
  # Set the initial learning rate as a fraction of the base learning rate
212
+ for group in optimizer.param_groups:
213
+ group["lr"] = group["lr"] * args.init_lr_frac
214
+ group["initial_lr"] = group["lr"]
 
215
 
216
  # Learning rate scheduler: simple rampdown to zero over num_steps
217
  def get_lr_multiplier(it):
 
304
 
305
  # Update the model parameters
306
  lrm = get_lr_multiplier(step)
307
+ for group in optimizer.param_groups:
308
+ group["lr"] = group["initial_lr"] * lrm
309
+ optimizer.step()
 
 
310
  model.zero_grad(set_to_none=True)
311
  wandb_run.log({
312
  "step": step,
scripts/chat_sft.py CHANGED
@@ -150,17 +150,16 @@ build_val_loader = lambda: sft_data_generator(val_ds, batch_size=args.device_bat
150
  # -----------------------------------------------------------------------------
151
  # Initialize the Optimizer
152
 
153
- optimizers = model.setup_optimizers(
154
  unembedding_lr=args.unembedding_lr,
155
  embedding_lr=args.embedding_lr,
156
  matrix_lr=args.matrix_lr,
157
  weight_decay=args.weight_decay,
158
  )
159
  # Set the initial learning rate as a fraction of the base learning rate
160
- for opt in optimizers:
161
- for group in opt.param_groups:
162
- group["lr"] = group["lr"] * args.init_lr_frac
163
- group["initial_lr"] = group["lr"] # save the initial learning so we can decay easily later
164
 
165
  # -----------------------------------------------------------------------------
166
  # Training loop
@@ -230,13 +229,11 @@ for step in range(num_iterations):
230
 
231
  # learning rate scheduler
232
  lrm = get_lr_multiplier(step)
233
- for opt in optimizers:
234
- for group in opt.param_groups:
235
- group["lr"] = group["initial_lr"] * lrm
236
 
237
- # step the optimizers
238
- for opt in optimizers:
239
- opt.step()
240
  model.zero_grad(set_to_none=True)
241
 
242
  # logging
 
150
  # -----------------------------------------------------------------------------
151
  # Initialize the Optimizer
152
 
153
+ optimizer = model.setup_optimizer(
154
  unembedding_lr=args.unembedding_lr,
155
  embedding_lr=args.embedding_lr,
156
  matrix_lr=args.matrix_lr,
157
  weight_decay=args.weight_decay,
158
  )
159
  # Set the initial learning rate as a fraction of the base learning rate
160
+ for group in optimizer.param_groups:
161
+ group["lr"] = group["lr"] * args.init_lr_frac
162
+ group["initial_lr"] = group["lr"]
 
163
 
164
  # -----------------------------------------------------------------------------
165
  # Training loop
 
229
 
230
  # learning rate scheduler
231
  lrm = get_lr_multiplier(step)
232
+ for group in optimizer.param_groups:
233
+ group["lr"] = group["initial_lr"] * lrm
 
234
 
235
+ # step the optimizer
236
+ optimizer.step()
 
237
  model.zero_grad(set_to_none=True)
238
 
239
  # logging
scripts/mid_train.py CHANGED
@@ -93,14 +93,12 @@ print0(f"Tokens / micro-batch: {world_tokens_per_fwdbwd:,}")
93
  print0(f"Total batch size {args.total_batch_size:,} => gradient accumulation steps: {grad_accum_steps}")
94
  token_bytes = get_token_bytes(device=device)
95
 
96
- # Initialize the Optimizer (Muon for Linear layers, AdamW for embedding and lm_head)
97
- optimizers = model.setup_optimizers(unembedding_lr=args.unembedding_lr, embedding_lr=args.embedding_lr, matrix_lr=args.matrix_lr, weight_decay=args.weight_decay)
98
- adamw_optimizer, muon_optimizer = optimizers
99
  # Override the initial learning rate as a fraction of the base learning rate
100
- for opt in optimizers:
101
- for group in opt.param_groups:
102
- group["lr"] = group["lr"] * args.init_lr_frac
103
- group["initial_lr"] = group["lr"] # save the initial learning so we can decay easily later
104
 
105
  # Midtraining data mixture and DataLoader
106
  base_dir = get_base_dir()
@@ -274,7 +272,7 @@ while True:
274
  checkpoint_dir,
275
  step,
276
  orig_model.state_dict(),
277
- [opt.state_dict() for opt in optimizers], # TODO: make sure saving across ranks is done correctly
278
  {
279
  "step": step,
280
  "val_bpb": val_bpb, # loss at last step
@@ -306,16 +304,14 @@ while True:
306
  loss.backward()
307
  x, y = next(train_loader) # prefetch the next batch while the GPU is busy with forward/backward
308
  progress = max(progress, approx_progress) # only increase progress monotonically
309
- # step the optimizers
310
  lrm = get_lr_multiplier(progress)
311
- for opt in optimizers:
312
- for group in opt.param_groups:
313
- group["lr"] = group["initial_lr"] * lrm
314
  muon_momentum = get_muon_momentum(step)
315
- for group in muon_optimizer.param_groups:
316
- group["momentum"] = muon_momentum
317
- for opt in optimizers:
318
- opt.step()
 
319
  model.zero_grad(set_to_none=True)
320
  synchronize()
321
  t1 = time.time()
 
93
  print0(f"Total batch size {args.total_batch_size:,} => gradient accumulation steps: {grad_accum_steps}")
94
  token_bytes = get_token_bytes(device=device)
95
 
96
+ # Initialize the Optimizer (combined MuonAdamW: Muon for matrix params, AdamW for rest)
97
+ optimizer = model.setup_optimizer(unembedding_lr=args.unembedding_lr, embedding_lr=args.embedding_lr, matrix_lr=args.matrix_lr, weight_decay=args.weight_decay)
 
98
  # Override the initial learning rate as a fraction of the base learning rate
99
+ for group in optimizer.param_groups:
100
+ group["lr"] = group["lr"] * args.init_lr_frac
101
+ group["initial_lr"] = group["lr"]
 
102
 
103
  # Midtraining data mixture and DataLoader
104
  base_dir = get_base_dir()
 
272
  checkpoint_dir,
273
  step,
274
  orig_model.state_dict(),
275
+ optimizer.state_dict(),
276
  {
277
  "step": step,
278
  "val_bpb": val_bpb, # loss at last step
 
304
  loss.backward()
305
  x, y = next(train_loader) # prefetch the next batch while the GPU is busy with forward/backward
306
  progress = max(progress, approx_progress) # only increase progress monotonically
307
+ # step the optimizer
308
  lrm = get_lr_multiplier(progress)
 
 
 
309
  muon_momentum = get_muon_momentum(step)
310
+ for group in optimizer.param_groups:
311
+ group["lr"] = group["initial_lr"] * lrm
312
+ if group['kind'] == 'muon':
313
+ group["momentum"] = muon_momentum
314
+ optimizer.step()
315
  model.zero_grad(set_to_none=True)
316
  synchronize()
317
  t1 = time.time()