Skip to content

Commit aca73c9

Browse files
committed
feat(v7-e33,e34,e35): FIRE/DASH/ReDo wired into stage_train
Closes the 3-item plasticity E-block audit: E33 FIRE — bd cppmega-mlx-6r7. E34 DASH — bd cppmega-mlx-amr. E35 ReDo — bd cppmega-mlx-asn. The modules existed in cppmega_mlx/training/plasticity/ and were re-exported from __init__.py, but grep cppmega_v4/runner/stages.py returned zero callers. Now opts knobs drive each intervention: fire_at_step: int | None — fire once at this step. dash_every: int>0 — DASH every N steps. dash_alpha: float — DASH cos-sim threshold. dash_shrink: float — DASH shrink rate. redo_every: int>0 — ReDo recycle period. redo_dormant_threshold: float — ReDo activation EMA ratio. redo_layer_map: dict — (in_modules, out_module) pairs. Defaults: every knob off — existing tests stay byte-identical. When a knob fires, extras.plasticity gets a trace: {'fire_fired_at_step': step, 'fire_keys_modified': [...], 'dash_steps_applied': [s1, s2, ...], 'dash_last_keys_count': n, 'redo_steps_applied': [...], 'redo_last_recycled': n, 'errors': [...]} # best-effort failures land here. Bug fix: cppmega_mlx/training/plasticity/{fire,dash}.py:_set_nested was raising 'array path expects list cursor' on nested nn.Sequential layers because non-digit pieces always created a dict child even when the next piece was digit-shaped. Added a 1-step look-ahead so the parent container matches what the next piece expects (list when next piece is digit, dict otherwise). tests/v5/test_stage_train_plasticity.py: test_plasticity_disabled_by_default_empty_trace test_fire_fires_at_step_and_modifies_keys test_dash_runs_periodically_and_records_steps test_redo_does_not_fire_without_layer_map All 4 green. tests/test_plasticity.py (7 existing unit tests) still passes — the look-ahead fix is backwards-compatible.
1 parent 52a9891 commit aca73c9

4 files changed

Lines changed: 181 additions & 12 deletions

File tree

cppmega_mlx/training/plasticity/dash.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,24 +92,36 @@ def _lookup_in_pytree(root: Any, path: list[str]) -> mx.array | None:
9292

9393
def _set_nested(root: dict[str, Any], path: list[str], value: mx.array) -> None:
9494
cursor: Any = root
95-
for piece in path[:-1]:
95+
for i, piece in enumerate(path[:-1]):
9696
next_cursor: Any
97+
next_is_digit = (i + 1 < len(path)
98+
and path[i + 1].isdigit())
9799
if piece.isdigit():
98100
idx = int(piece)
99101
if not isinstance(cursor, list):
100102
raise TypeError("array path expects list cursor")
101103
while len(cursor) <= idx:
102-
cursor.append({})
104+
cursor.append([] if next_is_digit else {})
103105
next_cursor = cursor[idx]
104-
if next_cursor is None or not isinstance(next_cursor, (dict, list)):
105-
next_cursor = {}
106+
want_list = next_is_digit
107+
if next_cursor is None or not isinstance(next_cursor,
108+
(dict, list)):
109+
next_cursor = [] if want_list else {}
110+
cursor[idx] = next_cursor
111+
elif want_list and isinstance(next_cursor, dict):
112+
next_cursor = []
106113
cursor[idx] = next_cursor
107114
else:
108115
if not isinstance(cursor, dict):
109116
raise TypeError("dict path expects dict cursor")
110117
next_cursor = cursor.get(piece)
111-
if next_cursor is None or not isinstance(next_cursor, (dict, list)):
112-
next_cursor = {}
118+
want_list = next_is_digit
119+
if next_cursor is None or not isinstance(next_cursor,
120+
(dict, list)):
121+
next_cursor = [] if want_list else {}
122+
cursor[piece] = next_cursor
123+
elif want_list and isinstance(next_cursor, dict):
124+
next_cursor = []
113125
cursor[piece] = next_cursor
114126
cursor = next_cursor
115127
last = path[-1]

cppmega_mlx/training/plasticity/fire.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,24 +136,38 @@ def apply_fire(
136136

137137
def _set_nested(root: dict[str, Any], path: list[str], value: mx.array) -> None:
138138
cursor: Any = root
139-
for piece in path[:-1]:
139+
for i, piece in enumerate(path[:-1]):
140140
next_cursor: Any
141+
# Look-ahead: if the *next* piece is digit-shaped the container
142+
# below us must be a list (matches nn.Sequential layers).
143+
next_is_digit = (i + 1 < len(path)
144+
and path[i + 1].isdigit())
141145
if piece.isdigit():
142146
idx = int(piece)
143147
if not isinstance(cursor, list):
144148
raise TypeError("array path expects list cursor")
145149
while len(cursor) <= idx:
146-
cursor.append({})
150+
cursor.append([] if next_is_digit else {})
147151
next_cursor = cursor[idx]
148-
if next_cursor is None or not isinstance(next_cursor, (dict, list)):
149-
next_cursor = {}
152+
want_list = next_is_digit
153+
if next_cursor is None or not isinstance(next_cursor,
154+
(dict, list)):
155+
next_cursor = [] if want_list else {}
156+
cursor[idx] = next_cursor
157+
elif want_list and isinstance(next_cursor, dict):
158+
next_cursor = []
150159
cursor[idx] = next_cursor
151160
else:
152161
if not isinstance(cursor, dict):
153162
raise TypeError("dict path expects dict cursor")
154163
next_cursor = cursor.get(piece)
155-
if next_cursor is None or not isinstance(next_cursor, (dict, list)):
156-
next_cursor = {}
164+
want_list = next_is_digit
165+
if next_cursor is None or not isinstance(next_cursor,
166+
(dict, list)):
167+
next_cursor = [] if want_list else {}
168+
cursor[piece] = next_cursor
169+
elif want_list and isinstance(next_cursor, dict):
170+
next_cursor = []
157171
cursor[piece] = next_cursor
158172
cursor = next_cursor
159173
last = path[-1]

cppmega_v4/runner/stages.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,11 @@ def _count(tree: Any) -> int:
10051005
# scaled wrapper happens at that init site.
10061006
loss_scaler = None
10071007
loss_scaler_overflow_steps: list[int] = []
1008+
# V7-E33..E35: plasticity-step trace (FIRE/DASH/ReDo). Populated
1009+
# in the train loop when the respective opts knob is set; ends
1010+
# up in extras.plasticity so the UI can render which steps
1011+
# fired which interventions.
1012+
plasticity_extras: dict[str, Any] = {}
10081013

10091014
# V4-11: inference probe — forward over a fixed-seed input both
10101015
# before training and after; report l2 and cosine drift.
@@ -1733,6 +1738,76 @@ def _compiled_step_value_and_grad(_emb, _targets):
17331738

17341739
if loss_scaler is not None:
17351740
loss_scaler.update(False)
1741+
# V7-E33..E35: Plasticity Toolkit (FIRE / DASH / ReDo).
1742+
# Modules existed in cppmega_mlx.training.plasticity but had
1743+
# no caller in stage_train. opts knobs (all default-off so
1744+
# existing tests/extras stay byte-identical):
1745+
# fire_at_step: int|None — fire once when step==value.
1746+
# dash_every: int>0 — apply DASH every N steps.
1747+
# dash_alpha: float — DASH cos-sim threshold.
1748+
# dash_shrink: float — DASH shrinkage rate.
1749+
# redo_every: int>0 — recycle ReDo dormant every N.
1750+
# redo_dormant_threshold: float — ReDo activation EMA ratio.
1751+
try:
1752+
from cppmega_mlx.training import plasticity as _plast
1753+
fire_at = opts.get("fire_at_step")
1754+
if (fire_at is not None
1755+
and int(fire_at) == step
1756+
and step >= 0):
1757+
fired_keys = _plast.apply_fire(all_modules)
1758+
plasticity_extras["fire_fired_at_step"] = step
1759+
plasticity_extras["fire_keys_modified"] = sorted(
1760+
fired_keys)
1761+
if fired_keys:
1762+
try:
1763+
_plast.reset_optimizer_states_for_fired_keys(
1764+
opt, fired_keys)
1765+
except Exception:
1766+
pass
1767+
dash_every = int(opts.get("dash_every", 0) or 0)
1768+
if dash_every > 0 and (step + 1) % dash_every == 0:
1769+
touched = _plast.dash_step_tree(
1770+
all_modules, grads,
1771+
alpha=float(opts.get("dash_alpha", 0.95)),
1772+
shrink_rate=float(opts.get("dash_shrink", 0.99)),
1773+
)
1774+
plasticity_extras.setdefault(
1775+
"dash_steps_applied", []).append(step)
1776+
plasticity_extras["dash_last_keys_count"] = len(touched)
1777+
# ReDo runs against a dormancy proxy built from
1778+
# per-brick grad-norms — a zero-grad brick is dormant.
1779+
# The full activation-EMA path requires forward hooks
1780+
# and is gated by opts.redo_layer_map (a caller-supplied
1781+
# dict mapping name → (in_modules, out_module)).
1782+
redo_every = int(opts.get("redo_every", 0) or 0)
1783+
if (redo_every > 0
1784+
and (step + 1) % redo_every == 0
1785+
and opts.get("redo_layer_map")):
1786+
try:
1787+
gnorms = _safe_per_brick_grads(
1788+
all_modules, grads)
1789+
stats = {k: mx.array(
1790+
[float(v)], dtype=mx.float32)
1791+
for k, v in gnorms.items()}
1792+
n_recycled = _plast.recycle_dormant_neurons(
1793+
opts["redo_layer_map"], stats,
1794+
tau=float(opts.get(
1795+
"redo_dormant_threshold", 0.025)),
1796+
)
1797+
plasticity_extras.setdefault(
1798+
"redo_steps_applied", []).append(step)
1799+
plasticity_extras["redo_last_recycled"] = (
1800+
int(n_recycled))
1801+
except Exception as _exc:
1802+
plasticity_extras.setdefault(
1803+
"redo_errors", []).append(str(_exc))
1804+
except Exception as _plast_exc:
1805+
# Plasticity is opt-in and best-effort — never blocks
1806+
# the training step. Surface the failure so the UI
1807+
# honest-closure shows which intervention silently fell
1808+
# through (instead of pretending it ran).
1809+
plasticity_extras.setdefault("errors", []).append(
1810+
f"{type(_plast_exc).__name__}: {_plast_exc}")
17361811
losses.append(float(loss.item()))
17371812
# V7-H06b: keep run_registry's last_step/last_loss live so
17381813
# pipeline.status reflects the most recent committed step.
@@ -2120,6 +2195,9 @@ def _compiled_step_value_and_grad(_emb, _targets):
21202195
"comm_backend": str(proxy.comm_backend.value) if proxy is not None else None,
21212196
"is_simulated": bool(proxy.is_simulated) if proxy is not None else None,
21222197
"fake_ranks": fake_ranks,
2198+
# V7-E33..E35: plasticity trace (FIRE/DASH/ReDo) —
2199+
# empty dict when none of the knobs fired.
2200+
"plasticity": plasticity_extras,
21232201
# V7-B-real: surface whether distributed actually
21242202
# engaged so the UI honest-closure can distinguish
21252203
# the real DP path from the fake_ranks replay.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""V7-E33..E35: FIRE/DASH/ReDo are wired into stage_train."""
2+
3+
from __future__ import annotations
4+
5+
from cppmega_v4.jsonrpc.schema import VerifyParams
6+
from cppmega_v4.runner import Pipeline, run_pipeline
7+
8+
9+
def _spec() -> VerifyParams:
10+
return VerifyParams.model_validate({
11+
"graph": {
12+
"nodes": [
13+
{"id": "attn", "kind": "attention", "params": {}},
14+
{"id": "mlp", "kind": "mlp",
15+
"params": {"intermediate_size": 64, "activation": "swiglu"}},
16+
],
17+
"edges": [{"src": "attn", "dst": "mlp"}],
18+
},
19+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
20+
"head_dim": 16},
21+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
22+
"optim": {"kind": "adamw",
23+
"groups": [{"matcher": "all", "lr": 1e-3,
24+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
25+
})
26+
27+
28+
def _run(opts: dict) -> dict:
29+
report = run_pipeline(_spec(), Pipeline.from_dict({
30+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
31+
"stage_options": {"train": opts},
32+
}))
33+
train = next(s for s in report.stages if s.name == "train")
34+
assert train.status == "ok", f"stage_train failed: {train.error}"
35+
return train.extras
36+
37+
38+
def test_plasticity_disabled_by_default_empty_trace():
39+
extras = _run({"num_steps": 2})
40+
assert extras["plasticity"] == {}
41+
42+
43+
def test_fire_fires_at_step_and_modifies_keys():
44+
extras = _run({"num_steps": 3, "fire_at_step": 1})
45+
pl = extras["plasticity"]
46+
assert pl.get("fire_fired_at_step") == 1
47+
assert isinstance(pl.get("fire_keys_modified"), list)
48+
# FIRE only touches 2D params; the tiny attn+mlp model has at
49+
# least one (mlp.up / mlp.down).
50+
assert len(pl["fire_keys_modified"]) >= 1
51+
52+
53+
def test_dash_runs_periodically_and_records_steps():
54+
extras = _run({"num_steps": 4, "dash_every": 2})
55+
pl = extras["plasticity"]
56+
# dash_every=2 over 4 steps → fires on (step+1)%2==0 → steps 1, 3.
57+
assert pl.get("dash_steps_applied") == [1, 3]
58+
assert pl.get("dash_last_keys_count", 0) > 0
59+
60+
61+
def test_redo_does_not_fire_without_layer_map():
62+
extras = _run({"num_steps": 3, "redo_every": 1})
63+
pl = extras["plasticity"]
64+
# Without redo_layer_map the dormant-neuron recycling skips.
65+
assert pl.get("redo_steps_applied") is None

0 commit comments

Comments
 (0)