|
| 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"] |
0 commit comments