Skip to content

Commit 72d8827

Browse files
committed
feat(moe): env-gated memory-efficient sparse-gather MoE (default OFF)
ReferenceMoE.__call__ was a dense loop running ALL num_experts experts on ALL tokens then masking — O(num_experts x tokens x expert_hidden) activation, which OOMs MLX-eager at seq=4096 (121 GB on gb10). Add a sparse-gather routed-combine gated by CPPMEGA_MOE_EFFICIENT (default OFF so existing paths are byte-identical). For each expert it gathers only the tokens routed to it (top_k routing), runs the FFN on that subset, and scatter-adds the weighted output back. Math is identical to the dense path (a non-routed token contributes expert_out*0, exactly the dropped term). Routing indices are stop_gradient'd, so deriving per-expert row lists on the host does not perturb autograd; gradients flow through the gathered FFN + scatter-add. Unrouted experts are zero-touched so their params keep a zero (never missing) gradient. Measured single-MoE-layer fwd+bwd (d=3584, 16 experts, top_k=4, bf16, Mac): seq=4096 dense=3.84GB/223ms -> eff=2.20GB/83ms (1.75x less mem, 2.7x faster); ratio grows with seq (4x less routed-activation per token). tests/test_moe_efficient.py pins exactness: fp32 forward <1e-5, input-grad <1e-5, param-grads <1e-4 vs dense; unrouted-expert grads present; bf16 within reduction-order tolerance. Existing tests/test_moe.py pass with flag ON and OFF. RULE #1: env-gated default-OFF, exact same math, fail-loud (numpy conversion of a traced/compiled index array would raise, not silently degrade).
1 parent 921c3bb commit 72d8827

2 files changed

Lines changed: 309 additions & 6 deletions

File tree

cppmega_mlx/nn/moe.py

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,29 @@
22

33
from __future__ import annotations
44

5+
import os
56
from dataclasses import dataclass
67
from typing import Literal, NamedTuple
78

89
import mlx.core as mx
910
import mlx.nn as nn
11+
import numpy as np
1012

1113

1214
ActivationName = Literal["gelu", "relu2", "swiglu"]
1315

16+
# Env gate for the memory-efficient sparse-gather routed compute. Default OFF so
17+
# every existing training/inference path stays byte-identical to the dense
18+
# reference unless the operator explicitly opts in.
19+
_MOE_EFFICIENT_ENV = "CPPMEGA_MOE_EFFICIENT"
20+
21+
22+
def _moe_efficient_enabled() -> bool:
23+
"""Return True iff the env flag opts into the sparse-gather routed compute."""
24+
25+
value = os.environ.get(_MOE_EFFICIENT_ENV, "").strip().lower()
26+
return value in {"1", "true", "yes", "on"}
27+
1428

1529
@dataclass(frozen=True)
1630
class MoEConfig:
@@ -210,12 +224,10 @@ def __call__(self, x: mx.array) -> MoEOutput:
210224
flat_indices = router.top_indices.reshape(-1, self.config.top_k)
211225
flat_weights = router.top_weights.reshape(-1, self.config.top_k)
212226

213-
routed = mx.zeros_like(flat_x)
214-
for expert_id, expert in enumerate(self.experts):
215-
expert_out = expert(flat_x)
216-
mask = mx.equal(flat_indices, mx.array(expert_id, dtype=flat_indices.dtype))
217-
weight = mx.sum(mx.where(mask, flat_weights, mx.zeros_like(flat_weights)), axis=-1)
218-
routed = routed + expert_out * weight[:, None]
227+
if _moe_efficient_enabled():
228+
routed = self._routed_combine_sparse(flat_x, flat_indices, flat_weights)
229+
else:
230+
routed = self._routed_combine_dense(flat_x, flat_indices, flat_weights)
219231

220232
routed = routed.reshape(x.shape)
221233
shared = self.shared_expert(x) if self.shared_expert is not None else None
@@ -227,6 +239,113 @@ def __call__(self, x: mx.array) -> MoEOutput:
227239
shared_output=shared,
228240
)
229241

242+
def _expert_weight(
243+
self,
244+
flat_indices: mx.array,
245+
flat_weights: mx.array,
246+
expert_id: int,
247+
) -> mx.array:
248+
"""Per-token combine weight for ``expert_id`` (0 when not selected).
249+
250+
Equals ``sum_k top_weights[t, k]`` over the slots ``k`` whose
251+
``top_indices[t, k] == expert_id``. Identical in both the dense and
252+
sparse routed-combine paths so the math is unchanged.
253+
"""
254+
255+
mask = mx.equal(flat_indices, mx.array(expert_id, dtype=flat_indices.dtype))
256+
return mx.sum(
257+
mx.where(mask, flat_weights, mx.zeros_like(flat_weights)), axis=-1
258+
)
259+
260+
def _routed_combine_dense(
261+
self,
262+
flat_x: mx.array,
263+
flat_indices: mx.array,
264+
flat_weights: mx.array,
265+
) -> mx.array:
266+
"""Dense reference: run every expert on all tokens, then mask+combine.
267+
268+
Peak activation is ``O(num_experts x tokens x expert_hidden)`` because
269+
every one of ``num_experts`` experts materializes a full-token hidden.
270+
Kept as the default path for byte-identical behaviour.
271+
"""
272+
273+
routed = mx.zeros_like(flat_x)
274+
for expert_id, expert in enumerate(self.experts):
275+
expert_out = expert(flat_x)
276+
weight = self._expert_weight(flat_indices, flat_weights, expert_id)
277+
routed = routed + expert_out * weight[:, None]
278+
return routed
279+
280+
def _routed_combine_sparse(
281+
self,
282+
flat_x: mx.array,
283+
flat_indices: mx.array,
284+
flat_weights: mx.array,
285+
) -> mx.array:
286+
"""Memory-efficient sparse-gather routed compute (env-gated, exact).
287+
288+
For each expert, gather ONLY the tokens routed to it (the rows where the
289+
expert appears in ``top_indices``), run the FFN on that subset, scale by
290+
the per-token combine weight, and scatter-add the contribution back.
291+
292+
This is mathematically identical to :meth:`_routed_combine_dense`: a
293+
non-routed token contributes ``expert_out * 0`` in the dense path, which
294+
is exactly the term dropped here. The routing indices are produced under
295+
``mx.stop_gradient`` (see :class:`TopKRouter`), so deriving the per-expert
296+
row lists on the host from them does not perturb autograd — gradients
297+
flow only through the gathered FFN compute and the scatter-add.
298+
299+
Peak activation is bounded by the *largest* expert's token count, which
300+
is on average ``tokens x top_k / num_experts`` instead of
301+
``num_experts x tokens`` for the dense path — a ``num_experts / top_k``
302+
reduction in the routed MoE activation footprint (e.g. 16/4 = 4x), with
303+
experts processed sequentially so only one expert's hidden is live.
304+
"""
305+
306+
num_tokens, d_model = flat_x.shape
307+
routed = mx.zeros_like(flat_x)
308+
309+
# ``top_indices`` is stop_gradient'd routing metadata; materializing it on
310+
# the host to build per-expert row lists is a metadata read, not a
311+
# gradient-bearing op. Gather/scatter on the device carry the gradient.
312+
host_indices = np.asarray(flat_indices)
313+
for expert_id, expert in enumerate(self.experts):
314+
rows = np.nonzero(np.any(host_indices == expert_id, axis=1))[0]
315+
if rows.size == 0:
316+
# No token routed to this expert in this batch: it contributes
317+
# exactly zero in the dense path. Touch its params with a zero
318+
# scalar so the grad tree stays populated (grad == 0, not missing).
319+
routed = routed + self._zero_touch_expert(expert).astype(flat_x.dtype)
320+
continue
321+
row_idx = mx.array(rows.astype(np.int32))
322+
x_gathered = mx.take(flat_x, row_idx, axis=0)
323+
out_gathered = expert(x_gathered)
324+
weight = self._expert_weight(flat_indices, flat_weights, expert_id)
325+
weight_gathered = mx.take(weight, row_idx, axis=0)
326+
contrib = out_gathered * weight_gathered[:, None]
327+
scattered = mx.zeros((num_tokens, d_model), dtype=flat_x.dtype)
328+
scattered = scattered.at[row_idx].add(contrib)
329+
routed = routed + scattered
330+
return routed
331+
332+
def _zero_touch_expert(self, expert: "FeedForwardExpert") -> mx.array:
333+
"""Return a scalar that depends on every expert param but equals 0.
334+
335+
Used only when an expert receives no tokens in a batch so its parameters
336+
still appear in the gradient tree (with a zero gradient) — never silently
337+
dropped from the optimizer state. This mirrors the dense path, where an
338+
unrouted expert still runs (and gets a zero combine weight), so its
339+
params always carry a (zero) gradient.
340+
"""
341+
342+
from mlx.utils import tree_flatten
343+
344+
acc = mx.zeros((), dtype=mx.float32)
345+
for _leaf_name, leaf in tree_flatten(expert.parameters()):
346+
acc = acc + leaf.astype(mx.float32).sum() * mx.zeros((), dtype=mx.float32)
347+
return acc
348+
230349

231350
def _require_positive(name: str, value: int | float) -> None:
232351
if value <= 0:

tests/test_moe_efficient.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Numeric parity for the env-gated memory-efficient sparse-gather MoE.
2+
3+
The efficient routed-combine (``CPPMEGA_MOE_EFFICIENT=1``) must produce the same
4+
forward output *and* the same gradients (w.r.t. inputs and every parameter) as
5+
the dense reference, otherwise the env flag would silently change training
6+
numerics. These tests pin that exactness in fp32 (where the only remaining delta
7+
is float epsilon) and document the bf16 accumulation-order tolerance.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import numpy as np
13+
import mlx.core as mx
14+
import mlx.nn as nn
15+
from mlx.utils import tree_flatten, tree_unflatten
16+
17+
from cppmega_mlx.nn import moe as moe_mod
18+
from cppmega_mlx.nn.moe import MoEConfig, ReferenceMoE
19+
20+
21+
def _config(*, d_model: int = 3584, num_experts: int = 16, top_k: int = 4) -> MoEConfig:
22+
return MoEConfig(
23+
d_model=d_model,
24+
num_experts=num_experts,
25+
top_k=top_k,
26+
expert_hidden_size=128,
27+
shared_expert_hidden_size=96,
28+
activation="swiglu",
29+
)
30+
31+
32+
def _build(config: MoEConfig, dtype: mx.Dtype) -> ReferenceMoE:
33+
mx.random.seed(20260601)
34+
moe = ReferenceMoE(config)
35+
if dtype != mx.float32:
36+
moe.update(
37+
tree_unflatten(
38+
[
39+
(name, leaf.astype(dtype) if leaf.dtype == mx.float32 else leaf)
40+
for name, leaf in tree_flatten(moe.parameters())
41+
]
42+
)
43+
)
44+
mx.eval(moe.parameters())
45+
return moe
46+
47+
48+
def _run_both(moe: ReferenceMoE, x: mx.array):
49+
"""Return (dense_output, efficient_output) for the same module + input."""
50+
51+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
52+
dense_out = moe(x).output
53+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = "1"
54+
try:
55+
eff_out = moe(x).output
56+
finally:
57+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
58+
mx.eval(dense_out, eff_out)
59+
return dense_out, eff_out
60+
61+
62+
def test_env_flag_default_off() -> None:
63+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
64+
assert moe_mod._moe_efficient_enabled() is False
65+
for value in ("1", "true", "on", "YES"):
66+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = value
67+
assert moe_mod._moe_efficient_enabled() is True
68+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = "0"
69+
assert moe_mod._moe_efficient_enabled() is False
70+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
71+
72+
73+
def test_efficient_forward_matches_dense_fp32() -> None:
74+
config = _config()
75+
moe = _build(config, mx.float32)
76+
mx.random.seed(11)
77+
x = mx.random.normal((1, 128, config.d_model)).astype(mx.float32)
78+
79+
dense_out, eff_out = _run_both(moe, x)
80+
max_diff = float(mx.max(mx.abs(dense_out - eff_out)))
81+
assert max_diff < 1e-5, f"forward fp32 max_diff={max_diff:.3e} exceeds 1e-5"
82+
83+
84+
def test_efficient_input_grad_matches_dense_fp32() -> None:
85+
config = _config()
86+
moe = _build(config, mx.float32)
87+
mx.random.seed(12)
88+
x = mx.random.normal((1, 128, config.d_model)).astype(mx.float32)
89+
90+
def dense_loss(xx: mx.array) -> mx.array:
91+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
92+
return moe(xx).output.square().sum()
93+
94+
def eff_loss(xx: mx.array) -> mx.array:
95+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = "1"
96+
try:
97+
return moe(xx).output.square().sum()
98+
finally:
99+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
100+
101+
gd = mx.grad(dense_loss)(x)
102+
gs = mx.grad(eff_loss)(x)
103+
mx.eval(gd, gs)
104+
max_diff = float(mx.max(mx.abs(gd - gs)))
105+
assert max_diff < 1e-5, f"input-grad fp32 max_diff={max_diff:.3e} exceeds 1e-5"
106+
107+
108+
def test_efficient_param_grads_match_dense_fp32() -> None:
109+
config = _config()
110+
mx.random.seed(13)
111+
x = mx.random.normal((1, 128, config.d_model)).astype(mx.float32)
112+
113+
def loss(model: ReferenceMoE, xx: mx.array) -> mx.array:
114+
return model(xx).output.square().sum()
115+
116+
# Dense
117+
moe_dense = _build(config, mx.float32)
118+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
119+
_, gd = nn.value_and_grad(moe_dense, loss)(moe_dense, x)
120+
# Efficient (same seed -> same weights)
121+
moe_eff = _build(config, mx.float32)
122+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = "1"
123+
try:
124+
_, gs = nn.value_and_grad(moe_eff, loss)(moe_eff, x)
125+
finally:
126+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
127+
128+
flat_d = dict(tree_flatten(gd))
129+
flat_s = dict(tree_flatten(gs))
130+
assert set(flat_d) == set(flat_s), "gradient trees differ in keys"
131+
worst = 0.0
132+
worst_name = ""
133+
for name, gd_leaf in flat_d.items():
134+
d = float(mx.max(mx.abs(gd_leaf - flat_s[name])))
135+
if d > worst:
136+
worst, worst_name = d, name
137+
assert worst < 1e-4, f"param-grad fp32 worst={worst:.3e} ({worst_name}) exceeds 1e-4"
138+
139+
140+
def test_efficient_unrouted_expert_keeps_zero_param_grad() -> None:
141+
"""An expert with no routed tokens still appears in the grad tree (grad 0)."""
142+
143+
config = _config(num_experts=16, top_k=1)
144+
mx.random.seed(99)
145+
moe = _build(config, mx.float32)
146+
# Force the router so all tokens pick expert 0 -> experts 1..15 unrouted.
147+
weight = np.zeros((config.num_experts, config.d_model), dtype=np.float32)
148+
weight[0, 0] = 50.0 # huge logit for expert 0 on channel 0
149+
moe.router.gate.weight = mx.array(weight)
150+
mx.eval(moe.parameters())
151+
x = mx.zeros((1, 8, config.d_model), dtype=mx.float32)
152+
x = x + 0.0
153+
x_idx = np.zeros((8, config.d_model), dtype=np.float32)
154+
x_idx[:, 0] = 1.0 # ensure channel 0 active so expert 0 wins
155+
x = mx.array(x_idx[None])
156+
157+
def loss(model: ReferenceMoE, xx: mx.array) -> mx.array:
158+
return model(xx).output.square().sum()
159+
160+
moe_mod.os.environ[moe_mod._MOE_EFFICIENT_ENV] = "1"
161+
try:
162+
_, grads = nn.value_and_grad(moe, loss)(moe, x)
163+
finally:
164+
moe_mod.os.environ.pop(moe_mod._MOE_EFFICIENT_ENV, None)
165+
flat = dict(tree_flatten(grads))
166+
# Every expert param must be present (zero grad allowed, missing not).
167+
for e in range(config.num_experts):
168+
for proj in ("gate_proj", "up_proj", "down_proj"):
169+
key = f"experts.{e}.{proj}.weight"
170+
assert key in flat, f"missing grad for {key}"
171+
assert np.isfinite(np.array(flat[key])).all(), key
172+
173+
174+
def test_efficient_bf16_within_accumulation_tolerance() -> None:
175+
"""bf16 differs only by reduction-order rounding (documented, not a bug)."""
176+
177+
config = _config()
178+
moe = _build(config, mx.bfloat16)
179+
mx.random.seed(14)
180+
x = mx.random.normal((1, 128, config.d_model)).astype(mx.bfloat16)
181+
dense_out, eff_out = _run_both(moe, x)
182+
diff = float(mx.max(mx.abs(dense_out.astype(mx.float32) - eff_out.astype(mx.float32))))
183+
# Reordered bf16 reductions: tolerance ~ a few ULPs of the output scale.
184+
assert diff < 5e-2, f"bf16 max_diff={diff:.3e} unexpectedly large"

0 commit comments

Comments
 (0)