What it actually does
A dense layer applies every parameter to every token, which ties capacity to cost: doubling the parameters doubles the arithmetic. Replace the layer with many copies and a small router that picks one or two per token, and the two quantities come apart — total parameters grow with the expert count while per-token compute stays fixed. The difficulty is immediate and is the whole subject: the router is trained by gradient descent on the primary loss, and the fastest way for it to reduce that loss early is to send everything to whichever expert is currently best, which starves the others of gradient and makes them permanently worse. So a separate auxiliary loss is added whose only job is to stop that, and tuning its weight against the primary loss is the real engineering.
- The token distribution contains structure a router can usefully partition, rather than being homogeneous
- Memory is available for all experts while per-token compute is the binding constraint — if memory binds instead, the technique is counterproductive
- Enough data exists that each expert trains on a sufficient share, which bounds expert count far more tightly than compute does
- Batch-composition dependence of the output is acceptable, or capacity is set high enough that dropping effectively never happens
- The host model provides the actual objective; this technique supplies capacity and a regularizer, not a learning signal
Loss function
A task loss plus a penalty that optimizes nothing anybody wants. The balancing term is minimized when every expert receives an equal share, which is not a goal in itself — it is a defence against a failure the task loss creates. Note the asymmetry in the product: the token fraction is a hard count and carries no gradient, so all the gradient flows through the soft router probability, and the hard count acts only as a per-expert weight. That construction is doing something subtle: it penalizes high router confidence on experts that are already overloaded, which is the only differentiable handle on a discrete routing decision. The honest reading of the whole objective is that this is a technique whose main term is borrowed from whatever model hosts it and whose own contribution is a regularizer correcting for its own instability.
- number of experts; total parameters scale with it while per-token compute does not
- fraction of tokens actually routed to expert e — a hard count, so not differentiable
- mean router probability for expert e — soft, and the only path gradient can take
- the balancing weight; too small collapses the router, too large degrades the task
- experts per token, almost always 1 or 2; the sparsity is what buys the compute saving
How it is minimized
The host model’s optimizer, with a capacity factor and token dropping as the mechanism that makes batched routing possible
- router weight for the chosen expert; multiplying by it is what makes the choice differentiable at all
- the router — one small matrix, typically the cheapest parameter block in the layer and the most consequential
- capacity factor, typically 1.0 to 1.25; the slack that decides how many tokens get dropped
- tokens in the batch; capacity is per-batch, so routing behaviour depends on batch composition
Two mechanisms, and both are consequences of wanting this to run on real hardware rather than of the idea itself. The router weight multiplies the expert output so that a gradient reaches the router at all: the top-k selection is discrete and has no derivative, but the weight of the chosen expert does, so the router learns by having its confidence scaled against how useful the choice turned out to be. That is a genuine trick and it is why the weight appears in the forward pass rather than only in the loss. The capacity factor is less elegant. Batched expert computation requires a fixed buffer per expert, so a batch where routing is uneven overflows some buffers, and the overflow tokens are simply dropped — they pass through the residual connection with no expert applied. That is a real correctness compromise accepted for throughput, and it means the layer’s output depends on which other tokens happen to share the batch. In training that is noise. In inference it is a reproducibility problem that surprises people, because the same input can produce different outputs depending on batch composition.
expert count
Total parameters scale with it at fixed per-token compute; also scales the memory every device must hold (8 to 128)
experts per token (k)
One gives the largest compute saving; two is usually more stable because the router gets a second gradient path (1 to 2)
balancing weight
The most consequential knob here. Too small and the router collapses; too large and it overrides the task (0.001 to 0.01)
capacity factor
Slack in the per-expert buffer. Below 1.0 drops tokens routinely; above 1.25 wastes memory and compute (1.0 to 1.25)
router noise
Jitter added to router logits during training, which encourages exploration early and hurts if left on (0 to 1e-2)
expert dropout
Applied inside experts rather than to the routing, since dropping a routing decision drops the token entirely (0 to 0.1)
The characteristic failure is router collapse, and it is worth describing precisely because it is not a bug but the primary objective working as intended. Early in training one expert is marginally better than the others by chance; the router reduces loss by sending more tokens there; those tokens make it better still while the others receive almost no gradient and stagnate. The end state is a model with the memory cost of many experts and the capacity of one or two. The diagnostic is the routing histogram, not the loss curve — the loss looks entirely healthy throughout, which is what makes this failure expensive to discover late. The second failure is instability: router logits can grow without bound because nothing in the task loss penalizes confidence, and the fix in practice is to compute the router in float32 even when everything else is bfloat16, which is a numerical rather than algorithmic remedy. The third is fine-tuning behaviour: these models overfit noticeably faster than dense models of matched quality, because the effective capacity per token is far larger than the compute suggests.
Per-token compute is O(k·d·d_ff) regardless of expert count, against O(d·d_ff) for a dense layer — so at k=1 the arithmetic is roughly that of the dense layer it replaced while total parameters are E times larger. Memory is the real cost and it is O(E·d·d_ff), which must be resident somewhere; on multiple devices this becomes an all-to-all communication per layer, which is frequently the actual bottleneck rather than the arithmetic.
How, where, and why it is used
Time-Series Forecasting
AdaptedSubstitute the feed-forward blocks of a forecasting transformer with expert layers. The plausible story is that a heterogeneous panel contains distinguishable regimes or series families, and that the router learns to send each to specialized parameters — which would be capacity where it is needed rather than uniformly.
- Large heterogeneous panels where series families behave differently enough to specialize
- Multi-region or multi-product forecasting under a single model with shared low-level structure
- Settings with abundant data per regime, since each expert only sees its own routed share
- Serving constraints that cap per-token compute while leaving memory headroom
The regime-specialization story is appealing and mostly unproven at typical forecasting scale. The constraint is data volume: each expert trains only on the tokens routed to it, so an eight-expert layer gives each expert a fraction of an already-modest dataset, and forecasting panels are usually orders of magnitude smaller than the language corpora where this technique was established. A dense model of matched memory footprint is the honest baseline and frequently wins. The place it does earn its keep is a genuinely large panel with distinguishable families and a hard per-token compute budget — and even there the routing histogram should be inspected before believing the specialization story, because a collapsed router produces a dense model at several times the memory cost.
- Include the series identifier or family in the router input, or the router has nothing to specialize on
- Check the routing histogram against known regimes; if routing is uncorrelated with them, the specialization story is false
- Keep the per-expert token count large enough to train on — this bounds the expert count far more tightly than in language
- Compare against a dense model of matched memory, not matched compute, since memory is what this technique actually spends
Rolling-origin backtesting against a dense baseline of matched memory footprint, plus the routing histogram as a first-class diagnostic. A model whose accuracy matches the dense baseline while routing has collapsed is strictly worse than the baseline and the loss curve will not say so.
- Too many experts for the panel size, so each trains on too few tokens
- Never inspecting routing, and shipping a collapsed router that cost the memory of many experts for the capacity of one
- Comparing against a compute-matched rather than memory-matched dense baseline, which flatters the technique
- Batch-composition dependence at inference, which makes single-series forecasts irreproducible when batching changes
Anomaly Detection
Not applicableThe technique provides no notion of surprise. It replaces a feed-forward block and inherits whatever objective the host model has, so anomaly scoring is a property of the host — a decoder-only model with expert layers scores by its own likelihood, and that likelihood is not what the routing contributed. The one genuinely MoE-specific signal, a token routed to an unusual expert, is a statement about the router’s partition rather than about the data, and it drifts with batch composition because of capacity dropping. Use the host model’s objective for detection.
Optimization
Primary fitThe whole technique is a compute-allocation argument, and it is the clearest case in this reference of decoupling two quantities that are normally locked together: total parameters and per-token arithmetic. The auxiliary loss, the capacity factor and the all-to-all communication are each a separate optimization problem introduced by that decoupling.
- Decoupling parameter count from per-token compute, which is the core claim and it holds
- The auxiliary loss as a defence against a degenerate optimum the primary objective prefers
- Capacity factor as an explicit accuracy-for-throughput trade, paid in dropped tokens
- Expert-parallel placement, where the all-to-all becomes the real bottleneck rather than the arithmetic
Worth studying for two transferable lessons. The first is that adding an auxiliary term to defend against your own objective’s preferred degenerate solution is a general pattern, and the construction here is unusually instructive — the hard token count carries no gradient and acts only as a weight on the soft probability, which is how a discrete decision gets a differentiable penalty. The second is that the headline win is not where the cost went. Arithmetic per token stays flat as promised, and then memory grows linearly with expert count and the all-to-all exchange per layer becomes the bottleneck on multiple devices. A technique that moves the constraint rather than removing it is still valuable, but the accounting should be honest about which constraint now binds.
- Track the routing histogram from the first steps, since collapse happens early and the loss curve hides it entirely
- Compute the router in float32 even in a bfloat16 model; unbounded logit growth is a real and cheap-to-fix instability
- Measure the all-to-all time separately from the expert compute, or the profile attributes the cost to the wrong place
- Report the dropped-token fraction, because it is a silent accuracy cost that never appears in the loss
Quality at matched total memory against a dense baseline, not at matched FLOPs — matched-FLOP comparisons are the standard way this technique is made to look better than it is. Alongside that, the routing entropy over training and the dropped-token fraction, both of which can be healthy-looking in aggregate while the loss is fine and the model is quietly dense.
- Matched-FLOP comparisons that ignore the memory the experts occupy
- Tuning the balancing weight by task loss alone, which cannot see a collapsed router
- Attributing the cost to arithmetic when the all-to-all dominates
- Treating dropped tokens as negligible without ever measuring the fraction
Natural Language
Primary fitReplace the feed-forward block in every second transformer layer with an expert layer, keeping attention dense. Attention is left alone because its cost is quadratic in sequence length rather than in parameters, so routing it would complicate the layer without addressing where the parameters are.
- Large language models where total capacity matters and per-token serving compute is capped
- Multilingual models, where the specialization story is strongest because languages are genuinely distinguishable
- Settings with memory headroom but a hard latency budget
- Domain-mixed corpora where routing has real structure to find
This is where the technique was established and where it demonstrably works: several of the strongest openly-described models use it, and the parameter-to-compute decoupling is real at that scale. Two honest caveats. The memory cost is unavoidable and total, since every expert must be resident somewhere regardless of how rarely it is used, which makes these models awkward on single devices and shifts the difficulty into expert-parallel serving where the all-to-all per layer frequently dominates. And fine-tuning is harder than for a dense model of matched quality — they overfit faster, because effective capacity per token is far above what the compute suggests, so the fine-tuning recipe that works on a dense model of the same speed will usually overfit here.
- Alternate expert and dense layers rather than routing every block; routing everything gains little and costs stability
- Keep attention dense, since its cost scales with sequence length rather than parameter count
- Compute the router in float32 and monitor routing entropy from the first steps
- Reduce the learning rate and shorten fine-tuning relative to a dense model of matched quality
Held-out loss at matched total memory against a dense baseline, and separately at matched serving latency, because the technique wins on one of those comparisons and not the other. Routing entropy and dropped-token fraction reported alongside, since both can degrade while the loss looks fine.
- Routing every layer, which adds all-to-all cost for marginal quality
- Fine-tuning with a dense model’s recipe and overfitting quickly
- Ignoring batch-composition dependence, which makes serving non-reproducible
- Reporting only the matched-FLOP comparison, which is the flattering one
Recommendation & Ranking
AdaptedRoute by user segment, item category or surface so that different populations get different parameters while the serving cost per request stays fixed. This is the oldest form of the idea in production systems, predating the sparse transformer version, and it usually appears as per-segment towers rather than a learned router.
- Multi-surface ranking where a single model serves feeds, search and notifications with different dynamics
- Heavily segmented user populations with enough traffic per segment to train on
- Multi-task ranking where distinct objectives can occupy distinct parameters
- Latency-capped serving with memory to spare
Fits well because the segmentation is frequently known in advance, which removes the hardest part: a hand-specified router cannot collapse, and the routing histogram is fixed by construction. That makes this the safest version of the technique. The limit is the same data-volume constraint as forecasting — small segments starve, and the usual remedy is a shared dense trunk under the routed head so that low-traffic segments still benefit from the bulk of the parameters. Where a learned router is used instead, the collapse failure returns in full and the routing histogram must be monitored as a production metric rather than a training diagnostic.
- Prefer a known segmentation over a learned router when one exists; it removes the collapse failure entirely
- Keep a shared dense trunk beneath the routed head so small segments are not starved
- Monitor per-segment traffic, since a shrinking segment silently becomes an undertrained expert
- If routing is learned, treat the histogram as a production metric rather than a training diagnostic
Per-segment offline ranking metrics rather than the pooled aggregate, since the pooled number hides a starved segment entirely, followed by an online test. Traffic share per expert tracked over time, because segment drift shows up there long before it shows up in the aggregate metric.
- Segments too small to train their own parameters
- Pooled metrics concealing a segment that has degraded
- No shared trunk, so every segment pays for its own low-level representation
- Serving memory growing with segment count until the model no longer fits
What it costs to run
Per-token arithmetic close to the dense layer it replaced, which is the point, against memory that scales linearly with expert count. On multiple devices the all-to-all exchange per expert layer is frequently the dominant term, so the effective cost is a communication cost rather than a compute cost. Illustrative, not a measured benchmark.
Fixed compute per token and total memory proportional to expert count, since every expert must be resident whether or not it is used. That combination is comfortable on a cluster and awkward on a single device, which is the practical dividing line for whether this technique is available at all.
As for the host model. One MoE-specific addition: the routing distribution should be re-examined after any change to the data mixture, because a shift in mixture can quietly starve experts that were previously well-used.
- Routing histogram as the primary health metric — collapse is invisible in the loss and expensive to discover late
- Dropped-token fraction, which is a silent accuracy cost that no loss term reports
- Router logit magnitude, since unbounded growth precedes numerical instability in reduced precision
- All-to-all time as a separate line in the profile, or the cost is attributed to the wrong component
- Per-expert traffic share over time, because a data-mixture shift starves experts without any other symptom
- Output depends on batch composition. Capacity is per-batch and overflow tokens are dropped, so the same input can produce different outputs depending on what else is in the batch — this breaks reproducibility and surprises people who expect a deterministic model
- Every expert must be resident in memory regardless of use, so total memory is the constraint even though per-token compute is not
- A collapsed router produces a model with the memory cost of many experts and the capacity of one or two, and the loss curve looks entirely healthy
- Router precision matters. Computing router logits in bfloat16 invites unbounded growth and instability; float32 there is a cheap and standard fix
- Fine-tuning overfits faster than for a dense model of matched quality, so a recipe transplanted from a dense model of the same speed will usually need fewer steps and a lower rate
- Expert-parallel serving introduces an all-to-all per layer, which is frequently the real latency bottleneck rather than the expert arithmetic
- Matched-FLOP comparisons flatter this technique considerably; the honest comparison is at matched total memory
Pros and cons, in context
Parameter count and per-token compute come apart
The central claim and it holds: total capacity grows with expert count while arithmetic per token stays at roughly the dense layer it replaced. This is the clearest decoupling of two normally-locked quantities in this reference.
A drop-in layer substitution
It replaces a feed-forward block and inherits the host objective, so it composes with almost any transformer without redesigning training. That is why it appears across language, vision and ranking.
Specialization is real where the data is genuinely heterogeneous
Multilingual and multi-surface models show routing that correlates with meaningful structure. Worth verifying with the histogram rather than assuming, since a collapsed router looks identical in the loss.
The known-segmentation variant is safe
A hand-specified router cannot collapse, which removes the technique’s main failure mode entirely. This is why per-segment towers have been production practice in ranking for far longer than sparse transformers have existed.
Router collapse, which the primary objective actively prefers
Sending everything to the currently-best expert lowers the task loss fastest, so the degenerate solution is an attractor. Invisible in the loss curve, diagnosable only from the routing histogram, and expensive to find late.
Total memory scales with expert count
Every expert must be resident whether or not it is used, which moves the constraint from compute to memory rather than removing it. On a single device this frequently makes the technique unavailable.
Output depends on batch composition
Per-batch capacity means overflow tokens are dropped, so the same input can yield different outputs depending on its batch. A genuine correctness compromise accepted for throughput, and a reproducibility problem in serving.
Another loss term with another weight to tune
The balancing weight trades against the task loss with no principled setting. Too small collapses the router, too large degrades the task, and the task loss alone cannot tell you which is happening.
All-to-all communication becomes the bottleneck
Expert-parallel serving exchanges tokens across devices every expert layer, and that cost is frequently larger than the arithmetic saved. A profile that only measures compute will attribute the latency to the wrong place.
Fine-tuning is harder than for a dense model of matched quality
Effective capacity per token is far above what the compute suggests, so these models overfit faster. A dense model’s recipe transplanted directly will usually overfit.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Sparse mixture of experts, transcribed from the objective.
No ML library. One router, E expert MLPs, top-k selection per token, and
the auxiliary load-balancing loss written out so the two terms of
L = L_task + alpha * E * sum_e f_e * P_e
can be read against each other.
The thing to notice: `+ '`' + `f_e` is a hard count of tokens routed to expert
e, so it carries no gradient. `+ '`' + `P_e` is the mean router probability
and is soft. All the gradient flows through P_e, with f_e acting only as
a per-expert weight. That asymmetry is how a discrete routing decision
gets a differentiable penalty, and it is the whole trick.
"""
import math
def softmax(values):
top = max(values)
exps = [math.exp(value - top) for value in values]
total = sum(exps)
return [value / total for value in exps]
def matvec(matrix, vector):
return [
sum(row[idx] * vector[idx] for idx in range(len(vector)))
for row in matrix
]
def relu(values):
return [value if value > 0.0 else 0.0 for value in values]
def expert_forward(token, w_in, w_out):
"""One expert: a plain two-layer MLP. Nothing special about it.
The experts are ordinary. Everything interesting is in the router.
"""
return matvec(w_out, relu(matvec(w_in, token)))
def route(token, router_weights, n_experts, top_k):
"""Score every expert, keep the k best, renormalize over those.
Renormalizing over the kept experts matters: without it the output
magnitude depends on how much probability mass happened to land
outside the top-k, which varies per token and injects noise the
residual stream has to absorb.
"""
logits = matvec(router_weights, token)
probabilities = softmax(logits)
ranked = sorted(range(n_experts), key=lambda expert: -probabilities[expert])
chosen = ranked[:top_k]
kept_mass = sum(probabilities[expert] for expert in chosen)
weights = {expert: probabilities[expert] / kept_mass for expert in chosen}
return weights, probabilities
def moe_layer(tokens, router_weights, experts, top_k, capacity_factor):
"""Forward pass with capacity, so token dropping is visible.
Capacity is the ugly part and it is not an implementation detail.
Batched expert computation needs a fixed buffer per expert, so an
uneven batch overflows some buffers and the overflow tokens are
DROPPED -- they pass through with no expert applied at all.
Consequence worth stating plainly: the output for a token depends on
which other tokens share its batch. In training that is noise. At
serving time it means the same input can produce different outputs,
which surprises people who expect a deterministic model.
"""
n_tokens = len(tokens)
n_experts = len(experts)
width = len(tokens[0])
capacity = max(1, int(n_tokens * top_k / n_experts * capacity_factor))
outputs = [[0.0] * width for _ in range(n_tokens)]
assignments = [[] for _ in range(n_experts)]
dropped = 0
router_probabilities = []
for position, token in enumerate(tokens):
weights, probabilities = route(token, router_weights, n_experts, top_k)
router_probabilities.append(probabilities)
for expert, weight in weights.items():
if len(assignments[expert]) >= capacity:
dropped += 1
continue
assignments[expert].append((position, weight))
for expert, assigned in enumerate(assignments):
w_in, w_out = experts[expert]
for position, weight in assigned:
expert_output = expert_forward(tokens[position], w_in, w_out)
for idx in range(width):
# The router weight multiplies the expert output. This is
# what makes the routing decision differentiable at all:
# top-k has no derivative, but the weight of the chosen
# expert does, so the router learns by having its
# confidence scaled against how useful the choice was.
outputs[position][idx] += weight * expert_output[idx]
return outputs, assignments, router_probabilities, dropped
def load_balancing_loss(assignments, router_probabilities, n_tokens):
"""The auxiliary term: E * sum_e f_e * P_e.
Minimized when every expert gets an equal share. That is not a goal
anybody has -- it is a defence against a failure the TASK loss
causes. Early in training one expert is marginally better by chance,
the router reduces loss fastest by sending everything there, those
tokens make it better still, and the rest stagnate for want of
gradient. The end state is the memory cost of many experts and the
capacity of one.
The loss curve looks entirely healthy throughout. Only the routing
histogram shows it, which is why collapse is usually discovered late.
"""
n_experts = len(assignments)
fraction = [len(assigned) / n_tokens for assigned in assignments]
mean_probability = [
sum(probabilities[expert] for probabilities in router_probabilities) / n_tokens
for expert in range(n_experts)
]
# fraction[] is a hard count: no gradient. mean_probability[] is soft
# and is the only path a gradient can take.
return n_experts * sum(
fraction[expert] * mean_probability[expert] for expert in range(n_experts)
)
def routing_histogram(assignments, n_tokens):
"""The diagnostic that actually matters. Watch this, not the loss."""
shares = [len(assigned) / n_tokens for assigned in assignments]
total = sum(shares)
if total <= 0.0:
return {"shares": shares, "entropy": 0.0, "max_share": 0.0}
normalized = [share / total for share in shares]
entropy = -sum(
share * math.log(share) for share in normalized if share > 0.0
)
return {
"shares": shares,
# Uniform routing gives log(E); collapse drives this toward 0.
"entropy": entropy,
"max_share": max(shares),
}
Per-token compute is O(k·d·d_ff) against O(d·d_ff) for the dense layer it replaces, so at k=1 the arithmetic barely moves while parameters grow E-fold. Illustrative, not a measured benchmark: this transcription runs one expert call per assigned token in a Python loop, so it is dominated by interpreter overhead and demonstrates the routing rather than the saving.
How this language actually wants it written.
"""Mixture of experts with routing health as a returned value.
The design point: router collapse is invisible in the loss and visible
only in the routing histogram, so the histogram is part of the layer's
output rather than something a caller reassembles. A diagnostic that has
to be reconstructed by hand does not get watched, and this particular
failure is expensive to discover late.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Final, Sequence
import numpy as np
from numpy.typing import NDArray
# Uniform routing over E experts gives entropy log(E). Below this fraction
# of it, the router has effectively collapsed.
COLLAPSE_ENTROPY_RATIO: Final[float] = 0.5
class RouterCollapsed(RuntimeError):
"""Routing entropy has fallen far enough that experts are starving.
Not a numerical error -- the primary objective PREFERS this solution.
Sending every token to the currently-best expert lowers the task loss
fastest, so collapse is an attractor rather than a bug, and the loss
curve looks healthy the whole way down.
"""
class CapacityTooTight(ValueError):
"""A capacity factor below 1.0 guarantees dropping even under perfect
routing, which is almost never intended."""
class InvalidTopK(ValueError):
"""More experts requested per token than exist."""
class RouterPrecisionWarning(UserWarning):
"""Router logits computed in reduced precision.
Nothing in the task loss penalizes router confidence, so logits can
grow without bound; in bfloat16 that reaches instability quickly. The
standard fix is float32 for the router alone, which is cheap because
the router is the smallest weight in the layer.
"""
@dataclass(frozen=True, slots=True)
class MoEConfig:
"""Frozen because these are a training contract, not runtime knobs."""
n_experts: int
top_k: int = 2
capacity_factor: float = 1.25
balancing_weight: float = 0.01
router_jitter: float = 0.0
def __post_init__(self) -> None:
if self.n_experts < 2:
raise ValueError(f"need at least 2 experts, got {self.n_experts}")
if not 1 <= self.top_k <= self.n_experts:
raise InvalidTopK(
f"top_k {self.top_k} outside [1, {self.n_experts}]"
)
if self.capacity_factor < 1.0:
raise CapacityTooTight(
f"capacity_factor {self.capacity_factor} drops tokens even "
"under perfectly uniform routing"
)
if self.balancing_weight < 0.0:
raise ValueError("balancing_weight must be non-negative")
def capacity_for(self, n_tokens: int) -> int:
"""Per-expert buffer size. Per BATCH, which is the whole problem."""
exact = n_tokens * self.top_k / self.n_experts * self.capacity_factor
return max(1, int(exact))
@dataclass(frozen=True, slots=True)
class RoutingHealth:
"""What a caller must monitor. Returned, not reconstructed."""
shares: NDArray[np.float64]
entropy: float
max_entropy: float
dropped_fraction: float
@property
def entropy_ratio(self) -> float:
"""1.0 is uniform routing; toward 0.0 is collapse."""
if self.max_entropy <= 0.0:
return 0.0
return self.entropy / self.max_entropy
@property
def has_collapsed(self) -> bool:
return self.entropy_ratio < COLLAPSE_ENTROPY_RATIO
def raise_if_collapsed(self) -> None:
"""Guard clause for a training loop that should stop early."""
if not self.has_collapsed:
return
busiest = int(np.argmax(self.shares))
raise RouterCollapsed(
f"entropy ratio {self.entropy_ratio:.3f} below "
f"{COLLAPSE_ENTROPY_RATIO}; expert {busiest} holds "
f"{self.shares[busiest]:.1%} of tokens"
)
@dataclass(slots=True)
class MoEOutput:
"""The layer output alongside everything needed to judge it."""
activations: NDArray[np.float32]
balancing_loss: float
health: RoutingHealth
assignments: list[NDArray[np.int32]] = field(default_factory=list)
class Router:
"""Top-k routing with the histogram as an output.
Deliberately separate from the experts: the experts are ordinary MLPs
and the router is where every interesting failure lives, so they do
not belong in one class.
"""
def __init__(self, weights: NDArray[np.float32], config: MoEConfig) -> None:
if weights.shape[0] != config.n_experts:
raise ValueError(
f"router has {weights.shape[0]} rows for "
f"{config.n_experts} experts"
)
# float64 here to make the precision point explicit. In a real
# bfloat16 model the router alone runs in float32 -- cheap,
# because it is the smallest weight in the layer.
self._weights = weights.astype(np.float64)
self._config = config
def route(
self,
tokens: NDArray[np.float32],
rng: np.random.Generator | None = None,
) -> tuple[NDArray[np.int32], NDArray[np.float64], NDArray[np.float64]]:
"""Return chosen experts, renormalized weights, and full probabilities.
The full probability matrix is returned because the balancing loss
needs the soft distribution over ALL experts, not just the chosen
ones -- the gradient path runs through the unchosen entries too.
"""
logits = tokens.astype(np.float64) @ self._weights.T
if self._config.router_jitter > 0.0:
# No mutable default; a shared Generator across calls would
# make one caller's draws depend on another's.
generator = rng if rng is not None else np.random.default_rng()
logits = logits + generator.normal(
scale=self._config.router_jitter, size=logits.shape
)
shifted = logits - logits.max(axis=-1, keepdims=True)
exps = np.exp(shifted)
probabilities = exps / exps.sum(axis=-1, keepdims=True)
top_k = self._config.top_k
chosen = np.argpartition(-probabilities, top_k - 1, axis=-1)[:, :top_k]
chosen_probabilities = np.take_along_axis(probabilities, chosen, axis=-1)
# Renormalize over the kept experts. Without this the output
# magnitude depends on how much mass fell outside the top-k,
# which varies per token and injects noise downstream.
kept_mass = chosen_probabilities.sum(axis=-1, keepdims=True)
weights = chosen_probabilities / np.maximum(kept_mass, 1e-12)
return chosen.astype(np.int32), weights, probabilities
class MoELayer:
"""Experts plus a router, with capacity enforced and reported."""
def __init__(
self,
router: Router,
expert_w_in: NDArray[np.float32],
expert_w_out: NDArray[np.float32],
config: MoEConfig,
) -> None:
if expert_w_in.shape[0] != config.n_experts:
raise ValueError(
f"{expert_w_in.shape[0]} expert weight blocks for "
f"{config.n_experts} experts"
)
self._router = router
self._w_in = expert_w_in
self._w_out = expert_w_out
self._config = config
def forward(
self,
tokens: NDArray[np.float32],
rng: np.random.Generator | None = None,
) -> MoEOutput:
if tokens.ndim != 2:
raise ValueError(f"expected (tokens, width), got {tokens.shape}")
n_tokens, width = tokens.shape
chosen, weights, probabilities = self._router.route(tokens, rng)
capacity = self._config.capacity_for(n_tokens)
activations = np.zeros((n_tokens, width), dtype=np.float32)
assignments: list[NDArray[np.int32]] = []
dropped = 0
for expert in range(self._config.n_experts):
slot_positions, slot_ranks = np.nonzero(chosen == expert)
if slot_positions.size == 0:
assignments.append(np.empty(0, dtype=np.int32))
continue
# Capacity truncation. Order is arrival order within the
# batch, so which tokens survive depends on batch composition.
if slot_positions.size > capacity:
dropped += int(slot_positions.size - capacity)
slot_positions = slot_positions[:capacity]
slot_ranks = slot_ranks[:capacity]
assignments.append(slot_positions.astype(np.int32))
hidden = tokens[slot_positions] @ self._w_in[expert].T
np.maximum(hidden, 0.0, out=hidden)
expert_output = hidden @ self._w_out[expert].T
# Router weight scales the expert output; this is the only
# differentiable path to the routing decision.
scale = weights[slot_positions, slot_ranks].astype(np.float32)
activations[slot_positions] += scale[:, None] * expert_output
health = self._health(assignments, n_tokens, dropped)
balancing = self._balancing_loss(assignments, probabilities, n_tokens)
return MoEOutput(
activations=activations,
balancing_loss=self._config.balancing_weight * balancing,
health=health,
assignments=assignments,
)
def _balancing_loss(
self,
assignments: Sequence[NDArray[np.int32]],
probabilities: NDArray[np.float64],
n_tokens: int,
) -> float:
"""E * sum_e f_e * P_e, with the gradient asymmetry preserved.
f_e is a hard count and carries no gradient. P_e is soft. The
product penalizes high router confidence on already-overloaded
experts, which is the only differentiable handle available on a
discrete decision.
"""
n_experts = self._config.n_experts
fraction = np.array(
[assigned.size / n_tokens for assigned in assignments], dtype=np.float64
)
mean_probability = probabilities.mean(axis=0)
return float(n_experts * np.dot(fraction, mean_probability))
def _health(
self,
assignments: Sequence[NDArray[np.int32]],
n_tokens: int,
dropped: int,
) -> RoutingHealth:
shares = np.array(
[assigned.size / n_tokens for assigned in assignments], dtype=np.float64
)
total = shares.sum()
if total <= 0.0:
return RoutingHealth(shares, 0.0, 0.0, 1.0)
normalized = shares / total
nonzero = normalized[normalized > 0.0]
entropy = float(-np.sum(nonzero * np.log(nonzero)))
return RoutingHealth(
shares=shares,
entropy=entropy,
max_entropy=float(np.log(self._config.n_experts)),
dropped_fraction=dropped / (n_tokens * self._config.top_k),
)
What changed: The router becomes a class with the routing histogram as a first-class output rather than something a caller reconstructs, because collapse is invisible in the loss and a diagnostic that must be assembled by hand does not get watched. Capacity and the dropped-token count stop being incidental and become part of the returned result, so the accuracy cost of batching is reported rather than silent. Specific exceptions separate the failures that actually occur: a collapsed router detected by entropy threshold, a capacity factor that guarantees dropping, and a top-k larger than the expert count. The router logits are computed in float64 here to make the precision point explicit — unbounded logit growth is the standard numerical failure and float32 routing in a reduced-precision model is the standard fix.
Same asymptotics as the naive version — one gather and two matmuls per expert rather than a Python loop per token, so the constant factor falls substantially without the complexity changing. Illustrative, not a measured benchmark: what the stage buys is that a collapsed router now raises rather than training quietly to a healthy-looking loss, and the dropped-token fraction is reported instead of invisible.
Faster, and the justification for each change.
"""Mixture of experts with tokens sorted into contiguous expert blocks.
The optimization that matters: a naive implementation gathers scattered
rows per expert, which strides across memory and leaves BLAS idle.
Sorting the batch by expert assignment makes each expert's inputs one
CONTIGUOUS slice, so the layer becomes a pass over E contiguous segments
with two GEMMs each. The inverse permutation puts the outputs back.
What this does NOT change: the capacity mechanism, and therefore the
batch-composition dependence of the output. That is inherent to batched
expert computation, not an artefact of any implementation -- the same
input really can produce different outputs depending on what shares its
batch, and no amount of optimization removes it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
import numpy as np
from numpy.typing import NDArray
COLLAPSE_ENTROPY_RATIO: Final[float] = 0.5
@dataclass(frozen=True, slots=True)
class SortedRouting:
"""A permutation that makes each expert's tokens contiguous.
order -- token indices sorted by assigned expert
expert_offsets -- E+1 boundaries, so expert e owns order[off[e]:off[e+1]]
weights -- router weight per (permuted) slot
probabilities -- full soft distribution, kept for the balancing loss
"""
order: NDArray[np.int64]
expert_offsets: NDArray[np.int64]
weights: NDArray[np.float32]
probabilities: NDArray[np.float32]
dropped: int
def route_and_sort(
tokens: NDArray[np.float32],
router_weights: NDArray[np.float32],
n_experts: int,
top_k: int,
capacity_factor: float,
) -> SortedRouting:
"""One matmul for routing, one argsort for locality.
Router logits in float32 even if `+ '`' + `tokens` is lower precision: nothing
in the task loss penalizes router confidence, so logits grow without
bound, and in bfloat16 that reaches instability quickly. The router is
the smallest weight in the layer, so the upcast is nearly free.
"""
n_tokens = tokens.shape[0]
logits = tokens.astype(np.float32, copy=False) @ router_weights.T
logits -= logits.max(axis=-1, keepdims=True)
np.exp(logits, out=logits)
logits /= logits.sum(axis=-1, keepdims=True)
probabilities = logits # now a distribution; reused below, not recomputed
# Partial partition rather than a full sort: only the top-k boundary
# matters, and k is 1 or 2 against E of 8 to 128.
chosen = np.argpartition(-probabilities, top_k - 1, axis=-1)[:, :top_k]
chosen_probabilities = np.take_along_axis(probabilities, chosen, axis=-1)
kept_mass = chosen_probabilities.sum(axis=-1, keepdims=True)
slot_weights = chosen_probabilities / np.maximum(kept_mass, 1e-12)
flat_experts = chosen.reshape(-1)
flat_tokens = np.repeat(np.arange(n_tokens, dtype=np.int64), top_k)
flat_weights = slot_weights.reshape(-1).astype(np.float32)
# Stable sort by expert: within an expert, arrival order is preserved,
# which is what makes capacity truncation deterministic FOR A FIXED
# BATCH -- and undefined across different batches.
order = np.argsort(flat_experts, kind="stable")
sorted_experts = flat_experts[order]
counts = np.bincount(sorted_experts, minlength=n_experts)
capacity = max(1, int(n_tokens * top_k / n_experts * capacity_factor))
keep = np.ones(order.size, dtype=bool)
offsets = np.zeros(n_experts + 1, dtype=np.int64)
cursor = 0
dropped = 0
for expert in range(n_experts):
count = int(counts[expert])
if count > capacity:
keep[cursor + capacity : cursor + count] = False
dropped += count - capacity
cursor += count
kept_order = order[keep]
kept_experts = flat_experts[kept_order]
kept_counts = np.bincount(kept_experts, minlength=n_experts)
offsets[1:] = np.cumsum(kept_counts)
return SortedRouting(
order=flat_tokens[kept_order],
expert_offsets=offsets,
weights=flat_weights[kept_order],
probabilities=probabilities,
dropped=dropped,
)
def moe_forward(
tokens: NDArray[np.float32],
routing: SortedRouting,
expert_w_in: NDArray[np.float32],
expert_w_out: NDArray[np.float32],
out: NDArray[np.float32] | None = None,
) -> NDArray[np.float32]:
"""Two GEMMs per expert over contiguous slices.
The permutation is the whole optimization. `+ '`' + `permuted` is a copy of
the batch in expert order, so `+ '`' + `permuted[lo:hi]` is a contiguous block
that BLAS can stream. The cost is a full extra copy of the activations,
which roughly doubles peak memory for this layer -- worth it because
the alternative is a strided gather inside every expert matmul.
"""
n_tokens, width = tokens.shape
n_experts = expert_w_in.shape[0]
n_slots = routing.order.size
permuted = np.ascontiguousarray(tokens[routing.order])
# Preallocated and written in place, so no per-expert intermediate.
permuted_out = np.empty((n_slots, width), dtype=np.float32)
for expert in range(n_experts):
lo = int(routing.expert_offsets[expert])
hi = int(routing.expert_offsets[expert + 1])
if hi <= lo:
continue
block = permuted[lo:hi]
hidden = block @ expert_w_in[expert].T
np.maximum(hidden, 0.0, out=hidden)
np.matmul(hidden, expert_w_out[expert].T, out=permuted_out[lo:hi])
# Router weight scales the output before scatter-add. Still the only
# differentiable path to the routing decision.
permuted_out *= routing.weights[:, None]
activations = out if out is not None else np.zeros(
(n_tokens, width), dtype=np.float32
)
if out is not None:
activations.fill(0.0)
# np.add.at is the correct scatter-add: a token routed to k experts
# appears k times in `+ '`' + `order` and its contributions must sum.
np.add.at(activations, routing.order, permuted_out)
return activations
def balancing_loss(
routing: SortedRouting, n_tokens: int, n_experts: int, weight: float
) -> float:
"""E * sum_e f_e * P_e, reusing the probability matrix already computed.
Recomputing the softmax here would double the router cost for no
reason; the forward pass already materialized it.
"""
counts = np.diff(routing.expert_offsets).astype(np.float32)
fraction = counts / np.float32(n_tokens)
mean_probability = routing.probabilities.mean(axis=0)
return float(weight * n_experts * np.dot(fraction, mean_probability))
def routing_health(
routing: SortedRouting, n_tokens: int, n_experts: int, top_k: int
) -> dict[str, float]:
"""The metric to watch. Cheap enough to compute every step.
A model whose task loss looks perfect while entropy_ratio sits near
zero has the memory footprint of E experts and the capacity of one.
No other signal reports that.
"""
counts = np.diff(routing.expert_offsets).astype(np.float64)
total = counts.sum()
if total <= 0.0:
return {"entropy_ratio": 0.0, "max_share": 0.0, "dropped_fraction": 1.0}
shares = counts / total
nonzero = shares[shares > 0.0]
entropy = float(-np.sum(nonzero * np.log(nonzero)))
max_entropy = float(np.log(n_experts))
return {
"entropy_ratio": entropy / max_entropy if max_entropy > 0.0 else 0.0,
"max_share": float(shares.max()),
"dropped_fraction": routing.dropped / max(1, n_tokens * top_k),
"collapsed": float(
(entropy / max_entropy if max_entropy > 0.0 else 0.0)
< COLLAPSE_ENTROPY_RATIO
),
}
What changed: The per-expert Python loop becomes a single grouped matmul: tokens are sorted by expert assignment so each expert’s inputs form one contiguous block, and the whole layer is then two batched matmuls over a permuted array rather than E separate gathers. The permutation is the key move — it converts a scatter-gather pattern into contiguous slices, which is what lets BLAS run at full efficiency, and the inverse permutation puts the outputs back. Router probabilities are computed once for the whole batch as one matmul, the top-k uses a partial partition rather than a full sort, and the balancing loss reuses the already-materialized probability matrix rather than recomputing it.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Routing becomes one matmul for the whole batch and each expert one pair of GEMMs over a contiguous block
Trade-off: The routing decision per token is no longer visible in the code; it is implied by a permutation array that has to be trusted or asserted
Eliminate Python-level loops over samples
Sorting tokens by expert replaces a per-token dispatch with one pass over E contiguous segments
Trade-off: The sort costs O(T log T) and only pays off once the token count per expert is large enough to amortize it
Ensure contiguous memory layout and a single dtype
The permuted token block is contiguous per expert, so each expert GEMM streams instead of gathering scattered rows
Trade-off: The permutation materializes a full copy of the batch, doubling the layer’s peak activation memory
Pre-allocate output arrays and use in-place operations
Expert outputs are written directly into a preallocated permuted buffer, so no intermediate per-expert array is created
Trade-off: The buffer is sized for the worst case across experts, so memory is held whether or not routing is balanced
Per-token arithmetic is unchanged at O(k·d·d_ff) — the asymptotics were never the problem — but each expert now issues two GEMMs over a contiguous block instead of a strided gather, which is the difference between memory-bound and compute-bound. Illustrative, not a measured benchmark: the sort adds O(T log T) and the permutation copy roughly doubles peak activation memory for the layer, so the win depends on tokens per expert being large.