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
|
| 87 |
-
|
|
|
|
| 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
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
## Provenance
|
| 168 |
|
| 169 |
Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
|
| 170 |
converter is committed at
|
| 171 |
-
[`
|
| 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
|
| 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
|
| 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
|
| 232 |
-
# where ours are fp32. That is the one precision difference, and
|
| 233 |
-
#
|
| 234 |
-
#
|
| 235 |
-
#
|
| 236 |
-
#
|
| 237 |
-
#
|
| 238 |
-
#
|
| 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 |
-
#
|
| 253 |
-
#
|
| 254 |
-
#
|
| 255 |
-
#
|
| 256 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
#
|
| 258 |
-
#
|
| 259 |
-
#
|
| 260 |
-
#
|
| 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})`
|