Skip to content

Commit 8353464

Browse files
committed
feat(v4): scaffold cppmega_v4 plugin + ROI 1 (V4MoE aux-loss-free + sqrtsoftplus)
Side-by-side port of DeepSeek V4 architectural primitives that does not modify any file under cppmega_mlx/. The existing model continues to receive parallel performance work on the TileLang / TVM / TVM-FFI stack; the V4 port stays isolated until full cut-over. ROI 1 lands V4MoE with three V4-specific knobs: - scoring: 'softmax' (legacy) | 'sigmoid' (V3) | 'sqrtsoftplus' (V4-Pro default) - aux_loss_free: V3 expert-bias balancing (paper 2408.15664). Bias added only for top-k selection; top-k weights use raw (pre-bias) scores. - node_limited_routing: optional grouped routing for production scale. update_bias_after_step(load) applies the V3 rule: b[i] += rate * sign(mean_load - load[i]) (underloaded experts get a higher bias so they're selected more often). aux_loss is forced to zero when aux_loss_free=True so callers do not add an auxiliary loss on top of bias balancing. 18 new tests in tests/v4/test_moe_v4.py: - Scoring math (softmax/sigmoid/sqrtsoftplus) vs MLX golden - Backward-compat: V4MoE softmax/no-bias picks same top-k experts as ReferenceMoE - Bias init only when enabled, frozen from optimizer - Bias update sign correctness (underloaded -> +rate, overloaded -> -rate) - Bias affects selection only, not weights (V3 invariant) - Shared-expert sum - Node-limited routing divisibility validation - Gradient flow into gate + experts; expert_bias absent from trainable grads 99/99 regression tests green (existing extensions + checkpoint + pattern + MTP). Beads: cppmega-mlx-* tracker pending. Pipeline executed: Implementer -> Code Review -> Perf Optimization (gate double-compute fixed) -> Regression Tests.
1 parent d0145dc commit 8353464

8 files changed

Lines changed: 548 additions & 0 deletions

File tree

cppmega_v4/__init__.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""cppmega_v4 — DeepSeek V4 / GatedDeltaNet plugin port for cppmega.mlx.
2+
3+
This package is a **side-by-side plugin**: it imports from ``cppmega_mlx`` but
4+
never modifies it. The existing ``cppmega_mlx`` modules continue to receive
5+
parallel performance work on the TileLang / TVM / TVM-FFI stack while this
6+
package ports new architectural primitives in isolation.
7+
8+
Public surface (filled in as ROIs land):
9+
- ``cppmega_v4.nn.moe_v4`` — aux-loss-free balancing, sqrt(softplus) scoring
10+
- ``cppmega_v4.nn.linear_attention`` — GDN block ("L" symbol)
11+
- ``cppmega_v4.nn.kimi_delta_attention`` — KDA block ("K" symbol)
12+
- ``cppmega_v4.nn.mhc_v4`` — Sinkhorn-Knopp Manifold HyperConnection
13+
- ``cppmega_v4.nn.engram_v4`` — TileKernels-grade Engram
14+
- ``cppmega_v4.nn.attention_v4`` — FlashMLA absorb, Lightning Indexer
15+
- ``cppmega_v4.nn.mtp_v4`` — Sequential depth-D MTP heads
16+
- ``cppmega_v4.models.hybrid_v4`` — V4HybridTinyConfig / V4HybridTinyLM
17+
18+
Build invariants:
19+
- No file under ``cppmega_mlx/`` is modified by this port.
20+
- No entry is added to the ``cppmega_mlx*`` glob in ``pyproject.toml``.
21+
- Tests live under ``tests/v4/`` and use ``pytest.importorskip`` for
22+
anything that depends on optional kernels.
23+
- Final cut-over (swapping ``HybridTinyLM`` users to V4 wrappers) is a
24+
separate ROI handled only after every block here is green.
25+
"""
26+
27+
__version__ = "0.0.0-dev"
28+
29+
__all__ = ["__version__"]

cppmega_v4/_tilelang/__init__.py

Whitespace-only changes.

cppmega_v4/models/__init__.py

Whitespace-only changes.

cppmega_v4/nn/__init__.py

Whitespace-only changes.

cppmega_v4/nn/moe_v4.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""DeepSeek-V4-flavored MoE: aux-loss-free balancing + sqrt(softplus) scoring.
2+
3+
This is a side-by-side plugin module. It does not import ``ReferenceMoE`` and
4+
does not modify any file under ``cppmega_mlx/``. It reuses ``FeedForwardExpert``
5+
from ``cppmega_mlx.nn.moe`` only as a leaf module so the per-expert MLP shape
6+
stays consistent with the existing trainer.
7+
8+
Scoring options (per V3/V4 papers):
9+
- ``softmax`` — legacy V2/V3 default; backward-compatible.
10+
- ``sigmoid`` — V3 per-expert affinity (no normalization across experts).
11+
- ``sqrtsoftplus`` — V4-Pro default: ``sqrt(softplus(logits))``.
12+
13+
Aux-loss-free balancing (V3, paper 2408.15664):
14+
A learnable per-expert bias ``b[i]`` is added to the routing scores **only
15+
for top-k selection** — the weights of selected experts use the raw scores
16+
(without bias) divided by their sum. The bias is updated post-step:
17+
``b[i] += rate * sign(load[i] - mean_load)``.
18+
19+
Node-limited routing (V3):
20+
When ``node_limited_routing=N`` is set, the routing first picks the top-N
21+
expert *groups*, then top-k within those groups. We expose it as a config
22+
knob; setting it to ``None`` (default) disables grouping.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
from dataclasses import dataclass
28+
from typing import Literal
29+
30+
import mlx.core as mx
31+
import mlx.nn as nn
32+
33+
from cppmega_mlx.nn.moe import (
34+
ActivationName,
35+
FeedForwardExpert,
36+
MoEConfig,
37+
MoEOutput,
38+
RouterOutput,
39+
)
40+
41+
V4Scoring = Literal["softmax", "sigmoid", "sqrtsoftplus"]
42+
43+
44+
@dataclass(frozen=True)
45+
class V4MoEConfig:
46+
"""V4 MoE config. Mirrors ``MoEConfig`` fields plus V4-specific knobs."""
47+
48+
d_model: int
49+
num_experts: int = 16
50+
top_k: int = 4
51+
expert_hidden_size: int = 256
52+
shared_expert_hidden_size: int | None = None
53+
activation: ActivationName = "swiglu"
54+
normalize_top_k: bool = True
55+
router_dtype: str | None = "fp32"
56+
bias: bool = False
57+
scoring: V4Scoring = "softmax"
58+
aux_loss_free: bool = False
59+
bias_update_rate: float = 1e-3
60+
node_limited_routing: int | None = None
61+
62+
def __post_init__(self) -> None:
63+
if self.d_model <= 0:
64+
raise ValueError("d_model must be positive")
65+
if self.num_experts <= 0:
66+
raise ValueError("num_experts must be positive")
67+
if self.top_k <= 0 or self.top_k > self.num_experts:
68+
raise ValueError("top_k must satisfy 0 < top_k <= num_experts")
69+
if self.expert_hidden_size <= 0:
70+
raise ValueError("expert_hidden_size must be positive")
71+
if self.shared_expert_hidden_size is not None and self.shared_expert_hidden_size <= 0:
72+
raise ValueError("shared_expert_hidden_size must be positive when set")
73+
if self.scoring not in ("softmax", "sigmoid", "sqrtsoftplus"):
74+
raise ValueError(f"unsupported scoring {self.scoring!r}")
75+
if self.router_dtype not in (None, "fp32"):
76+
raise ValueError("only router_dtype=None or 'fp32' is supported")
77+
if self.bias_update_rate < 0:
78+
raise ValueError("bias_update_rate must be non-negative")
79+
if self.node_limited_routing is not None:
80+
if self.node_limited_routing <= 0:
81+
raise ValueError("node_limited_routing must be positive when set")
82+
if self.num_experts % self.node_limited_routing != 0:
83+
raise ValueError(
84+
"num_experts must be divisible by node_limited_routing groups"
85+
)
86+
if self.top_k > self.node_limited_routing * (
87+
self.num_experts // self.node_limited_routing
88+
):
89+
raise ValueError("top_k exceeds capacity of node_limited_routing groups")
90+
91+
def as_moe_config(self) -> MoEConfig:
92+
"""Project to the legacy MoEConfig (for parity tests against ReferenceMoE)."""
93+
return MoEConfig(
94+
d_model=self.d_model,
95+
num_experts=self.num_experts,
96+
top_k=self.top_k,
97+
expert_hidden_size=self.expert_hidden_size,
98+
shared_expert_hidden_size=self.shared_expert_hidden_size,
99+
activation=self.activation,
100+
normalize_top_k=self.normalize_top_k,
101+
router_dtype=self.router_dtype,
102+
bias=self.bias,
103+
)
104+
105+
106+
def _score_logits(logits: mx.array, scoring: V4Scoring) -> mx.array:
107+
if scoring == "softmax":
108+
return mx.softmax(logits, axis=-1)
109+
if scoring == "sigmoid":
110+
return mx.sigmoid(logits)
111+
# sqrtsoftplus: sqrt(softplus(x)) — per V4-Pro config (scoring_func=sqrtsoftplus)
112+
return mx.sqrt(nn.softplus(logits))
113+
114+
115+
class V4MoE(nn.Module):
116+
"""V4 MoE block: scoring + aux-loss-free bias + optional node-limited routing.
117+
118+
Forward returns a ``MoEOutput`` whose ``router.aux_loss`` is zero when
119+
``aux_loss_free=True`` (no auxiliary loss should be added to the training
120+
objective in that mode — the expert bias does the balancing).
121+
"""
122+
123+
def __init__(self, config: V4MoEConfig):
124+
super().__init__()
125+
self.config = config
126+
self.gate = nn.Linear(config.d_model, config.num_experts, bias=config.bias)
127+
self.experts = [
128+
FeedForwardExpert(
129+
config.d_model,
130+
config.expert_hidden_size,
131+
activation=config.activation,
132+
bias=config.bias,
133+
)
134+
for _ in range(config.num_experts)
135+
]
136+
self.shared_expert = (
137+
FeedForwardExpert(
138+
config.d_model,
139+
config.shared_expert_hidden_size,
140+
activation=config.activation,
141+
bias=config.bias,
142+
)
143+
if config.shared_expert_hidden_size is not None
144+
else None
145+
)
146+
# ``expert_bias`` is a non-trainable parameter mutated by
147+
# ``update_bias_after_step``. We freeze it so the optimizer ignores it.
148+
if config.aux_loss_free:
149+
self.expert_bias = mx.zeros((config.num_experts,), dtype=mx.float32)
150+
self.freeze(keys=["expert_bias"])
151+
152+
def _router_scores(self, flat_x: mx.array) -> tuple[mx.array, mx.array, mx.array]:
153+
"""Return (logits, raw_scores, biased_scores) — gate computed once."""
154+
logits = self.gate(flat_x)
155+
cast_logits = logits.astype(mx.float32) if self.config.router_dtype == "fp32" else logits
156+
raw_scores = _score_logits(cast_logits, self.config.scoring)
157+
if self.config.aux_loss_free:
158+
biased_scores = raw_scores + self.expert_bias[None, :]
159+
else:
160+
biased_scores = raw_scores
161+
return logits, raw_scores, biased_scores
162+
163+
def _select_top_k(self, biased_scores: mx.array) -> mx.array:
164+
"""Top-k indices over the (optionally node-limited) biased scores."""
165+
cfg = self.config
166+
if cfg.node_limited_routing is None or cfg.node_limited_routing == cfg.num_experts:
167+
return mx.stop_gradient(
168+
mx.argpartition(-biased_scores, cfg.top_k - 1, axis=-1)[:, : cfg.top_k]
169+
)
170+
171+
groups = cfg.node_limited_routing
172+
group_size = cfg.num_experts // groups
173+
reshaped = biased_scores.reshape(-1, groups, group_size)
174+
# Use max score within each group to rank groups.
175+
group_scores = reshaped.max(axis=-1)
176+
# Pick the top groups whose total capacity is at least ``top_k`` —
177+
# simplest policy is to take all groups whose score is in the top
178+
# ``ceil(top_k / group_size)`` ranks, then top-k within the masked set.
179+
keep_groups = max(1, (cfg.top_k + group_size - 1) // group_size)
180+
keep_groups = min(keep_groups, groups)
181+
top_group_idx = mx.stop_gradient(
182+
mx.argpartition(-group_scores, keep_groups - 1, axis=-1)[:, :keep_groups]
183+
)
184+
mask = mx.zeros_like(group_scores)
185+
ones = mx.ones_like(top_group_idx).astype(mask.dtype)
186+
mask = mx.put_along_axis(mask, top_group_idx, ones, axis=-1)
187+
mask = mx.repeat(mask[:, :, None], group_size, axis=-1).reshape(biased_scores.shape)
188+
masked = mx.where(
189+
mask.astype(mx.bool_),
190+
biased_scores,
191+
mx.full(biased_scores.shape, -mx.inf, dtype=biased_scores.dtype),
192+
)
193+
return mx.stop_gradient(
194+
mx.argpartition(-masked, cfg.top_k - 1, axis=-1)[:, : cfg.top_k]
195+
)
196+
197+
def __call__(self, x: mx.array) -> MoEOutput:
198+
if x.ndim < 2:
199+
raise ValueError(f"x must have a hidden dimension, got shape {x.shape}")
200+
if x.shape[-1] != self.config.d_model:
201+
raise ValueError(
202+
f"x last dim must be {self.config.d_model}, got {x.shape[-1]}"
203+
)
204+
cfg = self.config
205+
flat_x = x.reshape(-1, x.shape[-1])
206+
logits, raw_scores, biased_scores = self._router_scores(flat_x)
207+
208+
top_indices = self._select_top_k(biased_scores)
209+
# Weights use the **raw** (pre-bias) scores — the bias only affects
210+
# which experts are selected, per V3 paper.
211+
top_scores = mx.take_along_axis(raw_scores, top_indices, axis=-1)
212+
if cfg.normalize_top_k:
213+
denom = mx.maximum(top_scores.sum(axis=-1, keepdims=True), mx.array(1e-9))
214+
top_weights = top_scores / denom
215+
else:
216+
top_weights = top_scores
217+
top_weights = top_weights.astype(x.dtype)
218+
219+
flat_indices = top_indices.reshape(-1, cfg.top_k)
220+
flat_weights = top_weights.reshape(-1, cfg.top_k)
221+
routed = mx.zeros_like(flat_x)
222+
for expert_id, expert in enumerate(self.experts):
223+
expert_out = expert(flat_x)
224+
mask = mx.equal(flat_indices, mx.array(expert_id, dtype=flat_indices.dtype))
225+
weight = mx.sum(mx.where(mask, flat_weights, mx.zeros_like(flat_weights)), axis=-1)
226+
routed = routed + expert_out * weight[:, None]
227+
228+
routed = routed.reshape(x.shape)
229+
shared = self.shared_expert(x) if self.shared_expert is not None else None
230+
output = routed + shared if shared is not None else routed
231+
232+
selected = mx.equal(flat_indices[..., None], mx.arange(cfg.num_experts))
233+
selected = selected.astype(mx.float32)
234+
load = selected.mean(axis=(0, 1))
235+
importance = raw_scores.astype(mx.float32).mean(axis=0)
236+
# Aux loss is zero when aux_loss_free=True (bias-update does balancing).
237+
if cfg.aux_loss_free:
238+
aux_loss = mx.array(0.0, dtype=mx.float32)
239+
else:
240+
aux_loss = cfg.num_experts * mx.sum(load * importance)
241+
242+
prefix = x.shape[:-1]
243+
router = RouterOutput(
244+
logits=logits.reshape(*prefix, cfg.num_experts),
245+
probabilities=raw_scores.reshape(*prefix, cfg.num_experts),
246+
top_indices=top_indices.reshape(*prefix, cfg.top_k),
247+
top_weights=top_weights.reshape(*prefix, cfg.top_k),
248+
aux_loss=aux_loss,
249+
load=load,
250+
importance=importance,
251+
)
252+
return MoEOutput(
253+
output=output,
254+
router=router,
255+
routed_output=routed,
256+
shared_output=shared,
257+
)
258+
259+
def update_bias_after_step(self, router_load: mx.array) -> None:
260+
"""Apply the aux-loss-free bias update from V3 (paper 2408.15664).
261+
262+
``router_load[i]`` is the fraction of tokens routed to expert ``i``
263+
in the last training step. The update is ``b[i] += rate * sign(load[i] - mean)``.
264+
265+
Call this **once per optimizer step**, after the forward+backward pass,
266+
before the next step starts. No-op when ``aux_loss_free=False``.
267+
"""
268+
if not self.config.aux_loss_free:
269+
return
270+
if router_load.ndim != 1 or router_load.shape[0] != self.config.num_experts:
271+
raise ValueError(
272+
f"router_load must be shape ({self.config.num_experts},), "
273+
f"got {router_load.shape}"
274+
)
275+
load = router_load.astype(mx.float32)
276+
mean_load = load.mean()
277+
# Underloaded experts (load < mean) need a HIGHER bias so they get
278+
# selected more often → ``sign(mean - load) = +1``. Overloaded → -1.
279+
delta = self.config.bias_update_rate * mx.sign(mean_load - load)
280+
self.expert_bias = self.expert_bias + delta
281+
282+
283+
__all__ = ["V4MoE", "V4MoEConfig", "V4Scoring"]

cppmega_v4/recipes/__init__.py

Whitespace-only changes.

tests/v4/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)