Skip to content

Commit 6d051bc

Browse files
committed
feat(v5-g22): hybrid optimizer per-bucket update delta divergence
Closes V5-G22 / cppmega-mlx-3p2. V4-9 only reported bucket sizes (counts); G22 proves the underlying update math actually diverges between Muon (sign-of-grad NS-orthogonalised 2-D matmul weights) and AdamW (adaptive 2nd-moment 1-D / 3-D+ tensors). Backend (stages.py): - When optimizer_kind==muon_adamw_hybrid, snapshot per-bucket parameters pre-train (split_param_groups predicate) - Post-train: _compute_hybrid_deltas computes L2 update delta per bucket; reports {muon_norm, adamw_norm, ratio} - Different optimizer math → ratio ≠ 1.0 E2E (1 in 51_hybrid_delta.spec.ts): - UI hybrid → Train → both bucket norms > 0 AND |ratio - 1| > 0.05 (5% divergence floor — would fail if both buckets received identical updates) 43 stage_train pytest unchanged.
1 parent b2b6f29 commit 6d051bc

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

cppmega_v4/runner/stages.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,13 @@ def loss_fn(model: nn.Module, emb: mx.array, tgt: mx.array) -> mx.array:
565565
# prove the split predicate actually saw 2D vs 1D/3D parameters.
566566
muon_group_size: int | None = None
567567
adamw_group_size: int | None = None
568+
# G22: snapshot pre-train per-bucket param norms so post-train we
569+
# can compute the per-bucket update delta. Muon and AdamW produce
570+
# different update math; bucket-specific deltas must diverge.
571+
hybrid_muon_keys: list[str] = []
572+
hybrid_adamw_keys: list[str] = []
573+
hybrid_muon_before: dict[str, Any] = {}
574+
hybrid_adamw_before: dict[str, Any] = {}
568575
if optimizer_kind == "muon_adamw_hybrid":
569576
try:
570577
from cppmega_mlx.training.optimizers import split_param_groups
@@ -576,6 +583,19 @@ def _count(tree: Any) -> int:
576583
if hasattr(v, "size"))
577584
muon_group_size = _count(muon_t)
578585
adamw_group_size = _count(adamw_t)
586+
# G22: per-bucket snapshot
587+
hybrid_muon_keys = list(dict(
588+
nn.utils.tree_flatten(muon_t)).keys())
589+
hybrid_adamw_keys = list(dict(
590+
nn.utils.tree_flatten(adamw_t)).keys())
591+
flat_all = dict(nn.utils.tree_flatten(
592+
all_modules.parameters()))
593+
for k in hybrid_muon_keys:
594+
if k in flat_all:
595+
hybrid_muon_before[k] = mx.array(flat_all[k])
596+
for k in hybrid_adamw_keys:
597+
if k in flat_all:
598+
hybrid_adamw_before[k] = mx.array(flat_all[k])
579599
except Exception:
580600
pass
581601
loss_and_grad = nn.value_and_grad(all_modules, loss_fn)
@@ -745,6 +765,9 @@ def _count(tree: Any) -> int:
745765
),
746766
"muon_group_size": muon_group_size,
747767
"adamw_group_size": adamw_group_size,
768+
"hybrid_deltas": _compute_hybrid_deltas(
769+
after_flat, hybrid_muon_before, hybrid_adamw_before)
770+
if optimizer_kind == "muon_adamw_hybrid" else None,
748771
"inference_probe": {
749772
"l2_diff": round(l2_diff, 6),
750773
"cos_sim": round(cos_sim, 6),
@@ -894,6 +917,34 @@ def _apply_spec_rewriters(
894917
}
895918

896919

920+
def _compute_hybrid_deltas(
921+
after_flat: dict[str, Any],
922+
muon_before: dict[str, Any], adamw_before: dict[str, Any],
923+
) -> dict[str, Any]:
924+
"""G22: per-bucket L2 update delta. Muon (sign-of-grad NS-ortho on
925+
2-D matmul weights) and AdamW (adaptive 2nd moment on 1-D / 3-D+
926+
tensors) MUST produce different update magnitudes for the same
927+
training run, otherwise the hybrid split predicate is decorative."""
928+
import mlx.core as mx_local
929+
930+
def _bucket_norm(before: dict[str, Any]) -> float:
931+
total = 0.0
932+
for k, v in before.items():
933+
if k in after_flat:
934+
diff = after_flat[k] - v
935+
total += float(mx_local.sum(diff * diff).item())
936+
return total ** 0.5
937+
938+
muon_norm = _bucket_norm(muon_before)
939+
adamw_norm = _bucket_norm(adamw_before)
940+
ratio = (muon_norm / adamw_norm) if adamw_norm > 1e-12 else 0.0
941+
return {
942+
"muon_norm": round(muon_norm, 6),
943+
"adamw_norm": round(adamw_norm, 6),
944+
"ratio": round(ratio, 6),
945+
}
946+
947+
897948
def _compute_ifim_extras(
898949
all_modules: Any, lambda_fim: float, batch: int, seq: int, hidden: int,
899950
forward_layers: Any,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// G22: muon_adamw_hybrid produces distinct per-bucket update deltas.
2+
// V4-9 only counted bucket sizes; G22 asserts the update math actually
3+
// diverges between Muon and AdamW groups.
4+
5+
import { test, expect } from "@playwright/test";
6+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
7+
8+
test("G22: hybrid optimizer produces distinct muon/adamw bucket deltas",
9+
async ({ page }) => {
10+
test.setTimeout(60_000);
11+
await gotoApp(page);
12+
await selectPreset(page, "llama3_8b");
13+
14+
await page.getByTestId("sidebar-tab-optim").click();
15+
await page.getByTestId("optim-kind").selectOption("muon_adamw_hybrid");
16+
await page.getByTestId("optim-apply").click();
17+
18+
await page.getByTestId("run-pipeline-toggle").click();
19+
await page.getByTestId("train-num-steps").fill("4");
20+
await page.getByTestId("run-pipeline-train").click();
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+
const muonNorm = parseFloat(
26+
(await page.getByTestId(
27+
"run-result-extras-train-hybrid_deltas-muon_norm").textContent())
28+
?? "0");
29+
const adamwNorm = parseFloat(
30+
(await page.getByTestId(
31+
"run-result-extras-train-hybrid_deltas-adamw_norm").textContent())
32+
?? "0");
33+
const ratio = parseFloat(
34+
(await page.getByTestId(
35+
"run-result-extras-train-hybrid_deltas-ratio").textContent())
36+
?? "0");
37+
38+
// Both buckets must receive updates
39+
expect(muonNorm).toBeGreaterThan(0);
40+
expect(adamwNorm).toBeGreaterThan(0);
41+
// Ratio MUST differ from 1.0 by more than 5% — otherwise the
42+
// hybrid split would have produced identical updates (i.e., the
43+
// two optimizers' math is decorative).
44+
expect(Math.abs(ratio - 1.0)).toBeGreaterThan(0.05);
45+
await closeModal(page);
46+
});

0 commit comments

Comments
 (0)