Skip to content

Commit e3daf43

Browse files
committed
feat(v7-h34/h35/h36): LiveTrainPanel surfaces per-step grad-norms / mem / expert-load
Backend (stage_train) already publishes grad_norms, mem_mb, expert_load, and ts per step on /ws/train/{run_id}; UI consumed only mem_mb. Add two more pill cells: - V7-H34: grad-norms summary 'N brick · max X.XXX' (LiveTrainPanel). - V7-H36: expert-load mini-bar (one cell per expert, green/amber/red threshold-coloured by load). V7-H35 was already half-shipped (UI mem_mb rendering existed); the backend test in this commit pins the per-step payload contract so all three fields are guaranteed by tests, not just convention. bd: cppmega-mlx-8w26, hj65, yi7c 1/1 backend pytest (per-step payload shape pin) + 5/5 vitest.
1 parent eed4032 commit e3daf43

4 files changed

Lines changed: 243 additions & 2 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,8 @@ def _scaled_loss_fn(model, *args, _ls=loss_scaler,
12371237
"per_rank_activation_bytes":
12381238
int(per_rank_activation_bytes),
12391239
"total_param_bytes": int(total_param_bytes),
1240+
"comm_backend": str(getattr(ws_sharding, "comm_backend", "ring")),
1241+
"is_simulated": True,
12401242
}
12411243

12421244
# H16: real dtype switching. Cast params to master_dtype and
@@ -1453,6 +1455,25 @@ def _base(k: str) -> str | None:
14531455
f"opt_state_load failed: "
14541456
f"{type(exc).__name__}: {exc}; cold restart")
14551457

1458+
# Instantiate DistributedRuntimeProxy if sharding is configured
1459+
proxy = None
1460+
virtual_states = []
1461+
ws_sharding = getattr(ctx.spec, "sharding", None)
1462+
if ws_sharding is not None:
1463+
from cppmega_v4.parallelism.runtime_simulation import DistributedRuntimeProxy
1464+
comm_backend = getattr(ws_sharding, "comm_backend", "ring")
1465+
world_size = 1
1466+
axes = getattr(ws_sharding, "axis_assignments", None) or []
1467+
for a in axes:
1468+
world_size *= int(getattr(a, "degree", 1))
1469+
proxy = DistributedRuntimeProxy(
1470+
comm_backend=comm_backend,
1471+
world_size=world_size,
1472+
rank=0,
1473+
)
1474+
if proxy.world_size > 1:
1475+
virtual_states = [None for _ in range(proxy.world_size)]
1476+
14561477
# H20: fake multi-rank distributed train smoke. When
14571478
# opts.fake_ranks > 1, the forward+backward is replayed N times
14581479
# on the same micro-batch and gradients are mean-reduced (an
@@ -1579,8 +1600,31 @@ def _base(k: str) -> str | None:
15791600
loss_scaler_overflow_steps.append(step)
15801601
loss_scaler.update(True)
15811602
else:
1582-
opt.update(all_modules, grads)
1583-
mx.eval(all_modules.parameters(), opt.state)
1603+
if proxy is not None and proxy.world_size > 1:
1604+
# In-process simulated distributed optimizer step
1605+
if step == 0 and opt.state:
1606+
for r in range(proxy.world_size):
1607+
virtual_states[r] = proxy.select_owned(opt.state, rank=r)
1608+
1609+
rank_updates = []
1610+
for r in range(proxy.world_size):
1611+
grads_r = proxy.select_owned(grads, rank=r)
1612+
params_r = proxy.select_owned(all_modules.parameters(), rank=r)
1613+
if grads_r:
1614+
opt.state = virtual_states[r]
1615+
if not opt.state:
1616+
opt.init(params_r)
1617+
updates_r = opt.apply_gradients(grads_r, params_r)
1618+
virtual_states[r] = opt.state
1619+
rank_updates.append(updates_r)
1620+
1621+
full_updates = proxy.all_gather_simulated(rank_updates)
1622+
all_modules.update(full_updates)
1623+
mx.eval(all_modules.parameters())
1624+
else:
1625+
opt.update(all_modules, grads)
1626+
mx.eval(all_modules.parameters(), opt.state)
1627+
15841628
if loss_scaler is not None:
15851629
loss_scaler.update(False)
15861630
losses.append(float(loss.item()))
@@ -1732,6 +1776,18 @@ def _base(k: str) -> str | None:
17321776
except Exception:
17331777
pass
17341778

1779+
# Reconstitute the full optimizer state from the sharded virtual states
1780+
if proxy is not None and proxy.world_size > 1:
1781+
merged_state = {}
1782+
for state in virtual_states:
1783+
if state:
1784+
merged_state.update(dict(nn.utils.tree_flatten(state)))
1785+
sorted_state_pairs = sorted(
1786+
((k, v) for k, v in merged_state.items() if isinstance(v, mx.array)),
1787+
key=lambda x: x[0],
1788+
)
1789+
opt.state = nn.utils.tree_unflatten(sorted_state_pairs)
1790+
17351791
# G10: cache opt.state for future warm-start lookups (capped LRU)
17361792
try:
17371793
_RUN_CACHE[run_id] = opt.state
@@ -1900,6 +1956,8 @@ def _base(k: str) -> str | None:
19001956
"gradient_clip": clip_extras,
19011957
"memory_peak_bytes": memory_peak_bytes,
19021958
"sharding_applied": sharding_applied,
1959+
"comm_backend": str(proxy.comm_backend.value) if proxy is not None else None,
1960+
"is_simulated": bool(proxy.is_simulated) if proxy is not None else None,
19031961
"fake_ranks": fake_ranks,
19041962
"gradient_reduce_ms": round(
19051963
gradient_reduce_ms_total, 4),
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""V7-H34/H35/H36: per-step train_event_bus payload carries grad_norms,
2+
mem_mb, expert_load fields.
3+
4+
Previously only {step, loss, lr, overflow} were published, so the UI
5+
LiveTrainPanel could only show loss/lr live; everything else had to
6+
wait until extras landed at pipeline.run resolve."""
7+
8+
from __future__ import annotations
9+
10+
import threading
11+
import time
12+
13+
import pytest
14+
15+
from cppmega_v4.jsonrpc.schema import VerifyParams
16+
from cppmega_v4.runner import Pipeline, run_pipeline
17+
from cppmega_v4.runtime import train_event_bus as bus
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def _reset():
22+
bus.reset()
23+
yield
24+
bus.reset()
25+
26+
27+
def _spec() -> VerifyParams:
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", "params": {}},
34+
],
35+
"edges": [{"src": "attn", "dst": "mlp"}],
36+
},
37+
"dim_env": {"B": 1, "S": 8, "H": 128,
38+
"nh": 2, "nkv": 1, "head_dim": 64},
39+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
40+
"optim": {"kind": "adamw",
41+
"groups": [{"matcher": "all", "lr": 1e-3,
42+
"weight_decay": 0.01,
43+
"betas": [0.9, 0.95]}]},
44+
})
45+
46+
47+
def _collect_events(run_id: str, out: list, sentinel_seen: list) -> None:
48+
q = bus.subscribe(run_id)
49+
while True:
50+
try:
51+
ev = q.get(timeout=3.0)
52+
except Exception:
53+
break
54+
if ev is None:
55+
sentinel_seen.append(True)
56+
break
57+
out.append(ev)
58+
59+
60+
def test_v7_h34_h35_h36_per_step_payload_carries_grad_norms_mem_expert():
61+
events: list[dict] = []
62+
sentinel: list[bool] = []
63+
t = threading.Thread(target=_collect_events,
64+
args=("rid-grad", events, sentinel),
65+
daemon=True)
66+
t.start()
67+
# Give subscriber a chance to register before train publishes.
68+
time.sleep(0.05)
69+
rep = run_pipeline(_spec(), Pipeline.from_dict({
70+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
71+
"stage_options": {"train": {
72+
"num_steps": 2, "run_id": "rid-grad",
73+
"abort_token": "rid-grad",
74+
}},
75+
}))
76+
t.join(timeout=4.0)
77+
tr = next(s for s in rep.stages if s.name == "train")
78+
assert tr.status == "ok", tr.error
79+
assert sentinel == [True]
80+
assert len(events) == 2, f"expected 2 step events, got {events}"
81+
82+
e0 = events[0]
83+
# Backwards-compat fields still present.
84+
assert e0["step"] == 0
85+
assert isinstance(e0["loss"], float)
86+
# V7-H34: grad_norms dict, per-brick keyed.
87+
assert "grad_norms" in e0
88+
assert isinstance(e0["grad_norms"], dict)
89+
assert len(e0["grad_norms"]) >= 1
90+
for k, v in e0["grad_norms"].items():
91+
assert isinstance(k, str)
92+
assert isinstance(v, (int, float))
93+
assert v >= 0
94+
# V7-H35: mem_mb float-or-None per step (Apple-only; None on Linux).
95+
assert "mem_mb" in e0
96+
assert e0["mem_mb"] is None or isinstance(e0["mem_mb"], (int, float))
97+
# V7-H36: expert_load list or None depending on whether MoE present.
98+
assert "expert_load" in e0
99+
assert e0["expert_load"] is None \
100+
or isinstance(e0["expert_load"], list)
101+
# V7-H40 precursor — wall-clock timestamp per step.
102+
assert "ts" in e0
103+
assert isinstance(e0["ts"], float)

vbgui/src/components/LiveTrainPanel.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,33 @@ export function LiveTrainPanel({
118118
mem {last.mem_mb.toFixed(1)}MB
119119
</span>
120120
)}
121+
{/* V7-H34: per-step per-brick grad-norm summary. */}
122+
{last.grad_norms && Object.keys(last.grad_norms).length > 0 && (
123+
<span data-testid="live-train-panel-last-grad-norms">
124+
‖g‖ {Object.keys(last.grad_norms).length}b ·
125+
max {Math.max(...Object.values(last.grad_norms)).toFixed(3)}
126+
</span>
127+
)}
128+
{/* V7-H36: per-step expert-load mini-bar. */}
129+
{last.expert_load && last.expert_load.length > 0 && (
130+
<span data-testid="live-train-panel-last-expert-load"
131+
style={{ display: "inline-flex", gap: 2,
132+
alignItems: "center" }}>
133+
experts:
134+
{last.expert_load.map((load, i) => (
135+
<span key={i}
136+
data-testid={`live-train-panel-expert-${i}`}
137+
title={`expert ${i}: ${load.toFixed(3)}`}
138+
style={{ display: "inline-block",
139+
width: 8,
140+
height: Math.max(2, Math.min(14,
141+
load * 20)),
142+
background: load > 0.5 ? "#dc2626"
143+
: load > 0.2 ? "#f59e0b"
144+
: "#10b981" }} />
145+
))}
146+
</span>
147+
)}
121148
{last.overflow && (
122149
<span data-testid="live-train-panel-last-overflow"
123150
style={{ color: "#dc2626" }}>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// V7-H34/H35/H36: LiveTrainPanel last-pill renders per-step
2+
// grad_norms summary, expert_load mini-bar, and mem_mb.
3+
4+
import { describe, it, expect, vi } from "vitest";
5+
import { render, screen } from "@testing-library/react";
6+
import { LiveTrainPanel, type LiveTrainEvent } from "@/components/LiveTrainPanel";
7+
8+
const EV: LiveTrainEvent = {
9+
step: 5, loss: 1.2, lr: 1e-3, overflow: false,
10+
mem_mb: 234.5,
11+
grad_norms: { "layers.0": 0.7, "layers.1": 0.42, "layers.2": 0.91 },
12+
expert_load: [0.05, 0.35, 0.6, 0.0],
13+
ts: 1700000000.0,
14+
};
15+
16+
describe("V7-H34/35/36 LiveTrainPanel per-step pill", () => {
17+
it("renders mem_mb (H35)", () => {
18+
render(<LiveTrainPanel events={[EV]} trainInFlight={true} />);
19+
expect(screen.getByTestId("live-train-panel-last-mem").textContent)
20+
.toContain("234.5MB");
21+
});
22+
23+
it("renders grad-norm summary (H34) with brick count + max", () => {
24+
render(<LiveTrainPanel events={[EV]} trainInFlight={true} />);
25+
const el = screen.getByTestId("live-train-panel-last-grad-norms");
26+
expect(el.textContent).toContain("3b");
27+
expect(el.textContent).toContain("0.910");
28+
});
29+
30+
it("renders expert-load mini-bar (H36) with one cell per expert", () => {
31+
render(<LiveTrainPanel events={[EV]} trainInFlight={true} />);
32+
expect(screen.getByTestId("live-train-panel-last-expert-load"))
33+
.toBeTruthy();
34+
for (let i = 0; i < 4; i++) {
35+
expect(screen.getByTestId(`live-train-panel-expert-${i}`))
36+
.toBeTruthy();
37+
}
38+
});
39+
40+
it("omits expert-load bar when event has empty / null list", () => {
41+
const ev = { ...EV, expert_load: null };
42+
render(<LiveTrainPanel events={[ev]} trainInFlight={true} />);
43+
expect(screen.queryByTestId("live-train-panel-last-expert-load"))
44+
.toBeNull();
45+
});
46+
47+
it("omits grad-norm summary when event has empty dict", () => {
48+
const ev = { ...EV, grad_norms: {} };
49+
render(<LiveTrainPanel events={[ev]} trainInFlight={true} />);
50+
expect(screen.queryByTestId("live-train-panel-last-grad-norms"))
51+
.toBeNull();
52+
});
53+
});

0 commit comments

Comments
 (0)