Sparse mixture of experts · gradient derivation

Backprop through
a MoE router

The router is a tiny linear layer that decides which experts see a token. Its gradient never arrives directly — it has to be reconstructed from the experts' outputs, squeezed past a non-differentiable top-k, and redistributed by a softmax Jacobian. Here is every term, on one worked example, with numbers you can check.

read right-to-left ← the direction gradient actually travels
forward · what we are differentiating

The layer, exactly

One token at a time. A token is a vector x ∈ ℝd. The layer holds E experts and activates k of them.

h = W_r x router logits, W_r ∈ ℝ^(E×d), h ∈ ℝ^E p = softmax(h) router probabilities, Σ p_e = 1 T = TopK(p, k) index set of the k largest — HARD, no gradient g_e = p_e · 1[e ∈ T] gate weights (Variant A: softmax-then-top-k) y = Σ_{e∈T} g_e · E_e(x) the mixture output, y ∈ ℝ^d

Everything hinges on one structural fact: the router's output is a scalar multiplier on each expert's output vector. The router does not touch the token content path directly. It only scales. So the only way loss information can reach the router is by asking, for each expert, "how much did scaling you up help?"

The worked example

Every number on this page comes from this one setting. d=2, E=4, k=2 — small enough to verify by hand, large enough to show the non-selected experts.

x = [1.0, 2.0] W_r rows: w₁=[ 0.5,−0.2] w₂=[0.1,0.4] w₃=[−0.3,0.1] w₄=[0.2,0.3] h [ 0.1 , 0.9 , −0.1 , 0.8 ] p [ 0.16507 , 0.36737 , 0.13515 , 0.33241] TopK(p,2) → { expert 2 , expert 4 } g [ 0 , 0.36737 , 0 , 0.33241] E₂(x) = [ 1.0, −0.5] E₄(x) = [0.2, 0.8] y = 0.36737·[1.0,−0.5] + 0.33241·[0.2,0.8] = [0.43385, 0.08224] ȳ = ∂L/∂y = [0.4, −0.3] whatever the rest of the network sends down
Sign convention used everywhere below

Gradients are written for descent: θ ← θ − η·∂L/∂θ. So a positive gradient (red) means that quantity gets pushed down, and a negative gradient (green) means it gets pushed up. Every gradient number on this page is colored by what it does.

Forward signal, then gradient, through the same circuit
Figure 1 · 16 steps
forward activation gradient auxiliary-loss gradient inactive / not run
step 0 / 16
How to read it: the four rows are the four experts. Cyan flows left→right and is what the network computes; amber flows right→left and is what the network learns from. Notice that the amber signal enters at the experts, not at the router — the router's gradient is manufactured out of expert outputs at step B2.
∂L/∂y · link 1

Where backprop enters

Nothing special happens here. The MoE block sits inside a transformer, typically after a residual add and a norm, so ȳ = ∂L/∂y is handed down by whatever is above. In our example ȳ = [0.4, −0.3].

The one thing worth noting: because y is a sum over selected experts, this same ȳ gets broadcast unchanged to every selected branch. Addition is a gradient fan-out. All the differentiation between experts happens in the next step.

∂L/∂g · link 2 — the load-bearing step

The gate gradient is a dot product

This is the step that makes a router trainable at all, and it is worth staring at.

The output is y = Σe ge Ee(x). Differentiate with respect to the scalar ge. The product rule on a scalar-times-vector gives a vector, and the chain rule contracts it against ȳ:

∂L/∂g_e = Σ_i (∂L/∂y_i)(∂y_i/∂g_e) = Σ_i ȳ_i · E_e(x)_i = ȳ · E_e(x) expert 2: ∂L/∂g₂ = (0.4)(1.0) + (−0.3)(−0.5) = 0.40 + 0.15 = +0.55 expert 4: ∂L/∂g₄ = (0.4)(0.2) + (−0.3)( 0.8) = 0.08 − 0.24 = −0.16

Read it as an alignment test. −ȳ is the direction the output wants to move to reduce loss. If expert e's output vector points that way, the dot product ȳ · Ee(x) is negative, the gradient is negative, and descent raises that gate. If the expert points the wrong way, the gate gets lowered.

So expert 2 here is actively unhelpful for this token — its output has a large component along the loss-increasing direction — and the router will learn to send this token elsewhere. Expert 4 is mildly helpful and gets promoted. Credit assignment for routing is just cosine alignment between an expert's answer and the direction the loss wants to go, scaled by both magnitudes:

∂L/∂g_e = ‖ȳ‖ · ‖E_e(x)‖ · cos θ_e

Two consequences fall straight out of that formula, and both bite in practice:

  • An expert that outputs near-zero gets a near-zero gate gradient. A dead expert is invisible to the router, which is one half of the dead-expert failure loop.
  • Expert output norm is a confound. An expert with a large output norm produces a large-magnitude gate gradient regardless of whether it is right. Norms drifting apart across experts silently reweights routing pressure. This is part of why MoE blocks are sensitive to what normalization sits after the expert.

The other branch out of y is the ordinary one — into the expert body:

∂L/∂E_e(x) = g_e · ȳ expert 2: 0.36737 · [0.4,−0.3] = [ 0.14695, −0.11021] expert 4: 0.33241 · [0.4,−0.3] = [ 0.13296, −0.09972]

Note the gate appears here too, as a plain scale factor. An expert with a small gate learns slowly, because every gradient reaching its weights is multiplied by that gate. This is the second half of the dead-expert loop: low gate → small weight updates → the expert stays generic → its outputs stay unaligned → gate stays low. Nothing in the main loss breaks this cycle, which is exactly why the auxiliary loss exists.

Rotate an expert's output and watch its gate gradient flip sign
Figure 2 · interactive
Half the circle promotes, half demotes. The dividing line is perpendicular to ȳ. Anywhere in the green half-plane the expert is pulling the output toward lower loss, so ∂L/∂ge < 0 and the gate rises. The magnitude — the length of the projection onto ȳ — is how strongly the router updates.
the discontinuity · link 3, part one

Top-k has no useful derivative

Between the gate and the probability sits a hard selection. This is the part that makes MoE training qualitatively different from training a dense layer.

TopK maps a probability vector to an index set. Perturb p slightly and, almost everywhere, the index set does not move at all — the function is piecewise constant, so its Jacobian is exactly zero. At the measure-zero set where two probabilities cross, it jumps, and the derivative does not exist.

So we do not differentiate through the selection. We differentiate through the values of the gates that were selected, and treat the mask as a constant for this backward pass:

s_e := ∂L/∂p_e = ∂L/∂g_e · 1[e ∈ T] s = [ 0 , +0.55 , 0 , −0.16 ] ↑ ↑ not selected → exactly zero
What this actually costs you

The router only ever receives evidence about experts it already chose. It never learns "expert 3 would have been better here" — it never ran expert 3. Routing improves by local reweighting among the incumbents, which is a greedy, self-reinforcing dynamic. Left alone it collapses onto a few experts. Everything in the auxiliary-loss section exists to inject the counter-pressure that this zero cannot supply.

Some caveats on the "no gradient" framing, because they matter:

  • Variant A leaks a little. If the softmax is taken over all E logits before selection, unselected experts still get logit gradient through the softmax denominator. See the next section — the number is not zero.
  • Variant B does not. If you top-k first and softmax over only the k survivors (Mixtral-style), unselected logits get exactly zero, full stop.
  • The loss is still continuous in the parameters. When the ranking flips, the gate value at the crossover is equal on both sides, so y does not jump — only its slope does. Training is piecewise smooth, not discontinuous, which is why plain SGD works at all here.
Sweep one logit across the selection boundary
Figure 3 · interactive
The kink, not a cliff. As h₃ rises past h₄, expert 3 enters the top-2 and expert 4 falls out. The gate curves meet at the crossing — g is continuous — but the gradient assignment swaps instantaneously: expert 4's gate gradient drops to zero the moment it loses its slot, and expert 3's turns on. This is the boundary where TopK's Jacobian is undefined.
∂L/∂h · link 4 — the redistribution

The softmax Jacobian spreads the signal

Two sparse numbers go in. Four dense numbers come out. This is where a competition between experts gets created out of what was independent per-expert feedback.

The softmax Jacobian is the standard one:

∂p_i/∂h_j = p_i (δ_ij − p_j) ∂L/∂h_j = Σ_i s_i · p_i(δ_ij − p_j) = p_j ( s_j − Σ_i s_i p_i ) = p_j ( s_j − s̄ ) where s̄ := Σ_i s_i p_i

is the probability-weighted mean of the incoming signal — it is the baseline. Every logit is scored against it. A logit only rises if its expert beat the current average expert.

s̄ = (0.55)(0.36737) + (−0.16)(0.33241) = 0.20205 − 0.05319 = 0.14887 ∂L/∂h₁ = 0.16507·( 0 − 0.14887) = −0.02457 ∂L/∂h₂ = 0.36737·( 0.55 − 0.14887) = +0.14736 ∂L/∂h₃ = 0.13515·( 0 − 0.14887) = −0.02012 ∂L/∂h₄ = 0.33241·(−0.16 − 0.14887) = −0.10267 sum = −0.00000 ← always exactly zero. use it as an assert.
The most surprising line on this page

Experts 1 and 3 were never selected, never ran, and contributed nothing to y — yet their logits get a nonzero gradient of −pj·s̄, and it pushes them up. That is not a bug. Because the current selection was net harmful (s̄ > 0), the softmax normalizer moves probability mass out of the incumbents and toward everything else, indiscriminately. Every unselected expert gets the same signed nudge, scaled only by its own current probability.

This is the entire exploration mechanism you get for free in Variant A. It is weak, it is undirected — it cannot tell expert 1 from expert 3 — but it is not nothing.

Note also that expert 4 ends up with a negative logit gradient of larger magnitude than the unselected experts, which is right: it was the one genuinely useful expert here, so it gets promoted hardest. And expert 2, whose gate signal was strongly positive, is the only logit pushed down. The zero-sum structure is the competition: the router cannot raise one expert without lowering the others.

Two sparse signals redistributed across four logits
Figure 4 · animated
Left: ∂L/∂p, nonzero only where top-k fired. Right: ∂L/∂h after the Jacobian — every expert now has a signal, and the four bars sum to zero about the s̄ baseline. Toggle Variant B to see the leak to unselected experts disappear entirely.
∂L/∂Wr · link 5 — landing

Into the router weights, and back into the token

The router is a plain linear map, so the last step is the textbook one. Write δh = ∂L/∂h.

∂L/∂W_r = δ_h xᵀ outer product, shape (E×d) with δ_h = [−0.02457, +0.14736, −0.02012, −0.10267] and x = [1, 2]: row 1 (w₁): [ −0.02457, −0.04915 ] row 2 (w₂): [ +0.14736, +0.29473 ] row 3 (w₃): [ −0.02012, −0.04024 ] row 4 (w₄): [ −0.10267, −0.20534 ]

Every row is a scaled copy of the token itself. Descent moves w2 away from x and moves w4 toward it — the router is literally storing a prototype per expert, and each step nudges prototypes toward the tokens they handled well. That is the whole learning rule.

Gradient to the token

x was consumed twice — once by the router, once by each running expert — so its gradient is a sum over both paths:

∂L/∂x = W_rᵀ δ_h router path + Σ_{e∈T} g_e · J_{E_e}(x)ᵀ ȳ expert path, J = expert Jacobian router path = [−0.01205, +0.03105]

Forgetting the router path is a classic bug in hand-written fused MoE kernels. It is small in magnitude but it is the only thing that lets earlier layers shape their outputs to be routable — without it, layers below get no signal about how to make their representations easy to dispatch.

Expert weights

∂L/∂θ_e = J_{E_e,θ}(x)ᵀ (g_e · ȳ) for e ∈ T only ∂L/∂θ_e = 0 for e ∉ T — no memory traffic, no update

This is where sparse MoE gets its efficiency in the backward pass too: only k of E expert weight matrices receive any gradient for a given token. It is also why MoE optimizer states behave oddly — with Adam, an expert's second-moment estimate keeps decaying while it sits idle, so it takes an artificially large step the moment it is picked again.

the fork

Variant A vs Variant B: same forward, different gradients

The order of softmax and top-k is a one-line implementation choice with a real effect on what the router can learn.

Variant A · softmax, then top-k

GShard, Switch, ST-MoE

p = softmax(h) over all E, then keep the top k values as gates. Gates do not sum to 1.

Backward: ∂L/∂hj = pj(sj − s̄) over all E. Unselected experts get −pj.

Variant B · top-k, then softmax

Mixtral, DeepSeek-MoE, Qwen-MoE

Take the k largest logits, softmax over just those. Gates sum to 1 by construction, so output scale is stable in k.

Backward: a softmax Jacobian on the k-simplex. Unselected logits get exactly zero.

Variant B's derivation, on the same numbers. With Z = p₂+p₄ = 0.69978:

g₂ = 0.36737/0.69978 = 0.52498 g₄ = 0.33241/0.69978 = 0.47502 ∂L/∂g₂ = +0.55, ∂L/∂g₄ = −0.16 unchanged — depends only on expert outputs ḡ := Σ_{a∈T} (∂L/∂g_a)·g_a = 0.55(0.52498) + (−0.16)(0.47502) = 0.21273 ∂L/∂h_j = g_j ( ∂L/∂g_j − ḡ ) for j ∈ T, 0 otherwise ∂L/∂h₂ = 0.52498·( 0.55 − 0.21273) = +0.17706 ∂L/∂h₄ = 0.47502·(−0.16 − 0.21273) = −0.17706 ∂L/∂h₁ = ∂L/∂h₃ = 0

With k=2 the two gradients are exact negatives — the competition is now strictly between the two chosen experts and completely sealed off from the rest. Note the magnitude is also ~20% larger than Variant A's, because renormalization removes the Z < 1 attenuation.

Same token, same ȳ, same expert outputs. Only the gate definition differs.
quantityexpert 1expert 2expert 3expert 4
p0.165070.367370.135150.33241
gate — Variant A00.3673700.33241
gate — Variant B00.5249800.47502
∂L/∂g+0.55−0.16
∂L/∂h — Variant A−0.02457+0.14736−0.02012−0.10267
∂L/∂h — Variant B0+0.177060−0.17706

Which is better is genuinely contested. Variant B gives cleaner, larger, better-scaled gradients and a k-invariant output magnitude. Variant A gives the router a weak global exploration term for free. In practice Variant B has won on recent open models, and they compensate with stronger auxiliary balancing — or, increasingly, with bias-based balancing that sidesteps the aux gradient entirely.

the second gradient source

Auxiliary losses, and why they exist

The main loss cannot fix imbalance, because it only ever sees the experts that were already picked. So a second gradient is injected directly into h.

Load-balancing loss · step 1: name the thing you want to minimize

Fix one MoE layer and one batch of T tokens. Two different numbers describe "how much did expert e get used", and keeping them apart is the entire trick.

f_e = (1 / kT) · #{ (t, e) : expert e was in token t's top-k } the HARD load — a counted fraction of the kT dispatch slots. Σ_e f_e = 1 Produced by TopK, so it is a step function of the weights: NO GRADIENT P_e = (1 / T) · Σ_t p_e^(t) the SOFT load — the router's mean probability for expert e over the batch. Smooth in W_r: HAS GRADIENT Σ_e P_e = 1 P is what the router intended. f is what actually happened. They track each other closely — but only one of them is differentiable.

Imbalance is a property of f, not of P. You can have beautifully uniform probabilities and still overflow one expert's buffer, because top-k takes a hard slice through those probabilities. So the quantity to minimize is a second moment of f, and it has a very readable normalization:

I(f) = E · Σ_e f_e² = 1 + E² · Var(f) I = 1 ⟺ f is uniform — perfect balance I = E ⟺ f is one-hot — total collapse onto a single expert worked example — counts [4, 3, 1, 0] out of T = 8 tokens, k = 1: f = [0.500, 0.375, 0.125, 0.000] I(f) = 4 · (0.2500 + 0.1406 + 0.0156 + 0) = 4 · 0.40625 = 1.625

That identity I = 1 + E²·Var(f) is the whole justification for the shape of the loss. Minimizing Σe fe² is minimizing the variance of expert load — just written in a form with no subtraction in it, so it survives being differentiated.

Step 2: make it differentiable by swapping one factor

I(f) is the target, and its gradient is identically zero. The Switch Transformer move is to write Σ fe² as Σ fe·fe and replace one of the two factors with its smooth counterpart:

E · Σ_e f_e · f_e ← what you want. no gradient. ↓ replace one factor with P L_aux = α · E · Σ_e f_e · P_e ← what you actually optimize. e ranges over all E experts in this layer t ranges over all T tokens in the batch — every token, not just routed ones f_e is held CONSTANT. wrap it in stop_gradient / .detach() P_e carries the entire gradient α loss coefficient, typically 1e−2 E normalizes the scale: L_aux/α = 1 at perfect balance, E at total collapse worked example, with the batch's mean probabilities P = [0.400, 0.325, 0.175, 0.100]: Σ f·P = 0.5(0.400) + 0.375(0.325) + 0.125(0.175) + 0(0.100) = 0.34375 E · Σ f·P = 1.375 ← the surrogate (true I(f) = 1.625) L_aux = 0.01 × 1.375 = 0.01375 ← the scalar added to the task loss

Three conventions differ between papers, and all three cause real bugs. The k in the denominator: Switch used k=1, so a lot of code writes fe = (1/T)·#{tokens} and silently stops summing to 1 when someone raises k. Where the E lives: DeepSeek folds it into the load term, so their formula reads α Σ fePe with fe = (E/kT)·#{…} — the same loss, a different-looking equation. What "the batch" means: computing f from one micro-batch on one device balances load only within that shard, so large runs all-reduce the counts across the expert-parallel group before forming the loss. Some models instead compute it per sequence, which is a much stronger constraint and a different training signal.

Step 3: read the number

Because of the E factor, Laux sits on a fixed scale no matter how many experts you have, which makes it the one MoE diagnostic worth putting on a dashboard. Log that, not the raw loss: 1.0 is uniform, up to ~1.3 is healthy, past ~2 a real fraction of your parameters is idle, and a value pinned near E means the router has collapsed and the run will not recover on its own.

It is computed per layer, on every batch, and added to whatever the model is actually training on:

L_total = L_task + Σ_layers ( L_aux^(ℓ) + L_z^(ℓ) )
Every quantity in L_aux, on one batch of eight tokens
Figure 5 · interactive
Drag it and watch the two gauges move together. The counted tokens on top give f; the router's averaged probabilities give P; pairing them term by term gives the loss. The violet tick is the imbalance you actually want to minimize, E·Σf², and the amber marker is the differentiable stand-in, E·Σf·P. The stand-in always reads lower, because P is a smoothed version of f, and the gap widens as routing concentrates — but the two move together, which is all a surrogate has to do.

Why a loss that's linear in P balances anything

This is the part the formula hides, and it is the real answer to "how does that expression cause balance". Hold f fixed, as the definition demands, and Laux is a linear function of P. A linear function on the probability simplex is minimized at a vertex — so the loss, taken literally at one instant, wants the router to send everything to whichever expert is currently emptiest. That is not balance. That is the opposite collapse.

Balance comes from the fact that f is re-measured from scratch every step. Each expert's coefficient is its own current load, so the push always points away from whoever is full and toward whoever is empty, and it reverses the instant the ordering does. It is a repulsive force with a moving reference point, not a bowl with a minimum at the bottom. Uniform load is the fixed point of the dynamics, not the minimum of the loss — which is exactly why it can oscillate, and why α behaves more like a damping constant than like a regularization strength.

Why not use the honest convex version

You could minimize E·Σe Pe² instead. It is smooth, convex, and genuinely minimized at uniform P — no moving-reference argument needed, no oscillation. Shazeer's original importance loss is essentially this. It is used less because it constrains the wrong variable: flat probabilities do not imply flat counts once top-k has cut through them, and the buffer that overflows counts tokens, not probabilities. The f·P form is deliberately impure — it measures the quantity you care about and differentiates the one you can.

The gradient it produces

Once the definitions are pinned down this part is trivial, because f is a constant:

∂L_aux/∂P_e = α · E · f_e f is detached, so it just falls out front ∂L_aux/∂p_e^(t) = α · E · f_e / T since P_e = (1/T) Σ_t p_e^(t) → a CONSTANT per expert, identical for every token in the batch.

A flat per-expert constant is not yet a balancing force — it becomes one only after passing through the softmax Jacobian from the ∂L/∂h section, which scores each expert's load against the probability-weighted average load. Above average, logits go down; below average, up.

Worked, with T = 8, E = 4, α = 0.01, counts [4, 3, 1, 0]:

f = [0.500, 0.375, 0.125, 0.000] ∂L_aux/∂p^(t) = 0.04·f/8 = c = [0.00250, 0.00188, 0.00063, 0.00000] through the same Jacobian, for our token's p: c̄ = Σ c_j p_j = 0.001186 ∂L_aux/∂h₁ = 0.16507(0.00250−0.001186) = +0.000217 overloaded → down ∂L_aux/∂h₂ = 0.36737(0.00188−0.001186) = +0.000253 overloaded → down ∂L_aux/∂h₃ = 0.13515(0.00063−0.001186) = −0.000076 underused → up ∂L_aux/∂h₄ = 0.33241(0.00000−0.001186) = −0.000394 idle → up hardest

This term reaches every expert on every token, including ones that never fire. That is the property the main loss lacks, and it is the whole point. Note it is also the one term that is genuinely batch-coupled — a token's router gradient depends on how the other tokens in the batch were routed, which means changing your batch size or your expert-parallel sharding changes the router's gradient even with everything else fixed.

Router z-loss

A numerical-stability term, not a balancing one. It penalizes the log-partition function, keeping logits small so that the exponentials stay in range in bf16:

L_z = (1/T) Σ_t ( logsumexp(h^(t)) )² ∂L_z/∂h_e = (2/T) · logsumexp(h) · p_e note: all positive for our token, logsumexp(h) = 1.90138, coefficient c_z = 1e−3: ∂L_z/∂h = c_z·2(1.90138)·p = [+0.00063, +0.00140, +0.00051, +0.00126]

Every entry is positive, so every logit is pushed down together — this is the one router term that does not sum to zero across experts. It shifts the whole logit vector without changing the softmax much, which is exactly what you want from a regularizer that is supposed to be scale-only.

Total router gradient

δh = [main task] + [load balance] + [z-loss], summed before the outer product with x. The three have very different magnitudes — in our example roughly 10−1, 10−4, 10−3 — and getting α wrong is one of the most common MoE failures. Too small and the model collapses onto a few experts; too large and the router balances load at the cost of routing tokens where they belong.

Expert load over training, with the balancing gradient on and off
Figure 6 · simulation
step 0
A toy of the collapse dynamic. Each step routes tokens, applies the self-reinforcing main-loss update, and optionally the balancing gradient. Turn balancing off and watch one expert absorb everything while the rest go dark — the rich-get-richer loop from the ∂L/∂g section, running to completion. Illustrative dynamics, not a trained model.
at scale

Four things that change the gradient in a real system

Capacity overflow zeroes the gradient too

Each expert has a fixed buffer of capacity = capacity_factor · k · T / E slots. Tokens past that are dropped: their contribution to y is zeroed, so on the way back ∂L/∂ge = ȳ · 0 = 0 for that token-expert pair. A dropped token teaches the router nothing about the expert that rejected it. The residual connection is the only reason the token survives at all. Worth knowing: this makes the drop pattern a silent, batch-order-dependent source of gradient noise.

Combine is the transpose of dispatch

In expert-parallel training, forward is: build a dispatch mask, all-to-all the tokens to their expert's device, run, all-to-all back, combine with gates. The backward pass is the exact mirror — the backward of the combine all-to-all is a dispatch all-to-all. If you write a custom kernel, the dispatch/combine pair must be exact transposes or your router gradient will be silently wrong in a way that still trains, just worse.

Noisy top-k makes load itself differentiable

The original sparsely-gated MoE added learned noise before selection: he = (Wgx)e + ε·softplus((Wnx)e), ε ∼ 𝒩(0,1). With noise, the probability that expert e lands in the top-k is a smooth function of the parameters — computable in closed form from the Gaussian CDF — so you can put a load-balancing loss on that smooth quantity and get a real gradient into Wn, rather than relying on the f·P surrogate. Most large-scale systems dropped this for simplicity; a light input jitter is the vestigial remnant.

Loss-free balancing removes the aux gradient entirely

The newest approach (DeepSeek-V3) keeps a per-expert bias be used only for the top-k comparison, never for the gate value. After each step it is updated by a plain rule outside of autograd — decrement overloaded experts, increment idle ones. Since b never enters the gate, it contributes no gradient, and the router's gradient is purely task-driven again. This directly targets the tension in the callout above: balance without paying for it in the task gradient.

reference

The backward pass, written out

Autograd handles all of this for you. Writing it manually once is still the fastest way to be sure you know where each term comes from — and it is what you need if you fuse the layer.

# ---------- forward (Variant A) ----------
h = x @ Wr.T                                  # [T,E]
p = softmax(h, dim=-1)                        # [T,E]
topv, topi = p.topk(k, dim=-1)                # hard selection
g = zeros_like(p).scatter(-1, topi, topv)     # [T,E], k nonzeros per row
y = sum(g[:, e:e+1] * expert[e](x) for e in range(E))

# ---------- backward, given gy = dL/dy  [T,d] ----------
dg = zeros_like(g)                            # [T,E]
dx = zeros_like(x)

for e in range(E):
    m = g[:, e] != 0                          # tokens routed to expert e
    if not m.any(): continue                  # no gradient at all — skipped
    Ee = expert[e](x[m])                      # [n_e, d]  (recompute or stash)

    dg[m, e] = (gy[m] * Ee).sum(-1)           # dot product — the gate gradient
    dEe      = g[m, e:e+1] * gy[m]            # gate scales the expert gradient
    dtheta[e], dx_e = expert[e].backward(dEe)
    dx[m]   += dx_e                           # expert path into the token

dp  = dg                                      # top-k mask: already zero elsewhere
dh  = p * (dp - (dp * p).sum(-1, keepdim=True))     # softmax Jacobian

# ---- router-only terms, added straight into dh ----
f   = (g != 0).float().mean(0)                # load fractions — NO gradient
c   = alpha * E * f / T                       # dL_aux/dp, constant per expert
dh += p * (c - (c * p).sum(-1, keepdim=True))
dh += cz * 2 * logsumexp(h, -1, keepdim=True) * p   # z-loss

dWr = dh.T @ x                                # [E,d]
dx += dh @ Wr                                 # router path — easy to forget

Two checks that catch most mistakes:

  • Zero-sum invariant. Before the z-loss line, dh.sum(-1) must be zero to floating-point tolerance for every token. Any softmax-backward bug breaks this immediately. The z-loss term deliberately breaks it — check before adding it.
  • Finite differences on the boundary. Run gradcheck in float64 on a two-expert toy, but choose inputs away from ties. Near a top-k crossing, finite differences will disagree with the analytic gradient, and that disagreement is real, not a bug — it is the discontinuity from Figure 3.
the short version

Nine things to remember

  • 01
    The router's gradient is manufactured, not received. ∂L/∂ge = ȳ · Ee(x) — a dot product between the loss direction and the expert's answer.
  • 02
    Top-k contributes zero Jacobian. You differentiate the selected gate values, with the mask held constant.
  • 03
    The gate scales the expert's own gradient, so low-gate experts learn slowly — the second half of the collapse loop.
  • 04
    The softmax Jacobian turns independent per-expert feedback into a zero-sum competition around the baseline .
  • 05
    In Variant A, unselected experts get −pj through the normalizer. In Variant B they get exactly zero.
  • 06
    The balancing loss is the only term that reaches every expert on every token. It is also the only one that couples tokens within a batch.
  • 07
    Load f is a constant in the graph. Only P carries gradient. Wrapping f in a stop-gradient is not optional.
  • 08
    ∂L/∂x has two paths. The router path Wrᵀδh is small and easy to drop in a fused kernel — and it is what teaches lower layers to be routable.
  • 09
    Dropped tokens produce zero gate gradient. Capacity factor is a gradient hyperparameter, not just a memory one.