Back to Generative AI
AI/ML · Generative AI

Mixture of Experts

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.

Self-supervisedSupervisedGenerationSequence modelingClassificationRegression

A layer substitution rather than a training paradigm, so it inherits whatever objective the host model uses — which is why both self-supervised and supervised are listed. What it adds on top is an auxiliary loss of its own, and that addition is unusual: the load-balancing term optimizes nothing anyone wants, it exists purely to prevent the primary objective from collapsing the router.

Also known as: MoE, Sparse MoE, Switch Transformer, Sparsely-gated mixture of experts, Expert routing

Intuition

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.

Assumptions it makes
  • 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
Objective

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
Optimization

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.

Hyperparameters
  • 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)

Convergence & cost

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.

Applications

How, where, and why it is used

Time-Series Forecasting

Adapted
How

Substitute 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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 applicable

The 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 fit
How

The 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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
Also applied in

Natural Language

Primary fit
How

Replace 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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

Adapted
How

Route 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.

Where
  • 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
Why

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.

Featurization
  • 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
Evaluation

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.

Pitfalls
  • 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
Deployment

What it costs to run

Training

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.

Inference

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.

Retraining

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.

Drift & monitoring
  • 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
Production gotchas
  • 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
Trade-offs

Pros and cons, in context

Strengths
  • 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.

Limitations
  • 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.

Implementation

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.

1. Intuitive

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.

2. Idiomatic

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.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionalsNo mutable default arguments

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.

3. Optimized

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.