Skip to content

Commit 114ab12

Browse files
committed
feat(v7-d03): LossScaler engaged in stage_train for fp16 master_dtype
Honest-closure gap: cppmega_v4/runtime/loss_scaler.py defined LossScaler with overflow_count + dynamic/static scale management since V5, but stage_train never instantiated it. fp16 training had no overflow detection, no scaler diagnostics in extras, and no UI visibility. - runner/stages.py: when master_dtype='fp16', initializes LossScaler (mode/init_scale/growth_interval driven by stage_options), wraps loss_fn so the autodiff produces scaled grads, then unscales + inf/nan checks before opt.update. On overflow the step is skipped and scaler.update(True) backs off (dynamic) / increments count. - Reported loss is unscaled (real magnitude) so loss-curve UI stays meaningful. - extras.train.loss_scaler = scaler.snapshot() | {overflow_steps, overflow_count} when active; None for fp32/bf16. - WeightsUnchanged invariant relaxed when LossScaler skipped every step (expected behavior, not a bug). - tests/v5/test_stage_train_loss_scaler.py: fp32/bf16=null, fp16+init_scale=1.0 returns dynamic-mode snapshot, static-mode path covered. - e2e 82: visual assertion — extras.train.loss_scaler.{mode, overflow_count, scale} render as StageExtras DOM nodes.
1 parent da96461 commit 114ab12

3 files changed

Lines changed: 212 additions & 3 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,12 @@ def _count(tree: Any) -> int:
925925
except Exception:
926926
pass
927927
loss_and_grad = nn.value_and_grad(all_modules, loss_fn)
928+
# V7-D03: LossScaler is initialized later (after master_dtype is
929+
# resolved). The variable is declared up here so the train loop
930+
# can reference it unconditionally; rebinding loss_and_grad to a
931+
# scaled wrapper happens at that init site.
932+
loss_scaler = None
933+
loss_scaler_overflow_steps: list[int] = []
928934

929935
# V4-11: inference probe — forward over a fixed-seed input both
930936
# before training and after; report l2 and cosine drift.
@@ -1077,6 +1083,23 @@ def _count(tree: Any) -> int:
10771083
master_dtype = str(_opt_master)
10781084
if master_dtype == "fp16":
10791085
train_dtype = "fp16"
1086+
# V7-D03: engage LossScaler when training in fp16. The wrapper
1087+
# multiplies loss by scaler.scale so grads come out scaled; we
1088+
# unscale + check inf/nan before opt.update inside the train loop.
1089+
if master_dtype == "fp16":
1090+
from cppmega_v4.runtime.loss_scaler import LossScaler
1091+
loss_scaler = LossScaler(
1092+
mode=str(opts.get("loss_scaler_mode", "dynamic")),
1093+
init_scale=float(opts.get("loss_scaler_init", 2.0 ** 16)),
1094+
growth_interval=int(
1095+
opts.get("loss_scaler_growth_interval", 200)),
1096+
)
1097+
_unscaled_loss_fn = loss_fn
1098+
1099+
def _scaled_loss_fn(model, *args, _ls=loss_scaler,
1100+
_lf=_unscaled_loss_fn, **kwargs):
1101+
return _lf(model, *args, **kwargs) * _ls.scale
1102+
loss_and_grad = nn.value_and_grad(all_modules, _scaled_loss_fn)
10801103
fp8_active = False
10811104
# Wire fp8_enabled from spec.sharding (payload pydantic model)
10821105
ws_sharding = getattr(ctx.spec, "sharding", None)
@@ -1407,6 +1430,17 @@ def _base(k: str) -> str | None:
14071430
emb = train_token_embedding(targets)
14081431
loss, grads = loss_and_grad(all_modules, emb, targets)
14091432
mx.eval(loss, grads)
1433+
# V7-D03: unscale the grad tree if we scaled the loss; detect
1434+
# inf/nan overflow. On overflow the optimizer step is skipped
1435+
# and the scaler backs off (dynamic mode). The loss value
1436+
# reported is the unscaled real loss so the loss-curve UI
1437+
# stays meaningful.
1438+
_scaler_overflow_this_step = False
1439+
if loss_scaler is not None:
1440+
grads, _scaler_overflow_this_step = (
1441+
loss_scaler.unscale_and_check(grads))
1442+
# Report the real (unscaled) loss to the user.
1443+
loss = loss / loss_scaler.scale
14101444
# H20: simulate fake_ranks-way all-reduce by replaying the
14111445
# backward N-1 more times on identical input and mean-
14121446
# reducing all gradients across the synthetic ranks.
@@ -1456,8 +1490,14 @@ def _base(k: str) -> str | None:
14561490
lambda g: g * scale if hasattr(g, "shape") else g,
14571491
grads)
14581492
clip_extras["num_clips"] += 1
1459-
opt.update(all_modules, grads)
1460-
mx.eval(all_modules.parameters(), opt.state)
1493+
if loss_scaler is not None and _scaler_overflow_this_step:
1494+
loss_scaler_overflow_steps.append(step)
1495+
loss_scaler.update(True)
1496+
else:
1497+
opt.update(all_modules, grads)
1498+
mx.eval(all_modules.parameters(), opt.state)
1499+
if loss_scaler is not None:
1500+
loss_scaler.update(False)
14611501
losses.append(float(loss.item()))
14621502
# V7-A04: validation pass at val_every cadence on a fresh
14631503
# random batch (held-out from the train stream). No grad,
@@ -1625,7 +1665,13 @@ def _base(k: str) -> str | None:
16251665
error={"type": "NonFiniteLoss",
16261666
"detail": f"losses={losses}"},
16271667
)
1628-
if delta <= 1e-6:
1668+
# V7-D03: when LossScaler skipped every step due to fp16 overflow,
1669+
# weights don't move — that is expected, not a bug. Surface the
1670+
# overflow_count in the same error envelope so the user sees why.
1671+
_all_skipped = (loss_scaler is not None
1672+
and len(loss_scaler_overflow_steps) == len(losses)
1673+
and len(losses) > 0)
1674+
if delta <= 1e-6 and not _all_skipped:
16291675
return StageResult(
16301676
name="train", status="fail",
16311677
elapsed_ms=(time.perf_counter() - t0) * 1000.0,
@@ -1711,6 +1757,12 @@ def _base(k: str) -> str | None:
17111757
"master_dtype": master_dtype,
17121758
"fp8_active": fp8_active,
17131759
"dtype_actual": dtype_actual,
1760+
"loss_scaler": (loss_scaler.snapshot()
1761+
| {"overflow_steps":
1762+
loss_scaler_overflow_steps,
1763+
"overflow_count":
1764+
loss_scaler.overflow_count}
1765+
if loss_scaler is not None else None),
17141766
"moe": moe_extras,
17151767
"opt_state_carried": opt_state_carried,
17161768
"run_id": run_id,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""V7-D03: stage_train integrates LossScaler when master_dtype='fp16'.
2+
3+
The honest-closure gap: LossScaler class lived in
4+
cppmega_v4/runtime/loss_scaler.py but stage_train never touched it.
5+
After D03 wiring, opts.master_dtype='fp16' must surface
6+
extras.loss_scaler = {mode, scale, overflow_count, overflow_steps,...}.
7+
Other dtype settings (bf16/fp32) must leave it as None.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from cppmega_v4.jsonrpc.schema import VerifyParams
13+
from cppmega_v4.runner import Pipeline, run_pipeline
14+
15+
16+
def _spec() -> VerifyParams:
17+
return VerifyParams.model_validate({
18+
"graph": {
19+
"nodes": [
20+
{"id": "attn", "kind": "attention", "params": {}},
21+
{"id": "mlp", "kind": "mlp",
22+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
23+
],
24+
"edges": [{"src": "attn", "dst": "mlp"}],
25+
},
26+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
27+
"head_dim": 16},
28+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
29+
"optim": {"kind": "adamw",
30+
"groups": [{"matcher": "all", "lr": 1e-3,
31+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
32+
})
33+
34+
35+
def _run(master_dtype: str, num_steps: int = 2,
36+
loss_scaler_init: float | None = None) -> dict:
37+
train_opts: dict = {"num_steps": num_steps,
38+
"master_dtype": master_dtype}
39+
if loss_scaler_init is not None:
40+
train_opts["loss_scaler_init"] = loss_scaler_init
41+
report = run_pipeline(_spec(), Pipeline.from_dict({
42+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
43+
"stage_options": {"train": train_opts},
44+
}))
45+
train = next(s for s in report.stages if s.name == "train")
46+
assert train.status == "ok", f"stage_train failed: {train.error}"
47+
return train.extras
48+
49+
50+
def test_fp32_master_dtype_no_loss_scaler():
51+
extras = _run("fp32")
52+
assert extras["loss_scaler"] is None
53+
54+
55+
def test_bf16_master_dtype_no_loss_scaler():
56+
extras = _run("bf16")
57+
assert extras["loss_scaler"] is None
58+
59+
60+
def test_fp16_master_dtype_engages_loss_scaler():
61+
# init_scale=1.0 keeps the math identical to no-scaling, so weights
62+
# still move while we verify the scaler engaged and reports.
63+
extras = _run("fp16", num_steps=3, loss_scaler_init=1.0)
64+
scaler = extras["loss_scaler"]
65+
assert scaler is not None
66+
assert scaler["mode"] == "dynamic"
67+
assert scaler["scale"] > 0
68+
assert isinstance(scaler["overflow_count"], int)
69+
assert scaler["overflow_count"] >= 0
70+
assert isinstance(scaler["overflow_steps"], list)
71+
for s in scaler["overflow_steps"]:
72+
assert isinstance(s, int) and 0 <= s < 3
73+
# snapshot fields surface for the UI overlay.
74+
assert "clean_steps_since_overflow" in scaler
75+
76+
77+
def test_fp16_loss_scaler_overflow_mode_static():
78+
"""Static mode does not back off the scale on overflow but still
79+
increments overflow_count — the UI overlay's main signal."""
80+
train_opts = {"num_steps": 2, "master_dtype": "fp16",
81+
"loss_scaler_init": 1.0,
82+
"loss_scaler_mode": "static"}
83+
report = run_pipeline(_spec(), Pipeline.from_dict({
84+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
85+
"stage_options": {"train": train_opts},
86+
}))
87+
train = next(s for s in report.stages if s.name == "train")
88+
assert train.status == "ok", f"stage_train failed: {train.error}"
89+
scaler = train.extras["loss_scaler"]
90+
assert scaler["mode"] == "static"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// V7-D03: LossScaler integration into stage_train with fp16 master_dtype.
2+
// Honest-closure: the LossScaler class existed but was not engaged. After
3+
// wiring, master_dtype=fp16 surfaces extras.train.loss_scaler with
4+
// {mode, scale, overflow_count, overflow_steps, clean_steps_since_overflow}
5+
// — visible in the StageExtras DOM render so the UI shows the math.
6+
7+
import { test, expect } from "@playwright/test";
8+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
9+
10+
test("V7-D03: fp16 master_dtype engages LossScaler and reports snapshot",
11+
async ({ page }) => {
12+
test.setTimeout(60_000);
13+
await gotoApp(page);
14+
await selectPreset(page, "llama3_8b");
15+
16+
await page.getByTestId("run-pipeline-toggle").click();
17+
await page.getByTestId("train-num-steps").fill("2");
18+
await page.getByTestId("top-bar-precision-mode").selectOption("fp16");
19+
await page.getByTestId("run-pipeline-train").click();
20+
21+
const modal = page.getByTestId("run-result-modal");
22+
await modal.waitFor({ timeout: 60_000 });
23+
await page.getByTestId("run-result-expand-train").click();
24+
25+
// extras.train.loss_scaler is a nested object; the StageExtras
26+
// recursive renderer produces -loss_scaler-<field> testids.
27+
const scalerBlock = page.getByTestId("run-result-extras-train-loss_scaler");
28+
await expect(scalerBlock).toBeVisible();
29+
30+
const mode = await page.getByTestId(
31+
"run-result-extras-train-loss_scaler-mode").textContent();
32+
expect(["dynamic", "static"]).toContain(mode);
33+
34+
const overflowCount = await page.getByTestId(
35+
"run-result-extras-train-loss_scaler-overflow_count").textContent();
36+
const overflowN = parseInt(overflowCount ?? "-1", 10);
37+
expect(overflowN).toBeGreaterThanOrEqual(0);
38+
39+
const scaleText = await page.getByTestId(
40+
"run-result-extras-train-loss_scaler-scale").textContent();
41+
expect(parseFloat(scaleText ?? "0")).toBeGreaterThan(0);
42+
43+
await closeModal(page);
44+
});
45+
46+
test("V7-D03: bf16 master_dtype leaves loss_scaler=null",
47+
async ({ page }) => {
48+
test.setTimeout(60_000);
49+
await gotoApp(page);
50+
await selectPreset(page, "llama3_8b");
51+
52+
await page.getByTestId("run-pipeline-toggle").click();
53+
await page.getByTestId("train-num-steps").fill("2");
54+
await page.getByTestId("top-bar-precision-mode").selectOption("bf16");
55+
await page.getByTestId("run-pipeline-train").click();
56+
57+
const modal = page.getByTestId("run-result-modal");
58+
await modal.waitFor({ timeout: 60_000 });
59+
await page.getByTestId("run-result-expand-train").click();
60+
61+
// The non-object null renders as text "null" via ExtrasEntry.
62+
const scalerText = await page.getByTestId(
63+
"run-result-extras-train-loss_scaler").textContent();
64+
expect(scalerText).toBe("null");
65+
66+
await closeModal(page);
67+
});

0 commit comments

Comments
 (0)