ChrisMcCormick commited on
Commit
412ea12
·
verified ·
1 Parent(s): 162df34

Converter now emits nanochat optimizer shards for an SFT warm-start

Browse files
README.md CHANGED
@@ -79,7 +79,8 @@ logs/
79
  run_full_d24_w8.sh launcher, with the config rationale
80
  baseline_report.md throughput + val bpb vs upstream nanochat
81
  tokenizer/ the 32k vocab these weights were trained on
82
- convert_ckpt_to_nanochat.py DecoderStack capture -> nanochat state_dict
 
83
  ```
84
 
85
  Two capture points: **5568** is the end of the run, **1950** is the last uncooled state —
@@ -147,11 +148,28 @@ design; a load/resume path is future work.
147
 
148
  ### Optimizer state
149
 
150
- Not converted — which matters for one downstream use and not the other.
151
-
152
  **SFT works with the model alone.** nanochat's `chat_sft` builds a fresh optimizer and only
153
  *optionally* warm-starts it; with no optimizer shard present it prints `starting with fresh
154
- optimizer (slightly worse)` and carries on. Nothing above is missing for SFT.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  **Pre-training resume is out of reach**, and it is the dataloader that closes the door, not
157
  the optimizer: nanochat's resume needs a `dataloader_state_dict` to put its tokenizing
@@ -176,17 +194,21 @@ the two AdamW kernels differ in whether the param carries a mantissa, not in mom
176
  `W_O`'s NorMuon reduction axis differs — nanochat infers it from the shape and lands on
177
  `-1` for a square `c_proj`, DecoderStack states `residual_dim = -2` — but at d24 it costs
178
  nothing: polar express returns a ~orthonormal update, and a square orthonormal matrix has
179
- ~uniform neuron norms along either axis, so there is no variance to reduce. The explicit
180
- axis only earns its keep when `n_heads * d_head != d_model`.
 
 
 
181
 
182
- So an SFT warm-start is transferable in principle; the remaining mapping param ordering
183
- and per-rank shardingis written out at the bottom of `convert_ckpt_to_nanochat.py`.
 
184
 
185
  ## Provenance
186
 
187
  Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
188
  converter is committed at
189
- [`2e199ac`](https://github.com/chrisjmccormick/stacks/commit/2e199acd743c6dcdb4d768daae9b73500c9354a8)
190
  (`utils/convert_ckpt_to_nanochat.py`). The training script that produced these weights is
191
  `code/run_full_d24_w8.py` in this repo, which is the run copy of the single-file d24
192
  trainer with three launcher overrides (`micro_batch_tokens` 32768→65536,
 
79
  run_full_d24_w8.sh launcher, with the config rationale
80
  baseline_report.md throughput + val bpb vs upstream nanochat
81
  tokenizer/ the 32k vocab these weights were trained on
82
+ convert_ckpt_to_nanochat.py DecoderStack capture -> nanochat state_dict (+optimizer)
83
+ test_convert_ckpt_to_nanochat.py its test, if you change the converter
84
  ```
85
 
86
  Two capture points: **5568** is the end of the run, **1950** is the last uncooled state —
 
148
 
149
  ### Optimizer state
150
 
 
 
151
  **SFT works with the model alone.** nanochat's `chat_sft` builds a fresh optimizer and only
152
  *optionally* warm-starts it; with no optimizer shard present it prints `starting with fresh
153
+ optimizer (slightly worse)` and carries on.
154
+
155
+ To remove that "slightly worse", add `--world-size N` — the converter then also writes the
156
+ ZeRO-2 shards `optim_005568_rank{0..N-1}.pt` that `DistMuonAdamW` expects, matched to the
157
+ GPU count of the SFT run:
158
+
159
+ ```bash
160
+ python convert_ckpt_to_nanochat.py \
161
+ --model checkpoints/model_step005568.pt \
162
+ --optim checkpoints/optim_step005568.pt \
163
+ --meta base_checkpoints/d24_decoderstack/meta_005568.json \
164
+ --out ~/.cache/nanochat/base_checkpoints/d24_decoderstack \
165
+ --world-size 8
166
+ ```
167
+
168
+ Note that `torch`'s `load_state_dict` replaces param-group dicts wholesale, so the emitted
169
+ `lr`/`betas`/`weight_decay` become the optimizer's on load — they default to
170
+ `setup_optimizer()`'s own values with `weight_decay=0.0` (the SFT setting, and where
171
+ DecoderStack's cosine-to-zero Muon decay lands), and are CLI-overridable. `chat_sft`
172
+ restores its own LRs immediately after loading regardless.
173
 
174
  **Pre-training resume is out of reach**, and it is the dataloader that closes the door, not
175
  the optimizer: nanochat's resume needs a `dataloader_state_dict` to put its tokenizing
 
194
  `W_O`'s NorMuon reduction axis differs — nanochat infers it from the shape and lands on
195
  `-1` for a square `c_proj`, DecoderStack states `residual_dim = -2` — but at d24 it costs
196
  nothing: polar express returns a ~orthonormal update, and a square orthonormal matrix has
197
+ ~uniform neuron norms along either axis, so there is no variance to reduce. The converter
198
+ mean-fills onto nanochat's axis, and *asserts* rather than converting if the bank is not
199
+ square, where the two axes would carry genuinely different information. The explicit axis
200
+ only earns its keep when `n_heads * d_head != d_model` (and, for `ve_gate`, when
201
+ `n_kv_head != d_ve_gate`; both are equalities at d24).
202
 
203
+ **Caveat:** the optimizer path is verified against a real nanochat `GPT` and `MuonAdamW` at
204
+ a toy configstructure, per-rank sharding, shard reassembly, and a real warm-started
205
+ `optimizer.step()` — but has not yet been run against the full d24 capture.
206
 
207
  ## Provenance
208
 
209
  Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
210
  converter is committed at
211
+ [`a5e608f`](https://github.com/chrisjmccormick/stacks/commit/a5e608fac7759aefd5037ca1a985d0165a7d285c)
212
  (`utils/convert_ckpt_to_nanochat.py`). The training script that produced these weights is
213
  `code/run_full_d24_w8.py` in this repo, which is the run copy of the single-file d24
214
  trainer with three launcher overrides (`micro_batch_tokens` 32768→65536,
convert_ckpt_to_nanochat.py CHANGED
@@ -40,22 +40,29 @@
40
  # and 2.4833 without (bpb 0.7252 vs 0.7254). Pass --optim when you want the
41
  # exact master anyway -- it is a bit-exact reconstruction, not an approximation.
42
  #
43
- # WHAT THIS DOES NOT DO
44
- # ---------------------
45
- # It does not convert optimizer state, and nanochat could not resume from it if
46
- # it did -- see OPTIMIZER-STATE NOTES at the bottom of this file.
 
 
 
47
  #
48
  # Usage:
49
  # python utils/convert_ckpt_to_nanochat.py \
50
  # --model checkpoints/model_step005568.pt \
51
  # --optim checkpoints/optim_step005568.pt \
52
  # --meta base_checkpoints/d24_decoderstack/meta_005568.json \
53
- # --out ~/.cache/nanochat/base_checkpoints/d24_decoderstack
 
54
  #
55
  # Then, in nanochat (branch fa-varlen):
56
  # from nanochat.checkpoint_manager import build_model
57
  # model, tokenizer, meta = build_model(checkpoint_dir, 5568, device, "eval")
58
  #
 
 
 
59
  # The tokenizer is NOT interchangeable with other nanochat d24 releases -- see
60
  # the model card. DecoderStack trained on the 32k vocab shipped with the
61
  # ChrisMcCormick/climbmix_32k_8_170 dataset repo; pairing these weights with a
@@ -85,6 +92,42 @@ def fp32_master(live: torch.Tensor, mantissa: torch.Tensor | None) -> torch.Tens
85
  return bits.view(torch.float32)
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def ve_layers(n_layer: int) -> list[int]:
89
  """Layers carrying a value embedding, in bank-slot order.
90
 
@@ -149,6 +192,123 @@ def convert(model_data: dict, optim_state: dict | None) -> dict:
149
  return sd
150
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  def main():
153
  p = argparse.ArgumentParser(description=__doc__)
154
  p.add_argument("--model", required=True, help="DecoderStack model_stepNNNNNN.pt")
@@ -159,14 +319,31 @@ def main():
159
  p.add_argument("--meta", default=None, help="meta_NNNNNN.json to copy alongside the model")
160
  p.add_argument("--dump-code", action="store_true",
161
  help="also write the training script embedded in the capture's `code` field")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  args = p.parse_args()
 
 
163
 
164
  # mmap so a 2.8 GB model / 11 GB optimizer file is paged, not slurped.
165
  model_data = torch.load(args.model, map_location="cpu", mmap=True, weights_only=True)
166
  step = model_data["step"]
167
  print(f"loaded {args.model}: step {step}, {len(model_data['weights'])} weights")
168
 
169
- optim_state = None
170
  if args.optim:
171
  optim_data = torch.load(args.optim, map_location="cpu", mmap=True, weights_only=True)
172
  assert optim_data["step"] == step, f"optim step {optim_data['step']} != model step {step}"
@@ -200,6 +377,25 @@ def main():
200
  print(f"NOTE: nanochat also needs meta_{step:06d}.json in {args.out} "
201
  "(model_config lives there, not in the .pt)")
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  if args.dump_code:
204
  code_path = os.path.join(args.out, f"code_{step:06d}.py")
205
  with open(code_path, "w", encoding="utf-8", newline="\n") as f:
@@ -214,22 +410,29 @@ if __name__ == "__main__":
214
  # -----------------------------------------------------------------------------
215
  # OPTIMIZER-STATE NOTES
216
  # -----------------------------------------------------------------------------
217
- # Optimizer state is not converted -- but check which downstream use you have
218
- # before assuming that blocks you, because the two land very differently.
219
- #
220
  # SFT CONTINUATION WORKS WITH THE MODEL ALONE. nanochat's chat_sft builds a fresh
221
  # optimizer via model.setup_optimizer() and only optionally warm-starts it from
222
  # load_optimizer_state(); when the shard is absent it prints "optimizer
223
  # checkpoint not found, starting with fresh optimizer (slightly worse)" and
224
- # carries on. The conversion above is all SFT needs.
 
225
  #
226
- # PRE-TRAINING RESUME is the one that is genuinely out of reach, and it is the
227
- # dataloader that closes the door, not the optimizer: nanochat's resume needs
228
  # meta_data["dataloader_state_dict"] to put its tokenizing loader back in the
229
  # stream, and DecoderStack reads pre-tokenized binary shards through a loader
230
  # with no equivalent state to hand over. The data order could not be continued no
231
  # matter what the optimizer held.
232
  #
 
 
 
 
 
 
 
 
 
233
  # Every buffer we keep has a nanochat counterpart, and the precisions line up on
234
  # everything except the two embedding tables:
235
  #
@@ -266,12 +469,20 @@ if __name__ == "__main__":
266
  # d_model, W_O looks like an MLP projection and the shape heuristic happens to
267
  # agree; below it the heuristic picks the wrong axis, and since W_O stores its
268
  # heads transposed relative to QKV, the right answer is not one a shape alone
269
- # can give. Every other bank agrees either way (W_in -1, W_out -2, QKV -1,
270
- # ve_gate -1).
 
 
 
 
 
 
 
 
 
271
  #
272
- # So for an SFT warm-start the state is transferable in principle. The remaining
273
- # mapping is mechanical but fiddly, because nanochat's state_dict is keyed by
274
- # flattened param INDEX and is sharded per rank:
275
  # - Param order is setup_optimizer()'s group order: lm_head, wte,
276
  # value_embeds.*, resid_lambdas, x0_lambdas, [smear_gate.weight,
277
  # smear_lambda, backout_lambda], then the Muon groups in `sorted({shapes})`
 
40
  # and 2.4833 without (bpb 0.7252 vs 0.7254). Pass --optim when you want the
41
  # exact master anyway -- it is a bit-exact reconstruction, not an approximation.
42
  #
43
+ # OPTIMIZER STATE
44
+ # ---------------
45
+ # --world-size N additionally writes optim_NNNNNN_rank{0..N-1}.pt, the ZeRO-2
46
+ # shards nanochat's DistMuonAdamW expects, so chat_sft can warm-start its
47
+ # optimizer instead of printing "starting with fresh optimizer (slightly worse)".
48
+ # It is optional: SFT is correct without it. Pre-training resume is out of reach
49
+ # either way -- see OPTIMIZER-STATE NOTES at the bottom of this file.
50
  #
51
  # Usage:
52
  # python utils/convert_ckpt_to_nanochat.py \
53
  # --model checkpoints/model_step005568.pt \
54
  # --optim checkpoints/optim_step005568.pt \
55
  # --meta base_checkpoints/d24_decoderstack/meta_005568.json \
56
+ # --out ~/.cache/nanochat/base_checkpoints/d24_decoderstack \
57
+ # --world-size 8 # optional: also emit the optimizer shards
58
  #
59
  # Then, in nanochat (branch fa-varlen):
60
  # from nanochat.checkpoint_manager import build_model
61
  # model, tokenizer, meta = build_model(checkpoint_dir, 5568, device, "eval")
62
  #
63
+ # Verify a change to this file with:
64
+ # NANOCHAT_PATH=~/nanochat python utils/test_convert_ckpt_to_nanochat.py
65
+ #
66
  # The tokenizer is NOT interchangeable with other nanochat d24 releases -- see
67
  # the model card. DecoderStack trained on the 32k vocab shipped with the
68
  # ChrisMcCormick/climbmix_32k_8_170 dataset repo; pairing these weights with a
 
92
  return bits.view(torch.float32)
93
 
94
 
95
+ def _adamw_groups(ve_slots: int):
96
+ """The AdamW half of setup_optimizer()'s group list, in its exact order.
97
+
98
+ Each entry is (lr_key, [(bank_name, bank_slot), ...], betas, eps, weight_decay).
99
+ bank_slot is None for a whole tensor, an int to index a bank's dim 0. The
100
+ betas/eps/wd here are constants in BOTH codebases -- nanochat hardcodes them
101
+ in setup_optimizer, DecoderStack passes the same numbers to build_schedules --
102
+ so they are not a guess about this run, they are the shared values.
103
+ """
104
+ return [
105
+ ("lm_head", [("lm_head", None)], (0.8, 0.96), 1e-10, 0.01),
106
+ ("embedding", [("input_embeds", None)], (0.8, 0.995), 1e-10, 0.001),
107
+ ("value_embeds", [("value_embeds", j) for j in range(ve_slots)],
108
+ (0.8, 0.995), 1e-10, 0.01),
109
+ ("resid", [("resid_lambdas", None)], (0.8, 0.95), 1e-10, 0.05),
110
+ ("x0", [("x0_lambdas", None)], (0.96, 0.95), 1e-10, 0.0),
111
+ ("smear", [("smear_gate", None), ("smear_lambda", None),
112
+ ("backout_lambda", None)], (0.8, 0.95), 1e-10, 0.0),
113
+ ]
114
+
115
+
116
+ def _matrix_params(n_layer: int, ve: list[int]):
117
+ """setup_optimizer()'s `matrix_params`, in list(transformer.h.parameters()) order.
118
+
119
+ Module registration order gives, per block: attn.c_q, c_k, c_v, c_proj,
120
+ [ve_gate], then mlp.c_fc, mlp.c_proj.
121
+ """
122
+ out = []
123
+ for i in range(n_layer):
124
+ out += [("W_Q", i), ("W_K", i), ("W_V", i), ("W_O", i)]
125
+ if i in ve:
126
+ out.append(("ve_gate", ve.index(i))) # ve_gate banks by SLOT, not layer
127
+ out += [("W_in", i), ("W_out", i)]
128
+ return out
129
+
130
+
131
  def ve_layers(n_layer: int) -> list[int]:
132
  """Layers carrying a value embedding, in bank-slot order.
133
 
 
192
  return sd
193
 
194
 
195
+ def convert_optimizer(model_data: dict, optim_data: dict, world_size: int, rank: int,
196
+ lrs: dict) -> dict:
197
+ """DecoderStack optimizer capture -> one rank's nanochat optimizer state_dict.
198
+
199
+ nanochat's state_dict is keyed by flattened param INDEX over setup_optimizer()'s
200
+ groups, and DistMuonAdamW shards that state per rank. Our capture all-gathered
201
+ everything to full size, so this is re-slicing, not reconstruction.
202
+
203
+ Returns the dict to torch.save as optim_NNNNNN_rank{rank}.pt. Call once per rank
204
+ rather than building them all: at d24/world=8 each shard is ~1 GB.
205
+ """
206
+ w, st = model_data["weights"], optim_data["state"]
207
+ t_step = optim_data["t_step"]
208
+ n_layer = w["W_Q"].shape[0]
209
+ ve = ve_layers(n_layer)
210
+ n_embd = w["input_embeds"].shape[1]
211
+ d_scale = (n_embd / 768) ** -0.5 # setup_optimizer's 1/sqrt(dmodel) AdamW LR scale
212
+
213
+ def bank(name, slot, attr):
214
+ """One param's full-size optimizer state. value_embeds is the odd one out:
215
+ its AdamW state is shaped over the FLATTENED (slot * vocab) row axis, so it
216
+ has to be folded back to 3-D before a slot can be indexed."""
217
+ t = st[f"{name}.{attr}"]
218
+ if slot is None:
219
+ return t
220
+ if name == "value_embeds":
221
+ return t.view(len(ve), -1, t.shape[-1])[slot]
222
+ return t[slot]
223
+
224
+ adamw_lr = {
225
+ "lm_head": lrs["unembedding_lr"] * d_scale,
226
+ "embedding": lrs["embedding_lr"] * d_scale,
227
+ "value_embeds": lrs["embedding_lr"] * d_scale * 0.5,
228
+ "resid": lrs["scalar_lr"] * 0.01,
229
+ "x0": lrs["scalar_lr"],
230
+ "smear": 0.2, # hardcoded in setup_optimizer, not scaled
231
+ }
232
+
233
+ # --- Build the group plan exactly as setup_optimizer() would: AdamW groups in
234
+ # a fixed order, then Muon groups keyed by `sorted({p.shape})`. ---
235
+ plan = [] # (kind, [(name, slot), ...], hyperparams dict)
236
+ for lr_key, params, betas, eps, wd in _adamw_groups(len(ve)):
237
+ plan.append(("adamw", params, dict(kind="adamw", lr=adamw_lr[lr_key],
238
+ betas=list(betas), eps=eps, weight_decay=wd)))
239
+ matrix = _matrix_params(n_layer, ve)
240
+ shape_of = lambda p: tuple(w[p[0]].shape[1:])
241
+ for shape in sorted({shape_of(p) for p in matrix}):
242
+ plan.append(("muon", [p for p in matrix if shape_of(p) == shape],
243
+ dict(kind="muon", lr=lrs["matrix_lr"], momentum=0.95, ns_steps=5,
244
+ beta2=0.9, weight_decay=lrs["weight_decay"])))
245
+
246
+ # Param indices are assigned by walking the groups in order.
247
+ index, idx = {}, 0
248
+ for _, params, _ in plan:
249
+ for p in params:
250
+ index[p] = idx
251
+ idx += 1
252
+ assert idx == 7 + len(ve) + len(matrix), f"param count {idx} does not add up"
253
+
254
+ state, groups = {}, []
255
+ for kind, params, hp in plan:
256
+ groups.append({**hp, "initial_lr": hp["lr"],
257
+ "params": [index[p] for p in params]})
258
+ if kind == "adamw":
259
+ for p in params:
260
+ exp_avg = bank(*p, "exp_avg")
261
+ # ZeRO-2: params with >= 1024 elements are row-sharded over dim 0 by
262
+ # rank; smaller ones are replicated (nanochat batches those into an
263
+ # all_reduce instead of a reduce_scatter).
264
+ if exp_avg.numel() >= 1024:
265
+ assert exp_avg.shape[0] % world_size == 0, \
266
+ f"{p}: dim 0 ({exp_avg.shape[0]}) must divide world_size {world_size}"
267
+ rows = exp_avg.shape[0] // world_size
268
+ cut = lambda t, n=rows: t[rank * n:(rank + 1) * n].clone()
269
+ else:
270
+ cut = lambda t: t.clone()
271
+ state[index[p]] = {
272
+ "step": t_step,
273
+ "exp_avg": cut(exp_avg),
274
+ "exp_avg_sq": cut(bank(*p, "exp_avg_sq")),
275
+ }
276
+ else:
277
+ # Muon state is one stacked buffer per GROUP, held under the first
278
+ # param's entry, chunked across ranks and zero-padded when the group
279
+ # does not divide evenly.
280
+ shape = shape_of(params[0])
281
+ chunk = -(-len(params) // world_size)
282
+ start = rank * chunk
283
+ owned = min(chunk, max(0, len(params) - start))
284
+ mom = torch.zeros(chunk, *shape, dtype=torch.float32)
285
+ # nanochat factors the second moment along whichever axis its shape
286
+ # heuristic calls the neuron axis; ours is set explicitly per bank.
287
+ nc_shape = (shape[-2], 1) if shape[-2] >= shape[-1] else (1, shape[-1])
288
+ snd = torch.zeros(chunk, *nc_shape, dtype=torch.float32)
289
+ for k in range(owned):
290
+ p = params[start + k]
291
+ mom[k] = bank(*p, "frst_mntm")
292
+ ours = bank(*p, "scnd_mntm")
293
+ if tuple(ours.shape) == nc_shape:
294
+ snd[k] = ours
295
+ else:
296
+ # Only W_O lands here, and only because nanochat's shape
297
+ # heuristic picks the other axis on a SQUARE c_proj. That is
298
+ # benign: polar express returns a ~orthonormal update, whose
299
+ # neuron norms are ~uniform along either axis, so their mean is
300
+ # the right common value. On a non-square bank the two axes
301
+ # would carry genuinely different information -- refuse.
302
+ assert shape[-2] == shape[-1], (
303
+ f"{p}: second-moment axis differs on a non-square bank "
304
+ f"{shape} (ours {tuple(ours.shape)}, nanochat {nc_shape}); "
305
+ "no faithful conversion exists")
306
+ snd[k] = ours.mean()
307
+ state[index[params[0]]] = {"momentum_buffer": mom,
308
+ "second_momentum_buffer": snd}
309
+ return {"state": state, "param_groups": groups}
310
+
311
+
312
  def main():
313
  p = argparse.ArgumentParser(description=__doc__)
314
  p.add_argument("--model", required=True, help="DecoderStack model_stepNNNNNN.pt")
 
319
  p.add_argument("--meta", default=None, help="meta_NNNNNN.json to copy alongside the model")
320
  p.add_argument("--dump-code", action="store_true",
321
  help="also write the training script embedded in the capture's `code` field")
322
+ p.add_argument("--world-size", type=int, default=0, metavar="N",
323
+ help="also write optim_NNNNNN_rank{0..N-1}.pt for an N-GPU run "
324
+ "(requires --optim). Omit to convert weights only.")
325
+ # Group hyperparameters for the emitted optimizer. torch's load_state_dict
326
+ # REPLACES param_group dicts with the saved ones, so whatever goes here becomes
327
+ # the optimizer's policy on load. Defaults are setup_optimizer()'s own, with
328
+ # weight_decay=0.0 -- both the SFT setting and where DecoderStack's cosine-to-
329
+ # zero Muon decay actually lands (4.8e-9 at step 5568). nanochat's chat_sft
330
+ # restores its own lr right after loading and schedules momentum per step, so
331
+ # in practice only betas/eps/weight_decay/ns_steps come from here.
332
+ p.add_argument("--unembedding-lr", type=float, default=0.004)
333
+ p.add_argument("--embedding-lr", type=float, default=0.2)
334
+ p.add_argument("--matrix-lr", type=float, default=0.02)
335
+ p.add_argument("--scalar-lr", type=float, default=0.5)
336
+ p.add_argument("--weight-decay", type=float, default=0.0)
337
  args = p.parse_args()
338
+ if args.world_size and not args.optim:
339
+ p.error("--world-size needs --optim (the optimizer state lives in that file)")
340
 
341
  # mmap so a 2.8 GB model / 11 GB optimizer file is paged, not slurped.
342
  model_data = torch.load(args.model, map_location="cpu", mmap=True, weights_only=True)
343
  step = model_data["step"]
344
  print(f"loaded {args.model}: step {step}, {len(model_data['weights'])} weights")
345
 
346
+ optim_data = optim_state = None
347
  if args.optim:
348
  optim_data = torch.load(args.optim, map_location="cpu", mmap=True, weights_only=True)
349
  assert optim_data["step"] == step, f"optim step {optim_data['step']} != model step {step}"
 
377
  print(f"NOTE: nanochat also needs meta_{step:06d}.json in {args.out} "
378
  "(model_config lives there, not in the .pt)")
379
 
380
+ if args.world_size:
381
+ lrs = dict(unembedding_lr=args.unembedding_lr, embedding_lr=args.embedding_lr,
382
+ matrix_lr=args.matrix_lr, scalar_lr=args.scalar_lr,
383
+ weight_decay=args.weight_decay)
384
+ # One rank at a time -- holding all of them would cost the whole optimizer.
385
+ for r in range(args.world_size):
386
+ shard = convert_optimizer(model_data, optim_data, args.world_size, r, lrs)
387
+ path = os.path.join(args.out, f"optim_{step:06d}_rank{r:d}.pt")
388
+ torch.save(shard, path)
389
+ print(f"wrote {path} ({os.path.getsize(path):,} bytes)")
390
+ if r == 0:
391
+ g = shard["param_groups"]
392
+ print(f" {len(g)} groups "
393
+ f"({sum(1 for x in g if x['kind'] == 'adamw')} adamw / "
394
+ f"{sum(1 for x in g if x['kind'] == 'muon')} muon), "
395
+ f"{sum(len(x['params']) for x in g)} params, "
396
+ f"step {optim_data['t_step']}")
397
+ del shard
398
+
399
  if args.dump_code:
400
  code_path = os.path.join(args.out, f"code_{step:06d}.py")
401
  with open(code_path, "w", encoding="utf-8", newline="\n") as f:
 
410
  # -----------------------------------------------------------------------------
411
  # OPTIMIZER-STATE NOTES
412
  # -----------------------------------------------------------------------------
 
 
 
413
  # SFT CONTINUATION WORKS WITH THE MODEL ALONE. nanochat's chat_sft builds a fresh
414
  # optimizer via model.setup_optimizer() and only optionally warm-starts it from
415
  # load_optimizer_state(); when the shard is absent it prints "optimizer
416
  # checkpoint not found, starting with fresh optimizer (slightly worse)" and
417
+ # carries on. --world-size exists to remove that "slightly worse", not to unlock
418
+ # anything.
419
  #
420
+ # PRE-TRAINING RESUME is genuinely out of reach, and it is the dataloader that
421
+ # closes the door, not the optimizer: nanochat's resume needs
422
  # meta_data["dataloader_state_dict"] to put its tokenizing loader back in the
423
  # stream, and DecoderStack reads pre-tokenized binary shards through a loader
424
  # with no equivalent state to hand over. The data order could not be continued no
425
  # matter what the optimizer held.
426
  #
427
+ # HYPERPARAMETERS ARE POLICY, NOT STATE. torch's Optimizer.load_state_dict
428
+ # REPLACES each param_group dict with the saved one, keeping only 'params' -- so
429
+ # whatever this script writes becomes the optimizer's lr/betas/wd on load. That
430
+ # is why chat_sft saves and restores its own LRs around the call. The emitted
431
+ # groups use setup_optimizer()'s defaults (overridable on the command line), with
432
+ # weight_decay=0.0: both the SFT setting and where DecoderStack's cosine-to-zero
433
+ # Muon decay actually lands (4.8e-9 at step 5568). betas/eps/adamw-wd are not a
434
+ # guess -- they are identical constants in both codebases.
435
+ #
436
  # Every buffer we keep has a nanochat counterpart, and the precisions line up on
437
  # everything except the two embedding tables:
438
  #
 
469
  # d_model, W_O looks like an MLP projection and the shape heuristic happens to
470
  # agree; below it the heuristic picks the wrong axis, and since W_O stores its
471
  # heads transposed relative to QKV, the right answer is not one a shape alone
472
+ # can give. Every other bank agrees at d24 (W_in -1, W_out -2, QKV -1).
473
+ #
474
+ # ve_gate is the second place the axes can diverge, and it is worth knowing about
475
+ # because it is NOT square in general. DecoderStack banks it (num_ves, n_kv_heads,
476
+ # d_ve_gate) with residual_dim = -1, so the neurons are the n_kv_heads rows;
477
+ # nanochat's Linear(ve_gate_channels=12, n_kv_head) hits the same heuristic and
478
+ # agrees only when n_kv_head >= 12. At d24 n_kv_head == 12 == d_ve_gate, so the
479
+ # bank is square and the two land together. A model with fewer than 12 KV heads
480
+ # would disagree for real -- convert_optimizer() asserts rather than papering
481
+ # over it, since outside the square case the two axes carry different
482
+ # information.
483
  #
484
+ # --world-size implements the mapping below. It is mechanical but fiddly, because
485
+ # nanochat's state_dict is keyed by flattened param INDEX and is sharded per rank:
 
486
  # - Param order is setup_optimizer()'s group order: lm_head, wte,
487
  # value_embeds.*, resid_lambdas, x0_lambdas, [smear_gate.weight,
488
  # smear_lambda, backout_lambda], then the Muon groups in `sorted({shapes})`
test_convert_ckpt_to_nanochat.py ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Self-contained test for convert_ckpt_to_nanochat.py. No pytest, no fixtures:
2
+ #
3
+ # NANOCHAT_PATH=~/nanochat python utils/test_convert_ckpt_to_nanochat.py
4
+ #
5
+ # Builds a synthetic DecoderStack capture (weights + optimizer state, with real
6
+ # mantissas) at a toy config, converts it, and checks the result against a REAL
7
+ # nanochat GPT and a REAL MuonAdamW -- so the assertions are about nanochat's
8
+ # actual behaviour, not a second copy of my assumptions about it.
9
+ #
10
+ # The toy config deliberately mirrors d24's SHAPE RELATIONSHIPS rather than just
11
+ # being small: n_head * head_dim == n_embd (W_O square) and n_kv_head == 12 ==
12
+ # d_ve_gate (ve_gate square). Those are exactly the conditions under which the two
13
+ # codebases' NorMuon second-moment axes agree; a config that breaks either is
14
+ # meant to be REFUSED, and the last check covers that.
15
+ #
16
+ # Requires a nanochat checkout on the fa-varlen branch (or any branch whose GPT
17
+ # uses the modular transformer.h.N.* layout).
18
+ import math
19
+ import os
20
+ import sys
21
+
22
+ import torch
23
+
24
+ _NC = os.environ.get("NANOCHAT_PATH")
25
+ if not _NC or not os.path.isdir(os.path.join(os.path.expanduser(_NC), "nanochat")):
26
+ sys.exit("set NANOCHAT_PATH to a nanochat checkout, e.g. NANOCHAT_PATH=~/nanochat")
27
+ sys.path.insert(0, os.path.expanduser(_NC))
28
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+
30
+ # COMPUTE_DTYPE auto-detects to fp32 with no CUDA; force the bf16 a GPU box would
31
+ # pick, so the embedding dtypes we compare against are the ones a real load wants.
32
+ os.environ["NANOCHAT_DTYPE"] = "bfloat16"
33
+ # nanochat's fused kernels are @torch.compile'd and inductor's CPU backend wants a
34
+ # host compiler. Run them eager -- the bodies are plain PyTorch, so the shapes and
35
+ # the math are still exercised, just unfused.
36
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
37
+
38
+ from nanochat.gpt import GPT, GPTConfig # noqa: E402
39
+ from convert_ckpt_to_nanochat import (convert, convert_optimizer, # noqa: E402
40
+ ve_layers, _matrix_params)
41
+
42
+ NL, DM, NH, HD, V = 8, 192, 12, 16, 384
43
+ KV, MLP = NH * HD, 4 * DM
44
+ VE, D_VE_GATE, D_SMR = ve_layers(NL), 12, 24
45
+ NVE = len(VE)
46
+ LRS = dict(unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, scalar_lr=0.5,
47
+ weight_decay=0.0)
48
+
49
+ upper = lambda x: (x.contiguous().view(torch.int32) >> 16).to(torch.int16).view(torch.bfloat16)
50
+ lower = lambda x: (x.contiguous().view(torch.int32)).to(torch.int16).view(torch.uint16)
51
+
52
+ torch.manual_seed(0)
53
+
54
+ # ---------------------------------------------------------------- synthetic capture
55
+ BANKS = {
56
+ "input_embeds": (V, DM), "value_embeds": (NVE, V, KV), "lm_head": (V, DM),
57
+ "W_Q": (NL, KV, DM), "W_K": (NL, KV, DM), "W_V": (NL, KV, DM), "W_O": (NL, DM, KV),
58
+ "W_in": (NL, MLP, DM), "W_out": (NL, DM, MLP), "ve_gate": (NVE, NH, D_VE_GATE),
59
+ }
60
+ fp32_src, weights, state = {}, {}, {}
61
+ for name, shape in BANKS.items(): # bf16 live + uint16 mantissa
62
+ t = torch.randn(*shape)
63
+ fp32_src[name], weights[name], state[f"{name}.mantissa"] = t, upper(t), lower(t)
64
+ for name, shape in [("resid_lambdas", (NL,)), ("x0_lambdas", (NL,)),
65
+ ("smear_gate", (1, D_SMR)), ("smear_lambda", (1,)),
66
+ ("backout_lambda", (1,))]: # fp32 live, no mantissa
67
+ weights[name] = torch.randn(*shape)
68
+
69
+ # AdamW moments: param-shaped, except value_embeds, which the real capture holds
70
+ # over the FLATTENED (slot * vocab) row axis. Every element gets a distinct value
71
+ # so a misrouted or misaligned shard is loud, but the SCALE stays realistic --
72
+ # with a second moment of ~1e7 the resulting update lands below fp32 epsilon and
73
+ # the "every param moved" check below fails on the (1,) scalars for reasons that
74
+ # have nothing to do with the conversion.
75
+ for t_id, (name, shape) in enumerate([("input_embeds", (V, DM)), ("lm_head", (V, DM)),
76
+ ("value_embeds", (NVE * V, KV)),
77
+ ("resid_lambdas", (NL,)), ("x0_lambdas", (NL,)),
78
+ ("smear_gate", (1, D_SMR)), ("smear_lambda", (1,)),
79
+ ("backout_lambda", (1,))]):
80
+ for a_id, attr in enumerate(("exp_avg", "exp_avg_sq")):
81
+ n = math.prod(shape)
82
+ base = (t_id * 2 + a_id) * n
83
+ state[f"{name}.{attr}"] = (
84
+ (torch.arange(n, dtype=torch.float32) + base) * 1e-7).reshape(*shape)
85
+ # Muon moments. The factored second moment follows DecoderStack's EXPLICIT
86
+ # residual_dim: -1 for QKV / W_in / ve_gate, -2 for W_O / W_out.
87
+ SND = {"W_Q": (NL, KV, 1), "W_K": (NL, KV, 1), "W_V": (NL, KV, 1), "W_O": (NL, 1, KV),
88
+ "W_in": (NL, MLP, 1), "W_out": (NL, 1, MLP), "ve_gate": (NVE, NH, 1)}
89
+ for name, shape in SND.items():
90
+ b = BANKS[name]
91
+ state[f"{name}.frst_mntm"] = torch.stack(
92
+ [torch.full(b[1:], float(i + 1)) for i in range(b[0])])
93
+ state[f"{name}.scnd_mntm"] = torch.stack(
94
+ [torch.full(shape[1:], float(i + 1) * 0.5) for i in range(shape[0])])
95
+
96
+ model_data = {"step": 42, "code": "# toy\n", "weights": weights}
97
+ optim_data = {"step": 42, "t_step": 42, "state": state}
98
+
99
+ cfg = GPTConfig(sequence_len=256, vocab_size=V, n_layer=NL, n_head=NH, n_kv_head=NH,
100
+ n_embd=DM, window_pattern="SSSL")
101
+ new_opt = lambda m: m.setup_optimizer(unembedding_lr=LRS["unembedding_lr"],
102
+ embedding_lr=LRS["embedding_lr"],
103
+ matrix_lr=LRS["matrix_lr"],
104
+ weight_decay=LRS["weight_decay"],
105
+ scalar_lr=LRS["scalar_lr"])
106
+
107
+ # ---------------------------------------------------------------- 1. weights
108
+ with torch.device("meta"):
109
+ ref = GPT(cfg)
110
+ ref_sd = ref.state_dict()
111
+ for tag, mant in [("with mantissas", state), ("model only", None)]:
112
+ sd = convert(model_data, mant)
113
+ assert set(sd) == set(ref_sd), (f"key mismatch: missing "
114
+ f"{sorted(set(ref_sd) - set(sd))[:4]}, extra "
115
+ f"{sorted(set(sd) - set(ref_sd))[:4]}")
116
+ with torch.device("meta"):
117
+ model = GPT(cfg)
118
+ model.to_empty(device="cpu")
119
+ model.init_weights()
120
+ want_dtype = {k: v.dtype for k, v in model.state_dict().items()}
121
+ model.load_state_dict(sd, strict=True, assign=True) # strict: shapes + names
122
+ bad = {k: (sd[k].dtype, want_dtype[k]) for k in sd if sd[k].dtype != want_dtype[k]}
123
+ assert not bad, f"dtype mismatch vs a fresh model: {bad}"
124
+ for key, src, idx in [("lm_head.weight", "lm_head", None),
125
+ ("transformer.h.2.attn.c_q.weight", "W_Q", 2),
126
+ ("transformer.h.2.attn.c_proj.weight", "W_O", 2),
127
+ ("transformer.h.3.mlp.c_fc.weight", "W_in", 3),
128
+ (f"transformer.h.{VE[1]}.attn.ve_gate.weight", "ve_gate", 1)]:
129
+ full = fp32_src[src] if idx is None else fp32_src[src][idx]
130
+ want = full if mant else (upper(fp32_src[src]) if idx is None
131
+ else upper(fp32_src[src])[idx]).float()
132
+ assert torch.equal(sd[key], want), f"{tag}: {key} value mismatch"
133
+ for key, src, idx in [("transformer.wte.weight", "input_embeds", None),
134
+ (f"value_embeds.{VE[0]}.weight", "value_embeds", 0)]:
135
+ want = weights[src] if idx is None else weights[src][idx]
136
+ assert torch.equal(sd[key], want), f"{tag}: {key} should be the bf16 live weight"
137
+ print(f"[OK] weights, {tag}: {len(sd)} tensors, strict load, values verified")
138
+
139
+ ve_keys = sorted(k for k in ref_sd if k.endswith("ve_gate.weight"))
140
+ assert ve_keys == [f"transformer.h.{i}.attn.ve_gate.weight" for i in VE], ve_keys
141
+ print(f"[OK] ve slot->layer map: slots 0..{NVE - 1} -> layers {VE}")
142
+
143
+ # ---------------------------------------------------------------- 2. optimizer
144
+ ref_opt = new_opt(model)
145
+ param_key = {id(p): k for k, p in model.named_parameters()}
146
+ flat = [p for g in ref_opt.param_groups for p in g["params"]]
147
+ index_of_key = {param_key[id(p)]: i for i, p in enumerate(flat)}
148
+ print(f"reference optimizer: {len(ref_opt.param_groups)} groups, {len(flat)} params")
149
+
150
+ ROLE = {"W_Q": "attn.c_q", "W_K": "attn.c_k", "W_V": "attn.c_v", "W_O": "attn.c_proj",
151
+ "W_in": "mlp.c_fc", "W_out": "mlp.c_proj"}
152
+ def key_of(entry):
153
+ name, slot = entry
154
+ return {"lm_head": "lm_head.weight", "input_embeds": "transformer.wte.weight",
155
+ "smear_gate": "smear_gate.weight"}.get(name) \
156
+ or (f"value_embeds.{VE[slot]}.weight" if name == "value_embeds"
157
+ else f"transformer.h.{VE[slot]}.attn.ve_gate.weight" if name == "ve_gate"
158
+ else name if slot is None
159
+ else f"transformer.h.{slot}.{ROLE[name]}.weight")
160
+
161
+ ADAMW = ([("lm_head", None), ("input_embeds", None)]
162
+ + [("value_embeds", j) for j in range(NVE)]
163
+ + [("resid_lambdas", None), ("x0_lambdas", None), ("smear_gate", None),
164
+ ("smear_lambda", None), ("backout_lambda", None)])
165
+ matrix = _matrix_params(NL, VE)
166
+ shape_of = lambda e: tuple(weights[e[0]].shape[1:])
167
+
168
+ # world_size 3 is deliberate: it leaves the Muon groups RAGGED (the ve_gate
169
+ # group hands rank 2 nothing, the attention group one padding slot), which is
170
+ # exactly what d24's 12-param ve_gate group does across 8 ranks.
171
+ for W in (1, 2, 3, 4):
172
+ shards = [convert_optimizer(model_data, optim_data, W, r, LRS) for r in range(W)]
173
+
174
+ for sd_r in shards: # structure: a real optimizer accepts it
175
+ opt = new_opt(model)
176
+ opt.load_state_dict(sd_r) # raises on group/param-count mismatch
177
+ for g in opt.param_groups:
178
+ if g["kind"] != "muon":
179
+ continue
180
+ assert "momentum_buffer" in opt.state[g["params"][0]], "muon state off params[0]"
181
+ assert not any(opt.state.get(p) for p in g["params"][1:]), "muon state leaked"
182
+
183
+ for entry in ADAMW: # AdamW: shards must reassemble exactly
184
+ pidx = index_of_key[key_of(entry)]
185
+ full = state[f"{entry[0]}.exp_avg"]
186
+ if entry[1] is not None:
187
+ full = full.view(NVE, -1, full.shape[-1])[entry[1]]
188
+ pieces = [s["state"][pidx]["exp_avg"] for s in shards]
189
+ got = pieces[0] if pieces[0].shape == full.shape else torch.cat(pieces, 0)
190
+ assert torch.equal(got, full), f"W={W} {key_of(entry)}: adamw state misassembled"
191
+ assert all(s["state"][pidx]["step"] == 42 for s in shards), "step not carried"
192
+
193
+ for shape in sorted({shape_of(e) for e in matrix}): # Muon: chunked by group
194
+ members = [e for e in matrix if shape_of(e) == shape]
195
+ first = index_of_key[key_of(members[0])]
196
+ chunk = -(-len(members) // W)
197
+ for n, e in enumerate(members):
198
+ r, k = n // chunk, n % chunk
199
+ assert torch.equal(shards[r]["state"][first]["momentum_buffer"][k],
200
+ state[f"{e[0]}.frst_mntm"][e[1]]), f"W={W} {e}: momentum"
201
+ got = shards[r]["state"][first]["second_momentum_buffer"][k]
202
+ ours = state[f"{e[0]}.scnd_mntm"][e[1]]
203
+ if tuple(got.shape) == tuple(ours.shape):
204
+ assert torch.equal(got, ours), f"W={W} {e}: second moment"
205
+ else: # the square-c_proj mean fallback
206
+ assert e[0] == "W_O" and shape[-2] == shape[-1], f"unexpected fallback {e}"
207
+ assert torch.allclose(got, ours.mean().expand_as(got)), f"W={W} {e}: fallback"
208
+ for r in range(W): # padding slots stay zero
209
+ for k in range(chunk):
210
+ if r * chunk + k >= len(members):
211
+ assert not shards[r]["state"][first]["momentum_buffer"][k].any(), "padding"
212
+ print(f"[OK] optimizer, world_size={W}: structure + routing + reassembly verified")
213
+
214
+ # ---------------------------------------------------------------- 3. it actually steps
215
+ # At world_size=1 the non-distributed optimizer's buffers have exactly the shapes
216
+ # DistMuonAdamW's rank-0 shard carries, so this runs the real update kernels over
217
+ # our tensors rather than only checking bookkeeping.
218
+ opt = new_opt(model)
219
+ opt.load_state_dict(convert_optimizer(model_data, optim_data, 1, 0, LRS))
220
+ for p in model.parameters():
221
+ p.grad = torch.randn_like(p) * 1e-3
222
+ before = {k: v.detach().clone() for k, v in model.named_parameters()}
223
+ mom_before = {i: opt.state[g["params"][0]]["momentum_buffer"].clone()
224
+ for i, g in enumerate(opt.param_groups) if g["kind"] == "muon"}
225
+ opt.step()
226
+ stuck = [k for k, v in model.named_parameters() if torch.equal(v, before[k])]
227
+ assert not stuck, f"params did not move: {stuck[:5]}"
228
+ for i, m0 in mom_before.items():
229
+ assert not torch.equal(m0, opt.state[opt.param_groups[i]["params"][0]]["momentum_buffer"]), \
230
+ f"muon group {i}: momentum buffer did not advance"
231
+ assert all(opt.state[g["params"][0]]["step"] == 43
232
+ for g in opt.param_groups if g["kind"] == "adamw"), "step did not advance from 42"
233
+ assert all(torch.isfinite(v).all() for _, v in model.named_parameters()), "non-finite param"
234
+ print(f"[OK] warm-started MuonAdamW.step(): all {len(before)} params updated, "
235
+ "buffers advanced, step 42 -> 43")
236
+
237
+ # ---------------------------------------------------------------- 4. the guard bites
238
+ # Outside the square case the two second-moment axes carry different information,
239
+ # and there is no faithful conversion. Refuse rather than quietly mean-fill.
240
+ bad_state = dict(state)
241
+ bad_state["W_in.scnd_mntm"] = torch.zeros(NL, 1, DM) # wrong axis, NON-square bank
242
+ try:
243
+ convert_optimizer(model_data, {"step": 42, "t_step": 42, "state": bad_state}, 1, 0, LRS)
244
+ sys.exit("FAIL: a non-square second-moment axis mismatch was accepted")
245
+ except AssertionError as e:
246
+ assert "non-square" in str(e), e
247
+ print("[OK] non-square second-moment axis mismatch refused")
248
+ print("PASS")