Download test_convert_ckpt_to_nanochat.py from ChrisMcCormick/decoderstack-d24: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/ChrisMcCormick/decoderstack-d24/resolve/main/test_convert_ckpt_to_nanochat.py
- Command line
-
hf download hf://ChrisMcCormick/decoderstack-d24/test_convert_ckpt_to_nanochat.py
-
curl -L -o test_convert_ckpt_to_nanochat.py https://huggingface.co/ChrisMcCormick/decoderstack-d24/resolve/main/test_convert_ckpt_to_nanochat.py
13.9 kB
| # Self-contained test for convert_ckpt_to_nanochat.py. No pytest, no fixtures: | |
| # | |
| # NANOCHAT_PATH=~/nanochat python utils/test_convert_ckpt_to_nanochat.py | |
| # | |
| # Builds a synthetic DecoderStack capture (weights + optimizer state, with real | |
| # mantissas) at a toy config, converts it, and checks the result against a REAL | |
| # nanochat GPT and a REAL MuonAdamW -- so the assertions are about nanochat's | |
| # actual behaviour, not a second copy of my assumptions about it. | |
| # | |
| # The toy config deliberately mirrors d24's SHAPE RELATIONSHIPS rather than just | |
| # being small: n_head * head_dim == n_embd (W_O square) and n_kv_head == 12 == | |
| # d_ve_gate (ve_gate square). Those are exactly the conditions under which the two | |
| # codebases' NorMuon second-moment axes agree; a config that breaks either is | |
| # meant to be REFUSED, and the last check covers that. | |
| # | |
| # Requires a nanochat checkout on the fa-varlen branch (or any branch whose GPT | |
| # uses the modular transformer.h.N.* layout). | |
| import math | |
| import os | |
| import sys | |
| import torch | |
| _NC = os.environ.get("NANOCHAT_PATH") | |
| if not _NC or not os.path.isdir(os.path.join(os.path.expanduser(_NC), "nanochat")): | |
| sys.exit("set NANOCHAT_PATH to a nanochat checkout, e.g. NANOCHAT_PATH=~/nanochat") | |
| sys.path.insert(0, os.path.expanduser(_NC)) | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| # COMPUTE_DTYPE auto-detects to fp32 with no CUDA; force the bf16 a GPU box would | |
| # pick, so the embedding dtypes we compare against are the ones a real load wants. | |
| os.environ["NANOCHAT_DTYPE"] = "bfloat16" | |
| # nanochat's fused kernels are @torch.compile'd and inductor's CPU backend wants a | |
| # host compiler. Run them eager -- the bodies are plain PyTorch, so the shapes and | |
| # the math are still exercised, just unfused. | |
| os.environ["TORCHDYNAMO_DISABLE"] = "1" | |
| from nanochat.gpt import GPT, GPTConfig # noqa: E402 | |
| from convert_ckpt_to_nanochat import (convert, convert_optimizer, # noqa: E402 | |
| ve_layers, _matrix_params) | |
| NL, DM, NH, HD, V = 8, 192, 12, 16, 384 | |
| KV, MLP = NH * HD, 4 * DM | |
| VE, D_VE_GATE, D_SMR = ve_layers(NL), 12, 24 | |
| NVE = len(VE) | |
| LRS = dict(unembedding_lr=0.004, embedding_lr=0.2, matrix_lr=0.02, scalar_lr=0.5, | |
| weight_decay=0.0) | |
| upper = lambda x: (x.contiguous().view(torch.int32) >> 16).to(torch.int16).view(torch.bfloat16) | |
| lower = lambda x: (x.contiguous().view(torch.int32)).to(torch.int16).view(torch.uint16) | |
| torch.manual_seed(0) | |
| # ---------------------------------------------------------------- synthetic capture | |
| BANKS = { | |
| "input_embeds": (V, DM), "value_embeds": (NVE, V, KV), "lm_head": (V, DM), | |
| "W_Q": (NL, KV, DM), "W_K": (NL, KV, DM), "W_V": (NL, KV, DM), "W_O": (NL, DM, KV), | |
| "W_in": (NL, MLP, DM), "W_out": (NL, DM, MLP), "ve_gate": (NVE, NH, D_VE_GATE), | |
| } | |
| fp32_src, weights, state = {}, {}, {} | |
| for name, shape in BANKS.items(): # bf16 live + uint16 mantissa | |
| t = torch.randn(*shape) | |
| fp32_src[name], weights[name], state[f"{name}.mantissa"] = t, upper(t), lower(t) | |
| for name, shape in [("resid_lambdas", (NL,)), ("x0_lambdas", (NL,)), | |
| ("smear_gate", (1, D_SMR)), ("smear_lambda", (1,)), | |
| ("backout_lambda", (1,))]: # fp32 live, no mantissa | |
| weights[name] = torch.randn(*shape) | |
| # AdamW moments: param-shaped, except value_embeds, which the real capture holds | |
| # over the FLATTENED (slot * vocab) row axis. Every element gets a distinct value | |
| # so a misrouted or misaligned shard is loud, but the SCALE stays realistic -- | |
| # with a second moment of ~1e7 the resulting update lands below fp32 epsilon and | |
| # the "every param moved" check below fails on the (1,) scalars for reasons that | |
| # have nothing to do with the conversion. | |
| for t_id, (name, shape) in enumerate([("input_embeds", (V, DM)), ("lm_head", (V, DM)), | |
| ("value_embeds", (NVE * V, KV)), | |
| ("resid_lambdas", (NL,)), ("x0_lambdas", (NL,)), | |
| ("smear_gate", (1, D_SMR)), ("smear_lambda", (1,)), | |
| ("backout_lambda", (1,))]): | |
| for a_id, attr in enumerate(("exp_avg", "exp_avg_sq")): | |
| n = math.prod(shape) | |
| base = (t_id * 2 + a_id) * n | |
| state[f"{name}.{attr}"] = ( | |
| (torch.arange(n, dtype=torch.float32) + base) * 1e-7).reshape(*shape) | |
| # Muon moments. The factored second moment follows DecoderStack's EXPLICIT | |
| # residual_dim: -1 for QKV / W_in / ve_gate, -2 for W_O / W_out. | |
| SND = {"W_Q": (NL, KV, 1), "W_K": (NL, KV, 1), "W_V": (NL, KV, 1), "W_O": (NL, 1, KV), | |
| "W_in": (NL, MLP, 1), "W_out": (NL, 1, MLP), "ve_gate": (NVE, NH, 1)} | |
| for name, shape in SND.items(): | |
| b = BANKS[name] | |
| state[f"{name}.frst_mntm"] = torch.stack( | |
| [torch.full(b[1:], float(i + 1)) for i in range(b[0])]) | |
| state[f"{name}.scnd_mntm"] = torch.stack( | |
| [torch.full(shape[1:], float(i + 1) * 0.5) for i in range(shape[0])]) | |
| model_data = {"step": 42, "code": "# toy\n", "weights": weights} | |
| optim_data = {"step": 42, "t_step": 42, "state": state} | |
| cfg = GPTConfig(sequence_len=256, vocab_size=V, n_layer=NL, n_head=NH, n_kv_head=NH, | |
| n_embd=DM, window_pattern="SSSL") | |
| new_opt = lambda m: m.setup_optimizer(unembedding_lr=LRS["unembedding_lr"], | |
| embedding_lr=LRS["embedding_lr"], | |
| matrix_lr=LRS["matrix_lr"], | |
| weight_decay=LRS["weight_decay"], | |
| scalar_lr=LRS["scalar_lr"]) | |
| # ---------------------------------------------------------------- 1. weights | |
| with torch.device("meta"): | |
| ref = GPT(cfg) | |
| ref_sd = ref.state_dict() | |
| for tag, mant in [("with mantissas", state), ("model only", None)]: | |
| sd = convert(model_data, mant) | |
| assert set(sd) == set(ref_sd), (f"key mismatch: missing " | |
| f"{sorted(set(ref_sd) - set(sd))[:4]}, extra " | |
| f"{sorted(set(sd) - set(ref_sd))[:4]}") | |
| with torch.device("meta"): | |
| model = GPT(cfg) | |
| model.to_empty(device="cpu") | |
| model.init_weights() | |
| want_dtype = {k: v.dtype for k, v in model.state_dict().items()} | |
| model.load_state_dict(sd, strict=True, assign=True) # strict: shapes + names | |
| bad = {k: (sd[k].dtype, want_dtype[k]) for k in sd if sd[k].dtype != want_dtype[k]} | |
| assert not bad, f"dtype mismatch vs a fresh model: {bad}" | |
| for key, src, idx in [("lm_head.weight", "lm_head", None), | |
| ("transformer.h.2.attn.c_q.weight", "W_Q", 2), | |
| ("transformer.h.2.attn.c_proj.weight", "W_O", 2), | |
| ("transformer.h.3.mlp.c_fc.weight", "W_in", 3), | |
| (f"transformer.h.{VE[1]}.attn.ve_gate.weight", "ve_gate", 1)]: | |
| full = fp32_src[src] if idx is None else fp32_src[src][idx] | |
| want = full if mant else (upper(fp32_src[src]) if idx is None | |
| else upper(fp32_src[src])[idx]).float() | |
| assert torch.equal(sd[key], want), f"{tag}: {key} value mismatch" | |
| for key, src, idx in [("transformer.wte.weight", "input_embeds", None), | |
| (f"value_embeds.{VE[0]}.weight", "value_embeds", 0)]: | |
| want = weights[src] if idx is None else weights[src][idx] | |
| assert torch.equal(sd[key], want), f"{tag}: {key} should be the bf16 live weight" | |
| print(f"[OK] weights, {tag}: {len(sd)} tensors, strict load, values verified") | |
| ve_keys = sorted(k for k in ref_sd if k.endswith("ve_gate.weight")) | |
| assert ve_keys == [f"transformer.h.{i}.attn.ve_gate.weight" for i in VE], ve_keys | |
| print(f"[OK] ve slot->layer map: slots 0..{NVE - 1} -> layers {VE}") | |
| # ---------------------------------------------------------------- 2. optimizer | |
| ref_opt = new_opt(model) | |
| param_key = {id(p): k for k, p in model.named_parameters()} | |
| flat = [p for g in ref_opt.param_groups for p in g["params"]] | |
| index_of_key = {param_key[id(p)]: i for i, p in enumerate(flat)} | |
| print(f"reference optimizer: {len(ref_opt.param_groups)} groups, {len(flat)} params") | |
| ROLE = {"W_Q": "attn.c_q", "W_K": "attn.c_k", "W_V": "attn.c_v", "W_O": "attn.c_proj", | |
| "W_in": "mlp.c_fc", "W_out": "mlp.c_proj"} | |
| def key_of(entry): | |
| name, slot = entry | |
| return {"lm_head": "lm_head.weight", "input_embeds": "transformer.wte.weight", | |
| "smear_gate": "smear_gate.weight"}.get(name) \ | |
| or (f"value_embeds.{VE[slot]}.weight" if name == "value_embeds" | |
| else f"transformer.h.{VE[slot]}.attn.ve_gate.weight" if name == "ve_gate" | |
| else name if slot is None | |
| else f"transformer.h.{slot}.{ROLE[name]}.weight") | |
| ADAMW = ([("lm_head", None), ("input_embeds", None)] | |
| + [("value_embeds", j) for j in range(NVE)] | |
| + [("resid_lambdas", None), ("x0_lambdas", None), ("smear_gate", None), | |
| ("smear_lambda", None), ("backout_lambda", None)]) | |
| matrix = _matrix_params(NL, VE) | |
| shape_of = lambda e: tuple(weights[e[0]].shape[1:]) | |
| # world_size 3 is deliberate: it leaves the Muon groups RAGGED (the ve_gate | |
| # group hands rank 2 nothing, the attention group one padding slot), which is | |
| # exactly what d24's 12-param ve_gate group does across 8 ranks. | |
| for W in (1, 2, 3, 4): | |
| shards = [convert_optimizer(model_data, optim_data, W, r, LRS) for r in range(W)] | |
| for sd_r in shards: # structure: a real optimizer accepts it | |
| opt = new_opt(model) | |
| opt.load_state_dict(sd_r) # raises on group/param-count mismatch | |
| for g in opt.param_groups: | |
| if g["kind"] != "muon": | |
| continue | |
| assert "momentum_buffer" in opt.state[g["params"][0]], "muon state off params[0]" | |
| assert not any(opt.state.get(p) for p in g["params"][1:]), "muon state leaked" | |
| for entry in ADAMW: # AdamW: shards must reassemble exactly | |
| pidx = index_of_key[key_of(entry)] | |
| full = state[f"{entry[0]}.exp_avg"] | |
| if entry[1] is not None: | |
| full = full.view(NVE, -1, full.shape[-1])[entry[1]] | |
| pieces = [s["state"][pidx]["exp_avg"] for s in shards] | |
| got = pieces[0] if pieces[0].shape == full.shape else torch.cat(pieces, 0) | |
| assert torch.equal(got, full), f"W={W} {key_of(entry)}: adamw state misassembled" | |
| assert all(s["state"][pidx]["step"] == 42 for s in shards), "step not carried" | |
| for shape in sorted({shape_of(e) for e in matrix}): # Muon: chunked by group | |
| members = [e for e in matrix if shape_of(e) == shape] | |
| first = index_of_key[key_of(members[0])] | |
| chunk = -(-len(members) // W) | |
| for n, e in enumerate(members): | |
| r, k = n // chunk, n % chunk | |
| assert torch.equal(shards[r]["state"][first]["momentum_buffer"][k], | |
| state[f"{e[0]}.frst_mntm"][e[1]]), f"W={W} {e}: momentum" | |
| got = shards[r]["state"][first]["second_momentum_buffer"][k] | |
| ours = state[f"{e[0]}.scnd_mntm"][e[1]] | |
| if tuple(got.shape) == tuple(ours.shape): | |
| assert torch.equal(got, ours), f"W={W} {e}: second moment" | |
| else: # the square-c_proj mean fallback | |
| assert e[0] == "W_O" and shape[-2] == shape[-1], f"unexpected fallback {e}" | |
| assert torch.allclose(got, ours.mean().expand_as(got)), f"W={W} {e}: fallback" | |
| for r in range(W): # padding slots stay zero | |
| for k in range(chunk): | |
| if r * chunk + k >= len(members): | |
| assert not shards[r]["state"][first]["momentum_buffer"][k].any(), "padding" | |
| print(f"[OK] optimizer, world_size={W}: structure + routing + reassembly verified") | |
| # ---------------------------------------------------------------- 3. it actually steps | |
| # At world_size=1 the non-distributed optimizer's buffers have exactly the shapes | |
| # DistMuonAdamW's rank-0 shard carries, so this runs the real update kernels over | |
| # our tensors rather than only checking bookkeeping. | |
| opt = new_opt(model) | |
| opt.load_state_dict(convert_optimizer(model_data, optim_data, 1, 0, LRS)) | |
| for p in model.parameters(): | |
| p.grad = torch.randn_like(p) * 1e-3 | |
| before = {k: v.detach().clone() for k, v in model.named_parameters()} | |
| mom_before = {i: opt.state[g["params"][0]]["momentum_buffer"].clone() | |
| for i, g in enumerate(opt.param_groups) if g["kind"] == "muon"} | |
| opt.step() | |
| stuck = [k for k, v in model.named_parameters() if torch.equal(v, before[k])] | |
| assert not stuck, f"params did not move: {stuck[:5]}" | |
| for i, m0 in mom_before.items(): | |
| assert not torch.equal(m0, opt.state[opt.param_groups[i]["params"][0]]["momentum_buffer"]), \ | |
| f"muon group {i}: momentum buffer did not advance" | |
| assert all(opt.state[g["params"][0]]["step"] == 43 | |
| for g in opt.param_groups if g["kind"] == "adamw"), "step did not advance from 42" | |
| assert all(torch.isfinite(v).all() for _, v in model.named_parameters()), "non-finite param" | |
| print(f"[OK] warm-started MuonAdamW.step(): all {len(before)} params updated, " | |
| "buffers advanced, step 42 -> 43") | |
| # ---------------------------------------------------------------- 4. the guard bites | |
| # Outside the square case the two second-moment axes carry different information, | |
| # and there is no faithful conversion. Refuse rather than quietly mean-fill. | |
| bad_state = dict(state) | |
| bad_state["W_in.scnd_mntm"] = torch.zeros(NL, 1, DM) # wrong axis, NON-square bank | |
| try: | |
| convert_optimizer(model_data, {"step": 42, "t_step": 42, "state": bad_state}, 1, 0, LRS) | |
| sys.exit("FAIL: a non-square second-moment axis mismatch was accepted") | |
| except AssertionError as e: | |
| assert "non-square" in str(e), e | |
| print("[OK] non-square second-moment axis mismatch refused") | |
| print("PASS") | |