Skip to content

Commit 55c8be2

Browse files
committed
feat(v01): rng_key roundtrip → strict 1e-5 H19 loss continuation
V6 H19 left the strict 1e-5 bit-identity claim unreachable because stage_train's per-step rng_key (mx.random.split each step) was NOT saved in the checkpoint, so a resumed Train consumed a fresh key(0) data stream — different synthetic targets at step N+1 vs the contiguous run's step N+1. Fix: opt_state save bundle now also writes the active rng_key under the "_rng_key" entry; load pops it back before tree_unflatten and seeds the resumed loop with the restored key. New extras.checkpoint.rng_key_loaded boolean lets tests pin which path was taken. Tests (tests/v4/test_stage_train_rng_roundtrip.py): 3/3 — * Contiguous 5-step losses[4] == (4-save + load + 1-step) losses[0] within 1e-5 once weights + opt.state + rng_key all restored. * rng_key_loaded=False without opt_state_load_path. * rng_key_loaded=True after opt_state_load with the side-car file. - pytest stage_train + checkpoint regression: 57/57 passing.
1 parent 398270e commit 55c8be2

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,11 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
682682
# when absent (preserves E-4 matrix behaviour). data_source +
683683
# token_count surface in extras so deep e2e can prove the real-
684684
# data path actually executed instead of silently degrading.
685+
# V01: per-step random data key. Round-tripped through the
686+
# opt.state safetensors side-car (key "_rng_key") so a resumed
687+
# Train picks up the exact same data stream as a contiguous run.
685688
rng_key = mx.random.key(0)
689+
rng_key_loaded = False
686690
data_source = "synthetic"
687691
token_count = 0
688692
tokenizer_used: str | None = None
@@ -1118,7 +1122,14 @@ def _count(tree: Any) -> int:
11181122
try:
11191123
import safetensors.mlx as _stmlx
11201124
loaded_st = _stmlx.load_file(opt_state_load)
1125+
# 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,).
1128+
rng_buf = loaded_st.pop("_rng_key", None)
11211129
opt.state = nn.utils.tree_unflatten(list(loaded_st.items()))
1130+
if rng_buf is not None:
1131+
rng_key = rng_buf
1132+
rng_key_loaded = True
11221133
opt_state_loaded_path = str(opt_state_load)
11231134
except FileNotFoundError as exc:
11241135
opt_state_warning = (
@@ -1260,17 +1271,20 @@ def _count(tree: Any) -> int:
12601271
pass
12611272
# H19: opt.state save → separate file so a follow-up Train can
12621273
# resume exactly where this one left off (Adam moments + step).
1274+
# V01: also persist rng_key under the "_rng_key" entry so the
1275+
# resumed run consumes the same synthetic data stream → enables
1276+
# strict 1e-5 loss continuation in the matched-fragment test.
12631277
opt_state_save = opts.get("opt_state_save_path")
12641278
if opt_state_save:
12651279
try:
12661280
import safetensors.mlx as _stmlx
12671281
opt_flat = dict(nn.utils.tree_flatten(opt.state))
1268-
# safetensors only accepts mx.array values; strip
1269-
# scalars / non-array entries from opt.state.
12701282
opt_arrays = {
12711283
k: v for k, v in opt_flat.items()
12721284
if hasattr(v, "shape")
12731285
}
1286+
if hasattr(rng_key, "shape"):
1287+
opt_arrays["_rng_key"] = rng_key
12741288
_stmlx.save_file(opt_arrays, opt_state_save)
12751289
opt_state_saved_path = str(opt_state_save)
12761290
except Exception:
@@ -1414,6 +1428,7 @@ def _count(tree: Any) -> int:
14141428
"opt_state_saved_path": opt_state_saved_path,
14151429
"opt_state_loaded_path": opt_state_loaded_path,
14161430
"opt_state_warning": opt_state_warning,
1431+
"rng_key_loaded": rng_key_loaded,
14171432
},
14181433
"mtp": _compute_mtp_extras(
14191434
all_modules, mtp_k, mtp_betas, vocab_size,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""V01: rng_key roundtrip enables strict 1e-5 loss continuation.
2+
3+
Without rng_key save+load, the per-step random target stream restarts
4+
from key(0) on a resumed Train, so resumed.losses[0] does NOT equal
5+
the contiguous run's losses[N]. V01 persists the active key in the
6+
opt-state safetensors side-car under "_rng_key" and restores it on
7+
load. This test pins the strict 1e-5 bound.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pytest
13+
14+
from cppmega_v4.jsonrpc.schema import VerifyParams
15+
from cppmega_v4.runner import Pipeline, run_pipeline
16+
17+
18+
def _spec() -> VerifyParams:
19+
return VerifyParams.model_validate({
20+
"graph": {
21+
"nodes": [
22+
{"id": "attn", "kind": "attention",
23+
"params": {"num_heads": 4, "head_dim": 64}},
24+
{"id": "mlp", "kind": "mlp", "params": {}},
25+
],
26+
"edges": [{"src": "attn", "dst": "mlp"}],
27+
},
28+
"dim_env": {"B": 1, "S": 8, "H": 128,
29+
"nh": 2, "nkv": 1, "head_dim": 64},
30+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
31+
"optim": {"kind": "adamw",
32+
"groups": [{"matcher": "all", "lr": 1e-3,
33+
"weight_decay": 0.01,
34+
"betas": [0.9, 0.95]}]},
35+
})
36+
37+
38+
def _train(spec, **opts) -> dict:
39+
rep = run_pipeline(spec, Pipeline.from_dict({
40+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
41+
"stage_options": {"train": opts},
42+
}))
43+
tr = next(s for s in rep.stages if s.name == "train")
44+
assert tr.status == "ok", f"train failed: {tr.error}"
45+
return tr.extras
46+
47+
48+
def test_v01_rng_key_roundtrip_strict_loss_parity(tmp_path):
49+
"""Contiguous 5-step Run A losses[4] == (4-save + load + 1-step)
50+
Run B losses[0] within 1e-5 once weights + opt.state + rng_key
51+
are all restored."""
52+
a = _train(_spec(), num_steps=5)
53+
save_ckpt = str(tmp_path / "ck.safetensors")
54+
save_opt = str(tmp_path / "opt.safetensors")
55+
_train(_spec(), num_steps=4,
56+
checkpoint_save_path=save_ckpt,
57+
opt_state_save_path=save_opt)
58+
resumed = _train(_spec(), num_steps=1,
59+
checkpoint_load_path=save_ckpt,
60+
opt_state_load_path=save_opt)
61+
assert resumed["checkpoint"]["rng_key_loaded"] is True
62+
63+
diff = abs(a["losses"][4] - resumed["losses"][0])
64+
assert diff < 1e-5, (
65+
f"V01 rng_key roundtrip insufficient: contig l4={a['losses'][4]}, "
66+
f"resumed l0={resumed['losses'][0]}, diff={diff}"
67+
)
68+
69+
70+
def test_v01_rng_key_loaded_flag_false_without_opt_state():
71+
"""Without opt_state_load_path, rng_key_loaded stays False."""
72+
extras = _train(_spec(), num_steps=2)
73+
assert extras["checkpoint"]["rng_key_loaded"] is False
74+
75+
76+
def test_v01_rng_key_loaded_flag_when_present(tmp_path):
77+
"""opt_state_load_path with a key-bearing file flips the flag."""
78+
save_opt = str(tmp_path / "opt.safetensors")
79+
_train(_spec(), num_steps=2, opt_state_save_path=save_opt)
80+
extras = _train(_spec(), num_steps=1, opt_state_load_path=save_opt)
81+
assert extras["checkpoint"]["rng_key_loaded"] is True

0 commit comments

Comments
 (0)