Converter now emits nanochat optimizer shards for an SFT warm-start
Browse files- README.md +31 -9
- convert_ckpt_to_nanochat.py +228 -17
- test_convert_ckpt_to_nanochat.py +248 -0
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 180 |
-
axis
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 184 |
|
| 185 |
## Provenance
|
| 186 |
|
| 187 |
Trained by [`chrisjmccormick/stacks`](https://github.com/chrisjmccormick/stacks) — the
|
| 188 |
converter is committed at
|
| 189 |
-
[`
|
| 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 config — structure, 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 |
-
#
|
| 44 |
-
# ---------------
|
| 45 |
-
#
|
| 46 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 225 |
#
|
| 226 |
-
# PRE-TRAINING RESUME is
|
| 227 |
-
#
|
| 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
|
| 270 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
#
|
| 272 |
-
#
|
| 273 |
-
#
|
| 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")
|