ChrisMcCormick commited on
Commit
162df34
·
verified ·
1 Parent(s): 11f07f9

Correct optimizer-state notes: SFT needs only the weights; W_O axis is a no-op at d24

Browse files
README.md CHANGED
@@ -83,8 +83,9 @@ 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 —
86
- the LR/momentum hold ends after update 1949 (`N - round(0.65*N)`), so 1950 is the one to
87
- resume from if you want to train the horizon longer.
 
88
 
89
  DecoderStack writes `code` (the full training script, `open(sys.argv[0]).read()`) into
90
  every model capture, so each `.pt` carries its own exact source. `code/run_full_d24_w8.py`
@@ -146,8 +147,19 @@ design; a load/resume path is future work.
146
 
147
  ### Optimizer state
148
 
149
- Not converted, and nanochat could not resume from it if it were. Every buffer does have a
150
- counterpart, and the precisions line up on everything except the embedding tables:
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  | DecoderStack | nanochat | precision |
153
  |---|---|---|
@@ -156,19 +168,25 @@ counterpart, and the precisions line up on everything except the embedding table
156
  | `.exp_avg` / `.exp_avg_sq` | AdamW `exp_avg` / `exp_avg_sq` | fp32 both, **except** `wte` and `value_embeds`, which nanochat allocates `zeros_like(p)` on a bf16 param — so bf16 there, fp32 here |
157
  | `.mantissa` (uint16) | — | nanochat's fp32 param *is* the master; consumed by the converter to rebuild it |
158
 
159
- Two things block a faithful resume regardless: `W_O`'s NorMuon second moment reduces along
160
- a different axis in each codebase (nanochat infers it from the shape and lands on `-1` for
161
- a square `c_proj`; DecoderStack sets `residual_dim = -2` deliberately, because W_O's heads
162
- are stored transposed relative to QKV), and nanochat's resume needs a
163
- `dataloader_state_dict` that DecoderStack's pre-tokenized binary loader has no equivalent
164
- of. The remaining mappingparam ordering and per-rank sharding is written out at the
165
- bottom of `convert_ckpt_to_nanochat.py`.
 
 
 
 
 
 
166
 
167
  ## Provenance
168
 
169
  Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
170
  converter is committed at
171
- [`e664830`](https://github.com/chrisjmccormick/stacks/commit/e6648306442e0479433b287ede02d271997365c0)
172
  (`utils/convert_ckpt_to_nanochat.py`). The training script that produced these weights is
173
  `code/run_full_d24_w8.py` in this repo, which is the run copy of the single-file d24
174
  trainer with three launcher overrides (`micro_batch_tokens` 32768→65536,
 
83
  ```
84
 
85
  Two capture points: **5568** is the end of the run, **1950** is the last uncooled state —
86
+ the LR/momentum hold ends after update 1949 (`N - round(0.65*N)`), so 1950 is the capture
87
+ to branch from if the horizon is ever extended. (Extending it needs a load path that does
88
+ not exist yet in either codebase — see [Optimizer state](#optimizer-state).)
89
 
90
  DecoderStack writes `code` (the full training script, `open(sys.argv[0]).read()`) into
91
  every model capture, so each `.pt` carries its own exact source. `code/run_full_d24_w8.py`
 
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
158
+ loader back in the stream, and DecoderStack's pre-tokenized binary loader has no equivalent
159
+ state to hand over. The data order could not be continued no matter what the optimizer held.
160
+
161
+ Every buffer does have a counterpart, and the precisions line up on everything except the
162
+ embedding tables:
163
 
164
  | DecoderStack | nanochat | precision |
165
  |---|---|---|
 
168
  | `.exp_avg` / `.exp_avg_sq` | AdamW `exp_avg` / `exp_avg_sq` | fp32 both, **except** `wte` and `value_embeds`, which nanochat allocates `zeros_like(p)` on a bf16 param — so bf16 there, fp32 here |
169
  | `.mantissa` (uint16) | — | nanochat's fp32 param *is* the master; consumed by the converter to rebuild it |
170
 
171
+ DecoderStack is the more precise of the two on the embeddings, deliberately: only the
172
+ *gradients* are bf16 for those tables (they are the model's largest tensors), while the
173
+ moment math stays fp32 the kernel upcasts on the way in. There is no bf16 AdamW variant;
174
+ the two AdamW kernels differ in whether the param carries a mantissa, not in moment dtype.
175
+
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 sharding — is 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,
base_checkpoints/d24_decoderstack/meta_001950.json CHANGED
@@ -13,7 +13,7 @@
13
  "device_batch_size": 32,
14
  "max_seq_len": 2048,
15
  "total_batch_size": 1048576,
16
- "note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval; NOT sufficient for resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
17
  "stack_config": {
18
  "n_layers": 24,
19
  "d_model": 1536,
 
13
  "device_batch_size": 32,
14
  "max_seq_len": 2048,
15
  "total_batch_size": 1048576,
16
+ "note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval and for SFT (chat_sft builds a fresh optimizer and only optionally warm-starts it). NOT sufficient for pre-training resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume -- the data order could not be continued regardless. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
17
  "stack_config": {
18
  "n_layers": 24,
19
  "d_model": 1536,
base_checkpoints/d24_decoderstack/meta_005568.json CHANGED
@@ -13,7 +13,7 @@
13
  "device_batch_size": 32,
14
  "max_seq_len": 2048,
15
  "total_batch_size": 1048576,
16
- "note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval; NOT sufficient for resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
17
  "stack_config": {
18
  "n_layers": 24,
19
  "d_model": 1536,
 
13
  "device_batch_size": 32,
14
  "max_seq_len": 2048,
15
  "total_batch_size": 1048576,
16
+ "note": "DecoderStack-medium (d24) capture converted for nanochat. This meta was built after the fact from the run log (full_d24_w8.log); DecoderStack's capture writes only {step, code, weights} and does not emit a meta.json of its own. model_config is the nanochat GPTConfig equivalent of StackConfig -- the two architectures are the same model (both count 1,384,122,122 parameters). Sufficient for load/eval and for SFT (chat_sft builds a fresh optimizer and only optionally warm-starts it). NOT sufficient for pre-training resume: there is no dataloader_state_dict, loop_state, or optimizer state here, and DecoderStack's pre-tokenized binary loader has no state nanochat's tokenizing loader could consume -- the data order could not be continued regardless. device_batch_size/max_seq_len are the nanochat-shaped restatement of micro_batch_tokens=65,536 (= 32 x 2048) -- DecoderStack trains varlen, so its micro-batch is a token count, not a sequence count. TOKENIZER: these weights use the 32k vocab shipped in the ChrisMcCormick/climbmix_32k_8_170 dataset repo (mirrored under tokenizer/ in this repo). It is NOT the same vocabulary as ChrisMcCormick/nanochat-varlen-d24-2026-03-22 -- 31,474 of 32,759 ids differ. Pairing these weights with that tokenizer produces garbage.",
17
  "stack_config": {
18
  "n_layers": 24,
19
  "d_model": 1536,
convert_ckpt_to_nanochat.py CHANGED
@@ -214,6 +214,22 @@ if __name__ == "__main__":
214
  # -----------------------------------------------------------------------------
215
  # OPTIMIZER-STATE NOTES
216
  # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # Every buffer we keep has a nanochat counterpart, and the precisions line up on
218
  # everything except the two embedding tables:
219
  #
@@ -228,36 +244,34 @@ if __name__ == "__main__":
228
  #
229
  # nanochat allocates its Muon buffers as `dtype=p.dtype` and its AdamW buffers as
230
  # `torch.zeros_like(p)`. Its Muon params and lm_head are fp32, so those match us.
231
- # But wte and value_embeds are bf16 params, so THEIR AdamW moments are bf16 --
232
- # where ours are fp32. That is the one precision difference, and it is ours that
233
- # is the more precise of the two: we pair the embeddings with a mantissa so a
234
- # single AdamW kernel serves every param, instead of carrying a second bf16-live
235
- # variant (see the dtype scheme in the training script).
236
- #
237
- # Two things would still block a faithful resume INTO nanochat, so this script
238
- # does not pretend to offer one:
239
- #
240
- # 1. W_O's second moment is a different quantity. NorMuon's factored second
241
- # moment is a per-neuron mean-square, and the two codebases disagree about
242
- # which axis holds the neurons for the attention output projection. nanochat
243
- # infers it from the shape -- `red_dim = -1 if shape[-2] >= shape[-1] else -2`
244
- # -- which for a square (1536, 1536) c_proj picks -1. DecoderStack sets it
245
- # explicitly (m.W_O.residual_dim = -2) because W_O's heads are stored
246
- # transposed relative to QKV. So our W_O.scnd_mntm is (1, 1536) where
247
- # nanochat's is (1536, 1); they are not transposes of each other, they are
248
- # reductions along different axes. Everything else agrees (W_in -1, W_out -2,
249
- # QKV -1, ve_gate -1), because there the shape heuristic happens to land on
250
- # the same axis we chose deliberately.
251
  #
252
- # 2. nanochat's resume needs meta_data["dataloader_state_dict"] to place its
253
- # tokenizing loader back in the stream. DecoderStack reads pre-tokenized
254
- # binary shards through a completely different loader and has no such state to
255
- # hand over, so the data order could not be continued regardless of optimizer
256
- # state.
 
 
 
 
 
 
 
 
 
257
  #
258
- # If you do want the optimizer state anyway (say, to warm-start rather than
259
- # resume), the remaining mapping is mechanical but fiddly, because nanochat's
260
- # state_dict is keyed by flattened param INDEX and is sharded per rank:
261
  # - Param order is setup_optimizer()'s group order: lm_head, wte,
262
  # value_embeds.*, resid_lambdas, x0_lambdas, [smear_gate.weight,
263
  # smear_lambda, backout_lambda], then the Muon groups in `sorted({shapes})`
 
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
  #
 
244
  #
245
  # nanochat allocates its Muon buffers as `dtype=p.dtype` and its AdamW buffers as
246
  # `torch.zeros_like(p)`. Its Muon params and lm_head are fp32, so those match us.
247
+ # But wte and value_embeds are bf16 PARAMS, so THEIR AdamW moments are bf16 --
248
+ # where ours are fp32. That is the one precision difference, and ours is the more
249
+ # precise of the two, deliberately: it is only the GRADIENTS that are bf16 for
250
+ # those two tables (they are the biggest tensors in the model, so fp32 grads
251
+ # would double their scatter and comm traffic, and bf16 matches the autograd
252
+ # baseline's numerics). The moment math stays fp32 -- adamw_step_fused upcasts on
253
+ # the way in, `grad = grad.to(exp_avg.dtype)`. There is no bf16 AdamW variant in
254
+ # the file: the two AdamW kernels differ in whether the param carries a mantissa
255
+ # (adamw_step_fused vs adamw_step_fused_fp32), not in moment dtype.
 
 
 
 
 
 
 
 
 
 
 
256
  #
257
+ # W_O's reduction axis differs between the two, and at d24 it costs nothing.
258
+ # NorMuon's factored second moment is a per-neuron mean-square; nanochat infers
259
+ # the neuron axis from the shape (`red_dim = -1 if shape[-2] >= shape[-1] else
260
+ # -2`) while DecoderStack states it (m.W_O.residual_dim = -2), so the two
261
+ # disagree on a square c_proj -- ours is (1, 1536) where nanochat's is (1536, 1).
262
+ # But polar express returns a ~orthonormal update, and a square orthonormal
263
+ # matrix has ~uniform neuron norms along either axis: there is no variance to
264
+ # reduce, the rescale is a ~no-op, and the run is unaffected by the choice.
265
+ # The explicit axis earns its keep only when n_heads * d_head != d_model. Above
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})`