Skip to content

Commit 7d1ffd0

Browse files
committed
feat(v7-c01): opt-state arch mismatch surfaced honestly + safe skip
Closes V7-C01 (cppmega-mlx-q3jq): opt-state load was silently dropping mismatched keys via tree_unflatten and would crash mid-step on shape mismatch. v7 adds structural fingerprint validation: * extras.checkpoint.opt_state_arch_diff carries {missing_keys, extra_keys, shape_mismatch} (plus *_count) when the loaded opt-state doesn't match the live model params. * Base-key derivation strips opt-state suffixes (".m", ".v", etc.) to match against model.parameters() keys, and ignores AdamW internal scalars ("step", "learning_rate"). * opts.opt_state_strict=True → skip load on ANY diff, opt_state_warning="opt_state arch mismatch (strict mode): …; cold restart". * Default (non-strict) tolerates missing/extra keys but skips on shape_mismatch (would crash mid-step), warning="… (shape mismatch): …; cold restart". Tests (tests/v4/test_checkpoint_arch_mismatch.py): 4/4 — * Matching arch → diff=None, no warning. * Mismatched intermediate_size (256→128) → diff populated with shape_mismatch > 0, load skipped, training continues cold. * strict_mode=True on mismatch → load skipped, "strict mode" warning, losses still produced. * strict_mode=True on matched arch → still loads cleanly. - pytest stage_train + checkpoint + rng + arch regression: 68/68.
1 parent 4ef1949 commit 7d1ffd0

2 files changed

Lines changed: 207 additions & 2 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
StageStatus = Literal["ok", "skipped", "fail", "cancelled"]
4646

4747

48+
class _ArchMismatchSentinel(Exception):
49+
"""V7-C01 internal control-flow: skip opt-state load on strict
50+
arch mismatch without surfacing as a real error."""
51+
52+
4853
@dataclass
4954
class StageResult:
5055
"""One stage's execution outcome."""
@@ -1118,19 +1123,99 @@ def _count(tree: Any) -> int:
11181123
# resumed run picks up Adam moments → strict losses[0] parity
11191124
# with the saved run's losses[-1].
11201125
opt_state_load = opts.get("opt_state_load_path")
1126+
opt_state_strict = bool(opts.get("opt_state_strict", False))
1127+
opt_state_arch_diff: dict[str, Any] | None = None
11211128
if opt_state_load:
11221129
try:
11231130
import safetensors.mlx as _stmlx
11241131
loaded_st = _stmlx.load_file(opt_state_load)
11251132
# V01: pull rng_key out of the opt-state bundle before
1126-
# tree_unflatten so it doesn't pollute opt.state. mlx
1127-
# rng keys are mx.uint32 arrays of shape (2,).
1133+
# tree_unflatten so it doesn't pollute opt.state.
11281134
rng_buf = loaded_st.pop("_rng_key", None)
1135+
# V7-C01: structural fingerprint diff. Fresh Adam
1136+
# optimisers initialise opt.state lazily (only after
1137+
# the first opt.update), so the saved opt-state is
1138+
# compared against the LIVE MODEL params instead.
1139+
# Each opt-state key for AdamW carries the same shape
1140+
# as its underlying param (m and v moments share the
1141+
# param shape). We strip suffixes that aren't actual
1142+
# model keys to find the base param.
1143+
live_flat = dict(nn.utils.tree_flatten(
1144+
all_modules.parameters()))
1145+
def _fp(d: dict) -> dict[str, tuple]:
1146+
return {k: (tuple(v.shape), str(v.dtype))
1147+
for k, v in d.items() if hasattr(v, "shape")}
1148+
live_fp = _fp(live_flat)
1149+
saved_fp = _fp(loaded_st)
1150+
def _base(k: str) -> str | None:
1151+
if k in live_fp:
1152+
return k
1153+
# Walk back along dot path looking for a model
1154+
# param this opt-state entry corresponds to.
1155+
parts = k.split(".")
1156+
for cut in range(len(parts) - 1, 0, -1):
1157+
cand = ".".join(parts[:cut])
1158+
if cand in live_fp:
1159+
return cand
1160+
return None
1161+
# Non-param opt entries (e.g. AdamW "step",
1162+
# "learning_rate" scalars) are neither model-derived
1163+
# nor problematic — skip them.
1164+
NON_PARAM_SUFFIXES = {"step", "learning_rate"}
1165+
missing: list[str] = [] # model param with no opt-state
1166+
covered: set[str] = set()
1167+
extra: list[str] = [] # opt-state without a model param
1168+
shape_mismatch: list[str] = []
1169+
for sk, sv in saved_fp.items():
1170+
if (sk in NON_PARAM_SUFFIXES
1171+
or sk.endswith(".step")
1172+
or sk.endswith(".learning_rate")):
1173+
continue
1174+
bk = _base(sk)
1175+
if bk is None:
1176+
extra.append(sk)
1177+
continue
1178+
covered.add(bk)
1179+
if live_fp[bk] != sv:
1180+
shape_mismatch.append(sk)
1181+
missing = sorted(set(live_fp) - covered)
1182+
extra = sorted(extra)
1183+
shape_mismatch = sorted(shape_mismatch)
1184+
if missing or extra or shape_mismatch:
1185+
opt_state_arch_diff = {
1186+
"missing_keys": missing[:10],
1187+
"missing_keys_count": len(missing),
1188+
"extra_keys": extra[:10],
1189+
"extra_keys_count": len(extra),
1190+
"shape_mismatch": shape_mismatch[:10],
1191+
"shape_mismatch_count": len(shape_mismatch),
1192+
}
1193+
# Strict mode: skip on ANY diff.
1194+
# Non-strict: skip only on shape_mismatch (would
1195+
# crash mid-step otherwise); tolerate missing/extra.
1196+
skip_load = (opt_state_strict
1197+
or len(shape_mismatch) > 0)
1198+
if skip_load:
1199+
mode = ("strict mode" if opt_state_strict
1200+
else "shape mismatch")
1201+
opt_state_warning = (
1202+
f"opt_state arch mismatch ({mode}): "
1203+
f"{len(missing)} missing, {len(extra)} extra, "
1204+
f"{len(shape_mismatch)} shape-mismatch keys; "
1205+
f"cold restart"
1206+
)
1207+
raise _ArchMismatchSentinel()
1208+
# Not strict (or no diff) → attempt load. Missing/extra
1209+
# keys are tolerated; shape_mismatch will still raise
1210+
# in tree_unflatten and surface as the generic warning.
11291211
opt.state = nn.utils.tree_unflatten(list(loaded_st.items()))
11301212
if rng_buf is not None:
11311213
rng_key = rng_buf
11321214
rng_key_loaded = True
11331215
opt_state_loaded_path = str(opt_state_load)
1216+
except _ArchMismatchSentinel:
1217+
# Already populated opt_state_warning above.
1218+
pass
11341219
except FileNotFoundError as exc:
11351220
opt_state_warning = (
11361221
f"opt_state_load_path missing: {exc}; cold restart")
@@ -1429,6 +1514,7 @@ def _count(tree: Any) -> int:
14291514
"opt_state_loaded_path": opt_state_loaded_path,
14301515
"opt_state_warning": opt_state_warning,
14311516
"rng_key_loaded": rng_key_loaded,
1517+
"opt_state_arch_diff": opt_state_arch_diff,
14321518
},
14331519
"mtp": _compute_mtp_extras(
14341520
all_modules, mtp_k, mtp_betas, vocab_size,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""V7-C01: opt-state load surfaces arch mismatch honestly.
2+
3+
Currently a checkpoint produced for a different architecture (extra
4+
brick, renamed layer, changed hidden size) loads silently — keys are
5+
dropped by tree_unflatten and shape mismatches raise opaque errors
6+
mid-step. v7 adds a structural fingerprint diff:
7+
8+
* extras.checkpoint.opt_state_arch_diff dict with
9+
{missing_keys, extra_keys, shape_mismatch} when the checkpoint's
10+
opt-state fingerprint doesn't match the current model.
11+
* opts.opt_state_strict=True → skip the load and surface a
12+
"cold restart" warning instead of partial overwrite.
13+
* Default (strict=False) preserves the prior best-effort behavior
14+
but still reports the diff so the user can see what was dropped.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import pytest
20+
21+
from cppmega_v4.jsonrpc.schema import VerifyParams
22+
from cppmega_v4.runner import Pipeline, run_pipeline
23+
24+
25+
def _spec(intermediate: int = 256) -> VerifyParams:
26+
"""intermediate_size controls mlp weight shape → easy way to
27+
produce a checkpoint that won't fit a different architecture."""
28+
return VerifyParams.model_validate({
29+
"graph": {
30+
"nodes": [
31+
{"id": "attn", "kind": "attention",
32+
"params": {"num_heads": 4, "head_dim": 64}},
33+
{"id": "mlp", "kind": "mlp",
34+
"params": {"intermediate_size": intermediate}},
35+
],
36+
"edges": [{"src": "attn", "dst": "mlp"}],
37+
},
38+
"dim_env": {"B": 1, "S": 8, "H": 128,
39+
"nh": 2, "nkv": 1, "head_dim": 64},
40+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
41+
"optim": {"kind": "adamw",
42+
"groups": [{"matcher": "all", "lr": 1e-3,
43+
"weight_decay": 0.01,
44+
"betas": [0.9, 0.95]}]},
45+
})
46+
47+
48+
def _train(spec, **opts) -> dict:
49+
rep = run_pipeline(spec, Pipeline.from_dict({
50+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
51+
"stage_options": {"train": opts},
52+
}))
53+
tr = next(s for s in rep.stages if s.name == "train")
54+
assert tr.status == "ok", f"train failed: {tr.error}"
55+
return tr.extras
56+
57+
58+
def test_c01_arch_diff_null_on_matching_load(tmp_path):
59+
"""Matched arch → no diff reported."""
60+
save = str(tmp_path / "opt.safetensors")
61+
_train(_spec(), num_steps=2, opt_state_save_path=save)
62+
extras = _train(_spec(), num_steps=1, opt_state_load_path=save)
63+
assert extras["checkpoint"]["opt_state_loaded_path"] == save
64+
assert extras["checkpoint"]["opt_state_arch_diff"] is None
65+
assert extras["checkpoint"]["opt_state_warning"] is None
66+
67+
68+
def test_c01_arch_diff_populated_on_shape_mismatch(tmp_path):
69+
"""Save with intermediate=256 → load into intermediate=128 model.
70+
Should populate opt_state_arch_diff with shape_mismatch entries
71+
AND skip the load (would crash mid-step otherwise) — train
72+
proceeds as cold restart."""
73+
save = str(tmp_path / "opt.safetensors")
74+
_train(_spec(intermediate=256), num_steps=2,
75+
opt_state_save_path=save)
76+
extras = _train(_spec(intermediate=128), num_steps=1,
77+
opt_state_load_path=save)
78+
diff = extras["checkpoint"]["opt_state_arch_diff"]
79+
assert diff is not None, (
80+
"expected opt_state_arch_diff for mismatched intermediate_size"
81+
)
82+
assert diff["shape_mismatch_count"] > 0
83+
# Non-strict but shape_mismatch → load skipped, warning surfaced,
84+
# training still completes (cold restart).
85+
assert extras["checkpoint"]["opt_state_loaded_path"] is None
86+
warning = extras["checkpoint"]["opt_state_warning"]
87+
assert isinstance(warning, str)
88+
assert "shape mismatch" in warning.lower()
89+
assert len(extras["losses"]) == 1
90+
91+
92+
def test_c01_strict_mode_blocks_load_on_mismatch(tmp_path):
93+
"""opt_state_strict=True → load skipped, warning + diff populated,
94+
opt_state_loaded_path stays None."""
95+
save = str(tmp_path / "opt.safetensors")
96+
_train(_spec(intermediate=256), num_steps=2,
97+
opt_state_save_path=save)
98+
extras = _train(_spec(intermediate=128), num_steps=1,
99+
opt_state_load_path=save,
100+
opt_state_strict=True)
101+
assert extras["checkpoint"]["opt_state_loaded_path"] is None
102+
diff = extras["checkpoint"]["opt_state_arch_diff"]
103+
assert diff is not None
104+
warning = extras["checkpoint"]["opt_state_warning"]
105+
assert isinstance(warning, str) and "strict mode" in warning.lower()
106+
assert "cold restart" in warning.lower()
107+
# Training still produced losses (cold start).
108+
assert len(extras["losses"]) == 1
109+
110+
111+
def test_c01_strict_mode_passes_through_when_matched(tmp_path):
112+
"""opt_state_strict=True on a matched arch → still loads cleanly."""
113+
save = str(tmp_path / "opt.safetensors")
114+
_train(_spec(), num_steps=2, opt_state_save_path=save)
115+
extras = _train(_spec(), num_steps=1,
116+
opt_state_load_path=save,
117+
opt_state_strict=True)
118+
assert extras["checkpoint"]["opt_state_loaded_path"] == save
119+
assert extras["checkpoint"]["opt_state_arch_diff"] is None

0 commit comments

Comments
 (0)