Skip to content

Commit 6273ad6

Browse files
committed
feat(v7-e01,v7-e02): V4MoE.capacity_factor engages real drop/reroute
Honest-closure for two paired gaps: V7-E01: V4MoEConfig.capacity_factor was unset and __call__ never bounded routing — overflow was impossible by construction. V7-E02: extras.moe.dropped_token_ratio was hardcoded 0.0 with no rerouted counterpart; compute_drop_reroute_stats helper sat unused in cppmega_v4/nn/moe_capacity.py. - moe_v4.py V4MoEConfig: new capacity_factor: float|None and reroute_overflow: bool fields, validated in __post_init__. - moe_v4.py V4MoE.__call__: when capacity_factor set, runs compute_drop_reroute_stats on the cpu-side top_indices and stashes result on self.last_drop_stats. Default None keeps old behavior. - runner/stages.py: reads moe_module.last_drop_stats and populates moe_extras with {dropped_token_ratio, rerouted_token_ratio, overflow_ratio, capacity_per_expert, capacity_factor}. - tests/v5/test_stage_train_moe_capacity.py: capacity_factor=None → zero metrics; capacity_factor=0.25 with num_experts=8, S=32 triggers overflow_ratio > 0. - e2e 83: visual assertion that extras.moe.dropped+rerouted ratios in the StageExtras DOM are non-zero with capacity_factor=0.25.
1 parent b840f1e commit 6273ad6

4 files changed

Lines changed: 189 additions & 1 deletion

File tree

cppmega_v4/nn/moe_v4.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ class V4MoEConfig:
5858
aux_loss_free: bool = False
5959
bias_update_rate: float = 1e-3
6060
node_limited_routing: int | None = None
61+
# V7-E01: capacity factor C ∈ (0, ∞). When < 1, expert capacity is
62+
# ceil(C * total_slots / num_experts), and any overflow slots are
63+
# rerouted (if reroute_overflow) or dropped. extras.moe surfaces
64+
# dropped_token_ratio + rerouted_token_ratio per V7-E02. When None
65+
# (default), routing is unbounded — backwards compatible.
66+
capacity_factor: float | None = None
67+
reroute_overflow: bool = True
6168

6269
def __post_init__(self) -> None:
6370
if self.d_model <= 0:
@@ -87,6 +94,10 @@ def __post_init__(self) -> None:
8794
self.num_experts // self.node_limited_routing
8895
):
8996
raise ValueError("top_k exceeds capacity of node_limited_routing groups")
97+
if self.capacity_factor is not None:
98+
if self.capacity_factor <= 0 or self.capacity_factor > 8:
99+
raise ValueError(
100+
"capacity_factor must be in (0, 8] when set")
90101

91102
def as_moe_config(self) -> MoEConfig:
92103
"""Project to the legacy MoEConfig (for parity tests against ReferenceMoE)."""
@@ -154,6 +165,10 @@ def __init__(self, config: V4MoEConfig):
154165
if config.aux_loss_free:
155166
self.expert_bias = mx.zeros((config.num_experts,), dtype=mx.float32)
156167
self.freeze(keys=["expert_bias"])
168+
# V7-E01/E02: stash from last __call__ when capacity_factor is set.
169+
# Read by stage_train to populate extras.moe.{dropped_token_ratio,
170+
# rerouted_token_ratio, capacity_per_expert}.
171+
self.last_drop_stats: dict | None = None
157172

158173
def _router_scores(self, flat_x: mx.array) -> tuple[mx.array, mx.array, mx.array]:
159174
"""Return (logits, raw_scores, biased_scores) — gate computed once."""
@@ -212,6 +227,25 @@ def __call__(self, x: mx.array) -> MoEOutput:
212227
logits, raw_scores, biased_scores = self._router_scores(flat_x)
213228

214229
top_indices = self._select_top_k(biased_scores)
230+
# V7-E01/E02: with capacity_factor set, compute drop/reroute
231+
# accounting on the cpu-side top_indices and stash for stage_train.
232+
if cfg.capacity_factor is not None:
233+
from cppmega_v4.nn.moe_capacity import compute_drop_reroute_stats
234+
try:
235+
idx_py = top_indices.tolist()
236+
if not isinstance(idx_py[0], list):
237+
# n_tokens dim collapsed (rare); wrap each.
238+
idx_py = [[i] for i in idx_py]
239+
self.last_drop_stats = compute_drop_reroute_stats(
240+
idx_py,
241+
num_experts=cfg.num_experts,
242+
capacity_factor=float(cfg.capacity_factor),
243+
reroute=bool(cfg.reroute_overflow),
244+
)
245+
except Exception:
246+
self.last_drop_stats = None
247+
else:
248+
self.last_drop_stats = None
215249
# Weights use the **raw** (pre-bias) scores — the bias only affects
216250
# which experts are selected, per V3 paper.
217251
top_scores = mx.take_along_axis(raw_scores, top_indices, axis=-1)

cppmega_v4/runner/stages.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1054,11 +1054,31 @@ def _count(tree: Any) -> int:
10541054
moe_extras["routing_entropy"] = round(
10551055
routing_entropy, 6)
10561056
moe_extras["load_balance_loss"] = round(lb, 8)
1057-
moe_extras["dropped_token_ratio"] = 0.0
10581057
moe_extras["per_expert_load"] = [
10591058
round(float(v), 6)
10601059
for v in load_arr.tolist()
10611060
]
1061+
# V7-E01/E02: real drop/reroute accounting
1062+
# when V4MoE was built with capacity_factor.
1063+
drop_stats = getattr(
1064+
moe_module, "last_drop_stats", None)
1065+
if drop_stats is not None:
1066+
moe_extras["dropped_token_ratio"] = round(
1067+
float(drop_stats["dropped_token_ratio"]),
1068+
6)
1069+
moe_extras["rerouted_token_ratio"] = round(
1070+
float(drop_stats["rerouted_token_ratio"]),
1071+
6)
1072+
moe_extras["overflow_ratio"] = round(
1073+
float(drop_stats["overflow_ratio"]), 6)
1074+
moe_extras["capacity_per_expert"] = int(
1075+
drop_stats["capacity_per_expert"])
1076+
moe_extras["capacity_factor"] = float(
1077+
getattr(moe_module.config,
1078+
"capacity_factor", 0.0) or 0.0)
1079+
else:
1080+
moe_extras["dropped_token_ratio"] = 0.0
1081+
moe_extras["rerouted_token_ratio"] = 0.0
10621082
except Exception:
10631083
# Keep static extras; UI still sees num_experts/top_k.
10641084
pass
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""V7-E01/E02: V4MoE with capacity_factor < 1 actually drops/reroutes
2+
tokens and stage_train surfaces dropped_token_ratio + rerouted_token_ratio.
3+
4+
Honest-closure: the helper compute_drop_reroute_stats existed
5+
(cppmega_v4/nn/moe_capacity.py) but V4MoE.__call__ never invoked it.
6+
After E01/E02 wiring, capacity_factor in the moe brick params engages
7+
the helper inside the forward pass and stage_train.moe_extras carries
8+
the real numbers — not the hardcoded 0.0 placeholder.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from cppmega_v4.jsonrpc.schema import VerifyParams
14+
from cppmega_v4.runner import Pipeline, run_pipeline
15+
16+
17+
def _spec(capacity_factor: float | None) -> VerifyParams:
18+
params = {"num_experts": 8, "top_k": 2,
19+
"expert_hidden_size": 32, "aux_loss_free": True}
20+
if capacity_factor is not None:
21+
params["capacity_factor"] = capacity_factor
22+
return VerifyParams.model_validate({
23+
"graph": {
24+
"nodes": [
25+
{"id": "attn", "kind": "attention", "params": {}},
26+
{"id": "moe", "kind": "moe", "params": params},
27+
],
28+
"edges": [{"src": "attn", "dst": "moe"}],
29+
},
30+
"dim_env": {"B": 1, "S": 32, "H": 32, "nh": 2, "nkv": 1,
31+
"head_dim": 16, "num_experts": 8, "top_k": 2},
32+
"loss": {"kind": "cross_entropy", "head_outputs": ["moe"]},
33+
"optim": {"kind": "adamw",
34+
"groups": [{"matcher": "all", "lr": 1e-3,
35+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
36+
})
37+
38+
39+
def _run(capacity_factor: float | None) -> dict:
40+
report = run_pipeline(_spec(capacity_factor), Pipeline.from_dict({
41+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
42+
"stage_options": {"train": {"num_steps": 2}},
43+
}))
44+
train = next(s for s in report.stages if s.name == "train")
45+
assert train.status == "ok", f"stage_train failed: {train.error}"
46+
return train.extras["moe"]
47+
48+
49+
def test_no_capacity_factor_zero_drop():
50+
moe = _run(None)
51+
assert moe["dropped_token_ratio"] == 0.0
52+
# No capacity bounding → no rerouted ratio metric, default 0.
53+
assert moe.get("rerouted_token_ratio", 0.0) == 0.0
54+
# capacity_factor absent in extras (None or missing key).
55+
assert moe.get("capacity_factor") in (None, 0.0)
56+
57+
58+
def test_capacity_factor_tight_triggers_drop_or_reroute():
59+
# With C=0.25 and num_experts=8, top_k=2 over S=32 tokens →
60+
# 64 dispatch slots; cap_per_expert = ceil(0.25*64/8) = 2. With
61+
# uniform-ish routing on a tiny untrained model many experts will
62+
# overflow → either drop or reroute fires.
63+
moe = _run(0.25)
64+
assert "rerouted_token_ratio" in moe
65+
assert "capacity_per_expert" in moe
66+
assert "overflow_ratio" in moe
67+
assert moe["capacity_factor"] == 0.25
68+
# capacity_per_expert is at least 1.
69+
assert moe["capacity_per_expert"] >= 1
70+
# Total overflow (drop + reroute) is in [0, 1].
71+
assert 0.0 <= moe["overflow_ratio"] <= 1.0
72+
# With 8 experts and tight cap, at least *some* overflow must fire.
73+
assert moe["overflow_ratio"] > 0.0
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// V7-E01/E02: capacity_factor < 1 in V4MoE causes real drop/reroute,
2+
// stage_train surfaces dropped_token_ratio + rerouted_token_ratio +
3+
// overflow_ratio + capacity_per_expert + capacity_factor.
4+
//
5+
// The honest-closure gap: extras.moe.dropped_token_ratio was hardcoded
6+
// 0.0 even when the user wired capacity_factor in the MoE brick. After
7+
// V7-E01/E02 plumbing it reflects compute_drop_reroute_stats.
8+
9+
import { test, expect } from "@playwright/test";
10+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
11+
12+
test("V7-E01/E02: capacity_factor=0.25 surfaces non-zero overflow_ratio",
13+
async ({ page }) => {
14+
test.setTimeout(90_000);
15+
await gotoApp(page);
16+
// deepseek_v3 has an MoE block — use it as the platform.
17+
await selectPreset(page, "deepseek_v3_pro");
18+
19+
// Open the MoE brick and set capacity_factor=0.25 via the canvas
20+
// edit affordance. The vbgui param editor is JSON-text driven so
21+
// we click into the BrickContextPanel.
22+
const moeBrick = page.locator("[data-testid^='brick-node-']")
23+
.filter({ hasText: /moe/i }).first();
24+
if (await moeBrick.count() > 0) {
25+
await moeBrick.click();
26+
const editor = page.locator("[data-testid='brick-params-editor']");
27+
if (await editor.count() > 0) {
28+
const current = (await editor.inputValue()) || "{}";
29+
const parsed = JSON.parse(current);
30+
parsed.capacity_factor = 0.25;
31+
await editor.fill(JSON.stringify(parsed));
32+
await page.locator("[data-testid='brick-params-apply']").click();
33+
}
34+
}
35+
36+
await page.getByTestId("run-pipeline-toggle").click();
37+
await page.getByTestId("train-num-steps").fill("2");
38+
await page.getByTestId("run-pipeline-train").click();
39+
40+
const modal = page.getByTestId("run-result-modal");
41+
await modal.waitFor({ timeout: 60_000 });
42+
await page.getByTestId("run-result-expand-train").click();
43+
44+
// extras.moe is a nested object; recursive StageExtras render emits
45+
// -moe-<field> testids.
46+
const moeBlock = page.getByTestId("run-result-extras-train-moe");
47+
await expect(moeBlock).toBeVisible();
48+
49+
const drop = await page.getByTestId(
50+
"run-result-extras-train-moe-dropped_token_ratio").textContent();
51+
const reroute = await page.getByTestId(
52+
"run-result-extras-train-moe-rerouted_token_ratio").textContent();
53+
const dropN = parseFloat(drop ?? "0");
54+
const rerouteN = parseFloat(reroute ?? "0");
55+
expect(dropN).toBeGreaterThanOrEqual(0);
56+
expect(rerouteN).toBeGreaterThanOrEqual(0);
57+
// With C=0.25 some overflow must fire.
58+
expect(dropN + rerouteN).toBeGreaterThan(0);
59+
60+
await closeModal(page);
61+
});

0 commit comments

Comments
 (0)