Mixture-of-Experts Kernels: Routing, Permutation, and Grouped GEMM

Updated

October 28, 2026

Mixture-of-Experts Kernels: Routing, Permutation, and Grouped GEMM

Most frontier open-weight models are now Mixture-of-Experts (MoE) models. DeepSeek-V3 and Kimi K2 have 256 and 384 routed experts per layer, Qwen3-235B-A22B and gpt-oss-120b have 128, and every token uses only 4 to 8 of them. The model holds hundreds of billions of parameters, but each token pays for only a few tens of billions. For a kernel engineer, that changes the central operation. Instead of one large GEMM per feed-forward layer, each layer runs dozens to hundreds of small GEMMs of different, data-dependent sizes, and the sizes are only known after the router has run on the GPU.

This chapter builds the MoE layer kernel by kernel. It writes a ground-truth reference, gets the routing semantics right (including the DeepSeek-style bias that is easy to misuse), builds the block-aligned indirection table that production kernels consume, writes a fused grouped GEMM in Triton that gathers and scatters rows itself, verifies it on a CPU with the Triton interpreter, compiles it for a Hopper GPU you do not own, and then uses shape arithmetic to explain why prefill and decode need different MoE kernels.


1. Why MoE is a kernel problem

A dense SwiGLU feed-forward layer computes \(W_2\,(\mathrm{silu}(W_1x)\odot W_3x)\) with one weight set. An MoE layer has \(E\) such weight sets (the experts) plus a small router \(W_g\in\mathbb{R}^{E\times H}\). For each token, the router scores all experts, keeps the top \(k\), and the layer output is the weighted sum of those \(k\) experts’ outputs.

Model (config.json) Routed experts \(E\) Active \(k\) Hidden \(H\) Expert intermediate \(I\) Router
Mixtral-8x7B 8 2 4096 14336 softmax, top-2
gpt-oss-120b 128 4 2880 2880 softmax over top-4
Qwen3-235B-A22B 128 8 4096 1536 softmax, renormalized top-8
DeepSeek-V3 256 (+1 shared) 8 7168 2048 sigmoid + bias, 8 groups, top-4 groups
Kimi K2 384 (+1 shared) 8 7168 2048 sigmoid + bias

Three properties make this hard to run fast:

  1. Ragged, data-dependent sizes. Expert \(e\) receives \(n_e\) token rows, with \(\sum_e n_e = T\cdot k\) for a batch of \(T\) tokens. The \(n_e\) change every step, live in GPU memory, and are often zero.
  2. Weights dominate bytes. One DeepSeek-V3 expert is \(3HI\approx 44\) M parameters. Every active expert must stream its whole weight set from HBM, whether it serves one token or a thousand.
  3. Imbalance. Routers are trained to balance load on average. Any given batch can still send most tokens to a few hot experts, and the slowest expert sets the latency of the layer.

The rest of the chapter is about turning “\(E\) ragged GEMMs” into one launch that keeps the tensor cores busy when there is enough work, and keeps HBM busy when there is not.


2. Mental model: route, align, grouped GEMM, combine

 x [T, H] ──router GEMM──► logits [T, E] ──top-k──► topk_ids, topk_w   [T, k]
                                                        │
                    moe_align: histogram ─► pad each expert to BLOCK_M ─► exclusive scan ─► scatter
                                                        ▼
                  sorted_ids  [padded rows]   (flat row r = token*k + slot, or a sentinel)
                  expert_ids  [padded rows / BLOCK_M]   (one expert per BLOCK_M-row tile)
                                                        │
 GEMM1:  rows of x gathered by sorted_ids // k,  B = W13[expert] ──► h   [T*k, 2I]  at row r
 act  :  silu(h[:, :I]) * h[:, I:]                                ──► a   [T*k, I]
 GEMM2:  rows of a gathered by sorted_ids,       B = W2[expert], × topk_w[r] ──► y [T*k, H] at row r
 combine: out[t] = Σ_slot y[t*k + slot]                           ──► out [T, H]

There are two ways to feed the expert GEMMs:

Design Data movement Used by
Permute, then grouped GEMM. Physically reorder token rows so each expert’s rows are contiguous, run a grouped GEMM with per-expert offsets, then un-permute. Writes and reads a permuted copy of the activations (and of the outputs) torch.nn.functional.grouped_mm, CUTLASS grouped GEMM, DeepGEMM’s grouped layouts, most expert-parallel stacks (the all-to-all already permutes)
Gather and scatter inside the GEMM. Keep activations in token order; each tile reads its rows through an index table and writes results back to their original positions. No permuted copy; indexed loads instead of contiguous ones vLLM and SGLang fused_moe Triton kernels

Both need the same metadata: how many rows each expert has, and where each expert’s rows start. Computing that is a histogram followed by a prefix sum (scan), the same primitive that underlies radix sort.


3. Ground truth first

Every faster version in this chapter is compared against two deliberately simple implementations: a per-expert loop, and a dense version that runs every expert on every token and masks the result. They share no indexing code, so a bug in one does not hide in the other.

# file: moe_reference.py
# Ground truth for a top-k Mixture-of-Experts FFN layer (SwiGLU experts), in PyTorch on CPU.
#   route:   logits = x @ Wg^T -> softmax -> top-k -> renormalise the k weights
#   experts: y_e(x) = W2_e (silu(W1_e x) * (W3_e x))
#   combine: out(x) = sum_j w_j * y_{e_j}(x)
# Two independent implementations must agree: a per-expert loop and a dense "run every expert" version.
import torch

torch.manual_seed(0)
T, H, I, E, TOP_K = 64, 64, 96, 8, 2          # tokens, hidden, expert intermediate, experts, k


def route(x, w_gate, top_k, renormalize=True):
    probs = torch.softmax(x @ w_gate.T, dim=-1)               # [T, E], fp32
    w, ids = torch.topk(probs, top_k, dim=-1)                 # [T, k] weights and expert ids
    if renormalize:
        w = w / w.sum(-1, keepdim=True)
    return w, ids


def moe_loop(x, w_gate, w1, w3, w2, top_k):
    """Per-expert loop: gather the tokens routed to e, run e once, scatter-add weighted outputs."""
    w, ids = route(x, w_gate, top_k)
    out = torch.zeros_like(x)
    for e in range(w1.shape[0]):
        tok, slot = torch.nonzero(ids == e, as_tuple=True)    # which tokens chose e, and in which slot
        if tok.numel() == 0:
            continue                                          # an expert may receive no tokens at all
        xe = x[tok]
        ye = (torch.nn.functional.silu(xe @ w1[e].T) * (xe @ w3[e].T)) @ w2[e].T
        out.index_add_(0, tok, ye * w[tok, slot, None])
    return out, ids


def moe_dense(x, w_gate, w1, w3, w2, top_k):
    """Run every expert on every token, then keep only the routed ones. E/k times the FLOPs, but obviously right."""
    w, ids = route(x, w_gate, top_k)
    gate = torch.zeros(x.shape[0], w1.shape[0]).scatter(1, ids, w)        # [T, E], k nonzeros per row
    y_all = torch.stack([(torch.nn.functional.silu(x @ w1[e].T) * (x @ w3[e].T)) @ w2[e].T
                         for e in range(w1.shape[0])], dim=1)            # [T, E, H]
    return (gate[:, :, None] * y_all).sum(1)


def make_layer(H, I, E, seed=0):
    g = torch.Generator().manual_seed(seed)
    s = 1.0 / H ** 0.5
    return (torch.randn(E, H, generator=g) * s,               # router weight  [E, H]
            torch.randn(E, I, H, generator=g) * s,            # W1 (gate proj) [E, I, H]  nn.Linear layout
            torch.randn(E, I, H, generator=g) * s,            # W3 (up proj)   [E, I, H]
            torch.randn(E, H, I, generator=g) / I ** 0.5)     # W2 (down proj) [E, H, I]


if __name__ == "__main__":
    x = torch.randn(T, H)
    wg, w1, w3, w2 = make_layer(H, I, E)
    out_loop, ids = moe_loop(x, wg, w1, w3, w2, TOP_K)
    out_dense = moe_dense(x, wg, w1, w3, w2, TOP_K)
    counts = torch.bincount(ids.flatten(), minlength=E)
    print("tokens per expert :", counts.tolist(), " (sum =", int(counts.sum()), "= T*k)")
    print("max |loop - dense|:", f"{(out_loop - out_dense).abs().max().item():.2e}")
    flops_moe = 2 * T * TOP_K * 3 * H * I
    print(f"expert FLOPs: routed {flops_moe/1e6:.1f} MFLOP vs dense {flops_moe*E/TOP_K/1e6:.1f} MFLOP")
python3 -m venv .venv && . .venv/bin/activate
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install triton numpy
python moe_reference.py

Output (PyTorch 2.14.1 CPU):

tokens per expert : [19, 11, 15, 15, 21, 15, 18, 14]  (sum = 128 = T*k)
max |loop - dense|: 0.00e+00
expert FLOPs: routed 4.7 MFLOP vs dense 18.9 MFLOP

The two implementations agree to the last bit here because they run the same row-wise products on the same CPU kernels. The FLOP line is the reason MoE exists: with \(k/E = 2/8\) the routed layer does a quarter of the dense work. DeepSeek-V3’s \(8/256\) is \(1/32\).

Note the indexing contract that every later kernel keeps: the \(T\cdot k\) routed copies of the tokens are numbered by a flat row \(r = t\cdot k + j\) (token \(t\), slot \(j\)). Expert loads are just bincount over the flattened topk_ids.


4. Routing semantics: the bias is for choosing, not for weighting

Softmax-then-top-\(k\) (Mixtral, Qwen3) is simple. DeepSeek-V3 and Kimi K2 use a router that kernel ports regularly get wrong:

  • scores are sigmoid affinities, not a softmax;
  • a per-expert correction bias, adjusted during training to balance load without an auxiliary loss, is added to the scores only to select experts;
  • group-limited routing: experts are split into groups (8 groups for DeepSeek-V3). Each group is scored by the sum of its top-2 biased scores, only the best topk_group groups (4) stay eligible, and top-\(k\) is taken inside them. In deployment this bounds how many nodes a token’s experts span;
  • the gate weights come from the unbiased sigmoid scores, are renormalized over the \(k\) chosen experts, and are multiplied by routed_scaling_factor (2.5 for DeepSeek-V3).
# file: router_grouped.py
# DeepSeek-V3-style routing: sigmoid scores, a per-expert correction bias that steers SELECTION only
# (auxiliary-loss-free load balancing), group-limited top-k, renormalisation and a fixed scale.
import torch

torch.manual_seed(0)
T, E, K, N_GROUP, TOPK_GROUP, SCALE = 4, 16, 4, 4, 2, 2.5   # small stand-ins for 256 / 8 / 8 / 4 / 2.5


def route_grouped(logits, bias, use_bias_for_weights=False):
    scores = torch.sigmoid(logits)                                    # [T, E] affinities in (0, 1)
    choice = scores + bias                                            # biased scores: selection only
    grp = choice.view(T, N_GROUP, -1).topk(2, dim=-1).values.sum(-1)  # group score = sum of its top-2
    keep_grp = grp.topk(TOPK_GROUP, dim=-1).indices                   # keep the best TOPK_GROUP groups
    mask = torch.zeros(T, N_GROUP).scatter(1, keep_grp, 1.0).repeat_interleave(E // N_GROUP, dim=1)
    ids = choice.masked_fill(mask == 0, float("-inf")).topk(K, dim=-1).indices
    w = (choice if use_bias_for_weights else scores).gather(1, ids)   # weights from UNBIASED scores
    return ids, SCALE * w / w.sum(-1, keepdim=True)


logits = torch.randn(T, E)
bias = torch.zeros(E)
bias[torch.arange(E) % 4 == 0] = 0.5          # the balancer has been boosting under-used experts 0,4,8,12

ids0, w0 = route_grouped(logits, torch.zeros(E))
ids1, w1 = route_grouped(logits, bias)
ids2, w2 = route_grouped(logits, bias, use_bias_for_weights=True)
print("no bias      ids:", ids0.tolist())
print("with bias    ids:", ids1.tolist())
print("groups used     :", (ids1 // (E // N_GROUP)).unique(dim=None).tolist(), "of", N_GROUP,
      "| per token:", [sorted(set(r)) for r in (ids1 // (E // N_GROUP)).tolist()])
print("weights (right) :", [[round(v, 3) for v in r] for r in w1.tolist()])
print("weights (bug)   :", [[round(v, 3) for v in r] for r in w2.tolist()])
print("max weight error from leaking the bias:", f"{(w1 - w2).abs().max().item():.3f}")
python router_grouped.py

Output:

no bias      ids: [[13, 14, 4, 5], [7, 13, 12, 14], [13, 6, 12, 7], [11, 10, 2, 1]]
with bias    ids: [[4, 12, 13, 14], [12, 4, 7, 13], [12, 4, 13, 6], [11, 8, 0, 10]]
groups used     : [0, 1, 2, 3] of 4 | per token: [[1, 3], [1, 3], [1, 3], [0, 2]]
weights (right) : [[0.635, 0.48, 0.702, 0.683], [0.637, 0.514, 0.688, 0.661], [0.561, 0.543, 0.812, 0.585], [0.962, 0.388, 0.336, 0.815]]
weights (bug)   : [[0.798, 0.685, 0.516, 0.501], [0.785, 0.692, 0.522, 0.501], [0.742, 0.729, 0.598, 0.431], [0.688, 0.633, 0.596, 0.583]]
max weight error from leaking the bias: 0.273

Two things to read off it. First, the bias changes which experts are chosen. The boosted experts 0, 4, 8, and 12 move into the top-\(k\), and each token uses exactly two of the four groups. Second, computing the gate weights from the biased scores changes the layer output by up to 0.27 in weight per expert. Nothing crashes and every shape is right; the model just gets measurably worse. Fused router kernels (vLLM’s grouped_topk, SGLang’s biased group top-k) exist to do this whole block in one launch, and their unit tests check exactly this distinction.


5. The indirection table: histogram, pad, scan, scatter

A tiled GEMM wants every BLOCK_M-row tile to use one expert’s weights. So each expert’s run of rows is padded up to a multiple of BLOCK_M with a sentinel row index, and a second array records the expert that owns each tile. This is the contract of vLLM’s moe_align_block_size:

  • sorted_ids[p]: the flat row \(r\) at padded position \(p\), or the sentinel \(T\cdot k\) for padding;
  • expert_ids[p // BLOCK_M]: the expert for that tile;
  • the padded total, so that tiles past the end can exit at once.
# file: moe_align.py
# Build the block-aligned indirection table a fused MoE GEMM consumes (same contract as vLLM's
# moe_align_block_size): sort the T*k (token, slot) pairs by expert, pad each expert's run to a
# multiple of BLOCK_M with a sentinel, and record which expert owns each BLOCK_M-row tile.
import torch


def moe_align(topk_ids, block_m, num_experts):
    flat = topk_ids.flatten().to(torch.int64)                 # flat row r = token * k + slot
    n = flat.numel()
    counts = torch.bincount(flat, minlength=num_experts)      # 1. histogram
    padded = (counts + block_m - 1) // block_m * block_m      # 2. round each run up to BLOCK_M
    starts = torch.cumsum(padded, 0) - padded                 # 3. exclusive scan -> run start
    total = int(padded.sum())

    sorted_ids = torch.full((total,), n, dtype=torch.int32)   # sentinel n = "padding row"
    order = torch.argsort(flat, stable=True)                  # rows grouped by expert, stable
    rank = torch.arange(n) - (torch.cumsum(counts, 0) - counts)[flat[order]]
    sorted_ids[starts[flat[order]] + rank] = order.to(torch.int32)   # 4. scatter into the runs

    expert_ids = torch.repeat_interleave(torch.arange(num_experts, dtype=torch.int32),
                                         padded // block_m)   # one entry per BLOCK_M tile
    return sorted_ids, expert_ids, total


if __name__ == "__main__":
    # The worked example from vLLM's moe_align_block_size docstring.
    topk_ids = torch.tensor([[2, 3, 4], [1, 2, 4], [1, 3, 4], [1, 2, 3]])
    sorted_ids, expert_ids, total = moe_align(topk_ids, block_m=4, num_experts=5)
    print("flat expert of each row :", topk_ids.flatten().tolist())
    print("sorted_ids  :", sorted_ids.tolist())
    print("expert_ids  :", expert_ids.tolist())
    print("rows after padding:", total, " useful:", topk_ids.numel(),
          f" waste: {1 - topk_ids.numel() / total:.0%}")
python moe_align.py

Output:

flat expert of each row : [2, 3, 4, 1, 2, 4, 1, 3, 4, 1, 2, 3]
sorted_ids  : [3, 6, 9, 12, 0, 4, 10, 12, 1, 7, 11, 12, 2, 5, 8, 12]
expert_ids  : [1, 2, 3, 4]
rows after padding: 16  useful: 12  waste: 25%

This reproduces the worked example in vLLM’s moe_align_block_size docstring exactly. Expert 0 receives no rows, so it gets no tile and its weights are never read. Experts 1 to 4 have 3 rows each, padded to 4, and the sentinel 12 marks the padding.

On a GPU, the four numbered steps are one small kernel. The histogram uses shared-memory atomics, the scan over \(E\) counts runs inside one CTA (with \(E\le 384\) it fits easily), and each row is scattered to start[e] + rank. vLLM and SGLang ship CUDA versions of this kernel because, at small batch sizes, its cost is not negligible next to the GEMMs. The stable=True sort is a choice made here, not a requirement. Keeping rows in token order within an expert makes the gathered loads more local and the output deterministic.


6. A fused grouped GEMM in Triton

The kernel is an ordinary tiled GEMM with three changes:

  1. Tile to expert. Program pid_m reads expert_ids[pid_m] and offsets the weight pointer by expert * stride_be.
  2. Gathered A rows. Row indices come from sorted_ids. For GEMM1, the input has one row per token, shared by its \(k\) slots, so the kernel loads row r // TOP_K. For GEMM2, the input already has one row per flat row, so TOP_K=1.
  3. Scattered C rows, with the router weight folded in. Results are stored at row r, so the output comes out in token order. GEMM2 multiplies by topk_w[r] in the epilogue, which leaves the combine as a plain sum over \(k\).

Padding rows (r >= num_rows) are masked on load and on store. They cost tensor-core work but no memory traffic.

# file: fused_moe_triton.py
# A fused-MoE grouped GEMM in Triton: one launch computes every expert's GEMM by reading a
# block-aligned indirection table (moe_align.py). Each program owns BLOCK_M rows that all belong to
# ONE expert, gathers their token rows from A, multiplies by that expert's weight tile, and scatters
# the result to the rows' original (token, slot) positions. No permuted copy of A is ever written.
# Runs on a GPU, or on CPU for correctness work with:  TRITON_INTERPRET=1 python fused_moe_triton.py
import os

import torch
import torch.nn.functional as F
import triton
import triton.language as tl

from moe_align import moe_align
from moe_reference import make_layer, moe_loop, route


@triton.jit
def moe_gemm_kernel(A, B, C, SORTED_IDS, EXPERT_IDS, TOPK_W,
                    num_rows, padded_rows, N, K,
                    stride_am, stride_be, stride_bn, stride_cm,
                    TOP_K: tl.constexpr, MUL_ROUTED_WEIGHT: tl.constexpr,
                    BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    pid_m = tl.program_id(0)
    pid_n = tl.program_id(1)
    if pid_m * BLOCK_M >= padded_rows:            # grid is sized for the worst case; trailing tiles idle
        return

    offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
    rows = tl.load(SORTED_IDS + offs_m)           # flat (token*TOP_K + slot), or the sentinel num_rows
    row_ok = rows < num_rows
    a_rows = rows // TOP_K                        # GEMM1: A holds one row per token, shared by k slots
    expert = tl.load(EXPERT_IDS + pid_m)          # every row of this tile uses the same expert

    offs_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
    offs_k = tl.arange(0, BLOCK_K)
    a_ptrs = A + a_rows[:, None] * stride_am + offs_k[None, :]
    b_ptrs = B + expert * stride_be + offs_n[None, :] * stride_bn + offs_k[:, None]   # B[e] is [N, K]

    acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
    for k0 in range(0, K, BLOCK_K):
        k_ok = (k0 + offs_k) < K
        a = tl.load(a_ptrs, mask=row_ok[:, None] & k_ok[None, :], other=0.0)
        b = tl.load(b_ptrs, mask=k_ok[:, None] & (offs_n[None, :] < N), other=0.0)
        acc += tl.dot(a, b)
        a_ptrs += BLOCK_K
        b_ptrs += BLOCK_K

    if MUL_ROUTED_WEIGHT:                         # fold the router weight into the down projection
        w = tl.load(TOPK_W + rows, mask=row_ok, other=0.0)
        acc = acc * w[:, None]

    c_ptrs = C + rows[:, None] * stride_cm + offs_n[None, :]
    tl.store(c_ptrs, acc.to(C.dtype.element_ty), mask=row_ok[:, None] & (offs_n[None, :] < N))


def moe_gemm(a, b, out, sorted_ids, expert_ids, padded, topk_w, top_k, mul_w,
             block_m, block_n=32, block_k=32):
    """a: [rows_a, K]; b: [E, N, K] (nn.Linear layout); out: [T*k, N] written at the flat row index."""
    E, N, K = b.shape
    grid = (expert_ids.numel(), triton.cdiv(N, block_n))
    moe_gemm_kernel[grid](a, b, out, sorted_ids, expert_ids, topk_w,
                          out.shape[0], padded, N, K,
                          a.stride(0), b.stride(0), b.stride(1), out.stride(0),
                          TOP_K=top_k, MUL_ROUTED_WEIGHT=mul_w,
                          BLOCK_M=block_m, BLOCK_N=block_n, BLOCK_K=block_k)


def fused_moe(x, w_gate, w1, w3, w2, top_k, block_m=16):
    T, H = x.shape
    E, I, _ = w1.shape
    topk_w, topk_ids = route(x, w_gate, top_k)
    sorted_ids, expert_ids, padded = moe_align(topk_ids, block_m, E)
    sorted_ids, expert_ids = sorted_ids.to(x.device), expert_ids.to(x.device)

    w13 = torch.cat([w1, w3], dim=1).contiguous()                   # [E, 2I, H]: gate and up in one GEMM
    h = torch.empty(T * top_k, 2 * I, device=x.device, dtype=x.dtype)
    moe_gemm(x, w13, h, sorted_ids, expert_ids, padded, topk_w, top_k, False, block_m)
    act = F.silu(h[:, :I]) * h[:, I:]                               # [T*k, I]  (fused in production)

    y = torch.empty(T * top_k, H, device=x.device, dtype=x.dtype)
    moe_gemm(act, w2, y, sorted_ids, expert_ids, padded, topk_w.flatten().contiguous(), 1, True, block_m)
    return y.view(T, top_k, H).sum(dim=1), padded                   # combine the k weighted slots


def grouped_mm_moe(x, w_gate, w1, w3, w2, top_k):
    """Third implementation: explicit permute -> torch grouped GEMM with offsets -> un-permute."""
    T, H = x.shape
    E = w1.shape[0]
    topk_w, topk_ids = route(x, w_gate, top_k)
    flat = topk_ids.flatten()
    order = torch.argsort(flat, stable=True)                        # rows grouped by expert
    xs = x[order // top_k]                                          # the permuted copy fused_moe avoids
    offs = torch.cumsum(torch.bincount(flat, minlength=E), 0).to(torch.int32)
    h = F.silu(F.grouped_mm(xs, w1.transpose(-2, -1), offs=offs)) * \
        F.grouped_mm(xs, w3.transpose(-2, -1), offs=offs)
    ys = F.grouped_mm(h, w2.transpose(-2, -1), offs=offs) * topk_w.flatten()[order, None]
    out = torch.zeros_like(x)
    out.index_add_(0, order // top_k, ys)                           # un-permute + combine
    return out


if __name__ == "__main__":
    device = "cpu" if os.environ.get("TRITON_INTERPRET") == "1" else "cuda"
    H, I = 64, 96
    cases = [  # name, tokens, experts, top_k, router bias toward expert 0
        ("uniform E=8 k=2", 64, 8, 2, 0.0),
        ("skewed  E=16 k=4", 50, 16, 4, 3.0),
        ("decode  T=1 k=2", 1, 8, 2, 0.0),
        ("top-1   E=8 k=1", 37, 8, 1, 0.0),
    ]
    for name, T, E, k, bias in cases:
        torch.manual_seed(1)
        wg, w1, w3, w2 = make_layer(H, I, E, seed=2)
        u = torch.nn.functional.normalize(torch.randn(H), dim=0)
        wg[0] += bias * u                                            # make expert 0 popular:
        x = torch.randn(T, H) + 1.5 * u                              # its logit rises by ~1.5*bias
        ref, ids = moe_loop(x, wg, w1, w3, w2, k)
        got, padded = fused_moe(*(t.to(device) for t in (x, wg, w1, w3, w2)), k)
        gmm = grouped_mm_moe(x, wg, w1, w3, w2, k)
        counts = torch.bincount(ids.flatten(), minlength=E)
        print(f"{name}: loads={counts.tolist()}  rows {T*k} -> padded {padded}  "
              f"|fused-ref|={(got.cpu() - ref).abs().max().item():.1e}  "
              f"|grouped_mm-ref|={(gmm - ref).abs().max().item():.1e}")
TRITON_INTERPRET=1 python fused_moe_triton.py

Output (Triton 3.8.0, PyTorch 2.14.1 CPU, interpreter mode):

uniform E=8 k=2: loads=[18, 8, 24, 21, 15, 12, 21, 9]  rows 128 -> padded 192  |fused-ref|=1.1e-06  |grouped_mm-ref|=0.0e+00
skewed  E=16 k=4: loads=[43, 5, 20, 11, 7, 7, 12, 10, 8, 15, 7, 5, 14, 20, 7, 9]  rows 200 -> padded 320  |fused-ref|=1.2e-06  |grouped_mm-ref|=0.0e+00
decode  T=1 k=2: loads=[1, 0, 0, 0, 0, 1, 0, 0]  rows 2 -> padded 32  |fused-ref|=3.0e-07  |grouped_mm-ref|=0.0e+00
top-1   E=8 k=1: loads=[5, 1, 11, 7, 4, 3, 6, 0]  rows 37 -> padded 112  |fused-ref|=9.5e-07  |grouped_mm-ref|=0.0e+00

What the test matrix covers:

Case Why
Uniform, \(E=8\), \(k=2\) The common case; every expert has several tiles’ worth of rows or a partial tile
Skewed, \(E=16\), \(k=4\) Expert 0 takes 43 of 50 tokens. Multi-tile experts next to experts with 5 rows
Decode, \(T=1\) Six experts have zero rows, so they get no tiles. 2 useful rows become 32 padded rows
Top-1, \(T=37\) TOP_K=1 in GEMM1 (no row sharing). Expert 7 is empty. \(T\) is not a multiple of anything
Three implementations Loop, fused Triton kernel, and F.grouped_mm with explicit permute and un-permute

The fused kernel matches the loop to about \(10^{-6}\) in FP32: the same sums, accumulated in a different order. F.grouped_mm is PyTorch’s public grouped-GEMM API. Its documentation targets BF16 on SM80+ CUDA GPUs. The CPU build used here also accepted FP32 inputs, which makes it a useful third opinion while developing.

The interpreter executes tile semantics with NumPy. It proves the indexing is right, which is where MoE kernels break. It says nothing about speed.


7. Compile for Hopper, and what the compiler tells you about decode

# file: compile_check.py
# Ahead-of-time compile the fused-MoE GEMM for an H100 (sm_90) on a machine with no GPU,
# once per specialisation the layer launches (GEMM1: TOP_K=k, no weight; GEMM2: TOP_K=1, weight).
import triton
from triton.backends.compiler import GPUTarget
from triton.compiler import ASTSource

from fused_moe_triton import moe_gemm_kernel

target = GPUTarget("cuda", 90, 32)
sig = {"A": "*bf16", "B": "*bf16", "C": "*bf16", "SORTED_IDS": "*i32", "EXPERT_IDS": "*i32",
       "TOPK_W": "*fp32", "num_rows": "i32", "padded_rows": "i32", "N": "i32", "K": "i32",
       "stride_am": "i32", "stride_be": "i32", "stride_bn": "i32", "stride_cm": "i32",
       "TOP_K": "constexpr", "MUL_ROUTED_WEIGHT": "constexpr",
       "BLOCK_M": "constexpr", "BLOCK_N": "constexpr", "BLOCK_K": "constexpr"}
for name, top_k, mul_w, bm in [("gemm1 (gate+up)", 8, False, 64), ("gemm2 (down)   ", 1, True, 64),
                               ("gemm1 decode   ", 8, False, 16)]:
    src = ASTSource(fn=moe_gemm_kernel, signature=sig,
                    constexprs={"TOP_K": top_k, "MUL_ROUTED_WEIGHT": mul_w,
                                "BLOCK_M": bm, "BLOCK_N": 128, "BLOCK_K": 64})
    k = triton.compile(src, target=target)
    ptx = k.asm["ptx"]
    print(f"{name} BLOCK_M={bm:<3} num_warps={k.metadata.num_warps} shared={k.metadata.shared:>6} B  "
          f"mma={'wgmma' if 'wgmma' in ptx else ('mma.sync' if 'mma.sync' in ptx else 'none')}  "
          f"ptx_lines={ptx.count(chr(10))}")
python compile_check.py

Output (Triton 3.8.0 on a CPU-only Linux box):

gemm1 (gate+up) BLOCK_M=64  num_warps=4 shared= 24576 B  mma=wgmma  ptx_lines=3937
gemm2 (down)    BLOCK_M=64  num_warps=4 shared= 24576 B  mma=wgmma  ptx_lines=3936
gemm1 decode    BLOCK_M=16  num_warps=4 shared= 18432 B  mma=mma.sync  ptx_lines=2294

All three specializations lower to sm_90 code. The last column is the useful part. With BLOCK_M=64, Triton emits Hopper wgmma warpgroup MMAs. With BLOCK_M=16, it falls back to Ampere-style mma.sync, because wgmma works on tiles that are 64 rows tall per warpgroup. That is the structural problem of MoE decode in one line:

  • At decode, an active expert typically has 1 to 4 rows. A 64-row tile is more than 90% padding (section 8), and a 16-row tile cannot use the Hopper MMA path at all.
  • At those sizes the layer is bandwidth-bound anyway, so wasted MMA rows cost little. What matters is that each active expert’s weights are streamed once, at full HBM bandwidth, by enough CTAs to cover the SMs.

That is why production stacks keep separate MoE kernels or configurations for small batches. Common approaches are tuned configs with small BLOCK_M (vLLM keys its tuned Triton configs by \(E\), \(N\), and GPU), a per-token path (vLLM skips alignment when \(T\cdot k\cdot 4\le E\) and gives each routed row its own tile), swap-AB schedules that put the expert’s large \(N\) dimension on the MMA’s M side, and fixed-capacity “masked” layouts that keep shapes static for CUDA Graphs. The ahead-of-time compile is cheap enough to run in the agent loop for every candidate tile shape. Grep the PTX for the instruction you expect before paying for GPU time.


8. Shapes decide everything: padding, stragglers, and the roofline

One active expert in FP8 holds \(3HI\) bytes of weights and does \(2\cdot n_e\cdot 3HI\) FLOPs for \(n_e\) rows. Its arithmetic intensity against weight bytes is simply

\[ \text{FLOP per weight byte} \approx 2\,n_e \quad(\text{FP8 weights}),\qquad \approx n_e \quad(\text{BF16 weights}). \]

An H100 SXM’s ridge point is roughly \(1979\ \text{TFLOP/s} / 3.35\ \text{TB/s}\approx 590\) FLOP/byte for dense FP8. An expert needs hundreds of rows before it becomes compute-bound.

# file: moe_shapes.py
# Back-of-envelope numbers for one MoE layer: tokens per expert, padding waste of a block-aligned
# grouped GEMM, and arithmetic intensity of the expert weights (the number that decides whether
# the layer is bandwidth-bound or compute-bound).
import numpy as np

rng = np.random.default_rng(0)


def routed_counts(tokens, k, E, skew):
    """Expert loads for `tokens` tokens choosing k distinct experts; skew=0 is uniform, >0 is Zipf-like."""
    p = 1.0 / np.arange(1, E + 1) ** skew
    p /= p.sum()
    counts = np.zeros(E, dtype=np.int64)
    for _ in range(tokens):
        counts[rng.choice(E, size=k, replace=False, p=p)] += 1
    return counts


def padded_rows(counts, block_m):
    return int(((counts + block_m - 1) // block_m * block_m).sum())


# DeepSeek-V3-like layer: hidden 7168, expert intermediate 2048, 256 routed experts, top-8.
H, I, E, K = 7168, 2048, 256, 8
w_bytes = 3 * H * I * 1                      # one expert's W1, W3, W2 in FP8 (1 byte/param)
print(f"one expert: {3*H*I/1e6:.1f} M params = {w_bytes/2**20:.0f} MiB in FP8\n")
print(f"{'tokens':>6} {'skew':>4} {'tok/exp':>7} {'active':>6} {'max':>5} {'BLOCK_M':>7} {'rows':>7} {'waste':>6} "
      f"{'FLOP/B(w)':>9}")
for tokens in (1, 16, 128, 1024, 8192):
    for skew in (0.0, 1.0):
        counts = routed_counts(tokens, K, E, skew)
        active = int((counts > 0).sum())
        for block_m in (16, 64):
            rows = padded_rows(counts, block_m)
            useful = tokens * K
            # FLOPs actually needed vs bytes of expert weights that must be streamed (active experts only)
            ai = 2 * useful * 3 * H * I / (active * w_bytes)
            print(f"{tokens:>6} {skew:>4} {useful/E:>7.2f} {active:>6} {int(counts.max()):>5} {block_m:>7} {rows:>7} "
                  f"{1-useful/rows:>6.0%} {ai:>9.1f}")
python moe_shapes.py

Output:

one expert: 44.0 M params = 42 MiB in FP8

tokens skew tok/exp active   max BLOCK_M    rows  waste FLOP/B(w)
     1  0.0    0.03      8     1      16     128    94%       2.0
     1  0.0    0.03      8     1      64     512    98%       2.0
     1  1.0    0.03      8     1      16     128    94%       2.0
     1  1.0    0.03      8     1      64     512    98%       2.0
    16  0.0    0.50    103     4      16    1648    92%       2.5
    16  0.0    0.50    103     4      64    6592    98%       2.5
    16  1.0    0.50     80    13      16    1280    90%       3.2
    16  1.0    0.50     80    13      64    5120    98%       3.2
   128  0.0    4.00    252    11      16    4032    75%       8.1
   128  0.0    4.00    252    11      64   16128    94%       8.1
   128  1.0    4.00    205   110      16    3632    72%      10.0
   128  1.0    4.00    205   110      64   13248    92%      10.0
  1024  0.0   32.00    256    47      16   10032    18%      64.0
  1024  0.0   32.00    256    47      64   16384    50%      64.0
  1024  1.0   32.00    256   774      16   10176    19%      64.0
  1024  1.0   32.00    256   774      64   20352    60%      64.0
  8192  0.0  256.00    256   308      16   67424     3%     512.0
  8192  0.0  256.00    256   308      64   73472    11%     512.0
  8192  1.0  256.00    256  6455      16   67504     3%     512.0
  8192  1.0  256.00    256  6455      64   73024    10%     512.0

Reading the table, row by row:

  • 1 to 16 tokens (decode): 8 to 103 experts are active, each with about one row. Intensity is 2 to 3 FLOP/byte, deep in bandwidth-bound territory. The padding “waste” of 90–98% is irrelevant: those MMA rows are free while the kernel waits on HBM. The real cost is \(\text{active}\times 42\) MiB of weights per layer. Below about 128 tokens, each extra token can wake experts that were idle, so per-token cost falls more slowly than in a dense model. Once nearly every expert is active, extra decode tokens add no weight bytes at all, and batching becomes almost free until the ridge.
  • 1024 tokens: every expert is active, with 32 rows on average. Padding to 64 now wastes half the tensor-core work, so BLOCK_M=16 or 32 is better, and still bandwidth-bound at 64 FLOP/byte.
  • 8192 tokens (prefill): 256 rows per expert reach the ridge, and padding waste drops to 3–11%. This is where wgmma-sized tiles, TMA, and warp specialization pay off, as for any large GEMM.
  • Skew (skew=1.0): the hottest expert takes 774 of 1024 and 6455 of 8192 routed rows, so one expert holds most of the batch. A grid with one tile per BLOCK_M rows still runs, but the hot expert’s tiles form a long tail if tiles are scheduled expert by expert. Persistent kernels that hand out tiles from a global queue, and the load-balancing router from section 4, both exist because of this column.

For expert parallelism across GPUs, the same arithmetic applies per GPU, with \(E\) replaced by the local expert count and \(T\) by the tokens the all-to-all delivers to that GPU.


9. Where this sits in production

Component Role
vLLM / SGLang fused_moe (Triton) The gather/scatter design of section 6: moe_align_block_size, a fused_moe_kernel with FP8/INT8/INT4 weight variants, tuned configs per \((E, N, \text{GPU})\), and fused silu_and_mul and moe_sum kernels
torch.nn.functional.grouped_mm Public PyTorch grouped GEMM over 2-D/3-D operands with offs. Used for MoE training in torchtitan
CUTLASS grouped GEMM Many problem sizes in one persistent launch. Problem shapes and pointer arrays can live on the device
DeepGEMM grouped GEMMs FP8 “m-grouped” kernels with a contiguous layout (each expert’s rows padded to the M-block alignment, for prefill and training) and a masked layout (fixed capacity per expert plus a valid-row count, for CUDA-Graph decode)
Triton grouped GEMM tutorial A persistent Triton kernel that walks a device-side list of GEMM problems
MegaBlocks “Dropless” MoE as block-sparse GEMMs. It removed the capacity-factor token dropping of earlier MoE systems
DeepEP Expert-parallel all-to-all dispatch and combine. High-throughput kernels for prefill and training, and low-latency RDMA kernels for decode

The capacity-factor approach deserves a sentence because generated code still produces it: early MoE systems gave every expert a fixed number of slots and dropped tokens that overflowed. That gives static shapes, but it silently changes the model’s output under load. Modern inference stacks are dropless: every routed row is computed.


10. Verification and benchmark plan

Functional

Check Catches
Loop vs dense vs fused vs grouped-GEMM agree Index bugs in any one path
Experts with 0 rows, 1 row, BLOCK_M−1, BLOCK_M, BLOCK_M+1 rows Tile-boundary and empty-expert bugs (reading weights of an expert with no tile)
\(T=1\) and \(T\cdot k < E\) The decode path, and any per-token fallback
One expert receives every token Grid sizing, the worst-case padded length, stragglers
Router: biased selection, unbiased weights, renormalization, scale factor Silent quality bugs (section 4)
Sentinel rows never written; output rows written exactly once per slot Races and out-of-bounds stores (run compute-sanitizer on a GPU)
BF16/FP8 inputs vs FP32 reference with tolerances Accumulating in low precision; FP8 scale handling

Performance

Metric Target
Decode: achieved bandwidth = active-expert weight bytes / layer time Approaches HBM peak
Prefill: achieved TFLOP/s on useful rows only (exclude padding) Approaches a dense GEMM of the same total size
Alignment + router + combine share of layer time Small at prefill. Watch it at decode, where it can rival the GEMMs
Sweep over \(T\) and routing skew No cliff where the tile config changes

Benchmark with real router outputs captured from the model on real prompts, not randint expert IDs. Uniform random routing hides the straggler problem that real traffic creates.


11. Prompting an AI assistant for an MoE kernel

Generated MoE code tends to loop over experts in Python or to assume uniform loads. State the contract:

  1. Shapes and layout: x [T, H], w13 [E, 2I, H] and w2 [E, H, I] in nn.Linear layout, topk_ids/topk_w [T, k], flat row \(r = t\cdot k + j\).
  2. Metadata: produce sorted_ids, expert_ids, and the padded count with a sentinel. Empty experts get no tiles.
  3. Kernel: one launch per GEMM, tile-to-expert mapping, gathered A rows (r // k for GEMM1), scattered C rows, router weight in the GEMM2 epilogue, FP32 accumulation.
  4. Router semantics if DeepSeek-style: sigmoid, bias for selection only, group-limited top-k, renormalize, scale.
  5. Tests first: interpreter-mode tests against a loop and a dense reference across the cases in section 10, plus an AOT compile for the target with a check that the expected MMA instruction appears.
  6. Metric: GB/s of weights at decode, useful TFLOP/s at prefill.

Pitfalls

Pitfall Result
Python loop over experts \(E\) launches per layer, idle GPU between them
Capacity factor with token dropping Static shapes, silently different outputs under load
Gate weights from bias-adjusted scores Plausible but wrong outputs, no error
Forgetting renormalization or routed_scaling_factor Output scaled wrongly by a per-token factor
Padding rows not masked on store Writes past the output buffer, or over real rows
Launching tiles for empty experts Weight bytes read for nothing, which hurts most at decode
One tile config for all batch sizes Prefill-tuned 64-row tiles at decode, or mma.sync tiles at prefill
Benchmarking with uniform random routing Hides hot-expert stragglers

Checkpoint

  • Explain why an MoE layer is \(E\) ragged GEMMs whose sizes are only known on the device.
  • Write the sorted_ids / expert_ids contract and compute it with a histogram and a scan.
  • Describe how a fused kernel gathers A rows for GEMM1 versus GEMM2, and where the router weight is applied.
  • State the DeepSeek-V3 routing rule, and which scores the gate weights come from.
  • Use “FLOP per weight byte \(\approx 2n_e\)” to say whether a given MoE layer is bandwidth- or compute-bound.
  • Explain why 16-row tiles cannot use Hopper wgmma, and name two ways decode kernels deal with small row counts.

Try this

  1. Change moe_align.py to compute sorted_ids without argsort: one pass for the histogram, an exclusive scan, and an atomic-style running counter per expert. Check it against the current output.
  2. Add an expert_map argument for expert parallelism: experts not on this GPU get expert_ids = -1, and the kernel skips those tiles. Test with half the experts remote.
  3. Fuse silu(gate) * up into the GEMM1 epilogue by computing the gate and up halves as two accumulators in the same program. Confirm the interpreter test still passes.
  4. Run compile_check.py with BLOCK_M ∈ {16, 32, 64, 128} and record which tiles get wgmma, and the shared memory each needs.
  5. On a GPU, capture real topk_ids from a small MoE model, then sweep \(T\in\{1, 8, 64, 512, 4096\}\) and plot achieved GB/s and TFLOP/s for BLOCK_M=16 and BLOCK_M=64.

Summary

An MoE layer replaces one GEMM with many small, ragged, data-dependent GEMMs. Making it fast starts with metadata: a histogram, a scan, and a scatter turn the router’s topk_ids into a block-aligned table that maps every tile to one expert. A single grouped-GEMM launch then either reads a permuted copy of the activations or gathers rows through the table, and scatters results back to token order with the router weight folded in. Routing semantics are part of correctness, especially the selection-only bias in DeepSeek-style routers. Shape arithmetic tells you which regime you are in: decode streams weights at about 2 FLOP per byte and needs bandwidth-oriented kernels, while prefill reaches the ridge only when experts see hundreds of rows. Verify the indexing on a CPU with the Triton interpreter, compile ahead of time for the target, and benchmark with real routing traces.

Sources (for further reading)

  • DeepSeek-AI, “DeepSeek-V3 Technical Report” (arXiv:2412.19437): auxiliary-loss-free balancing with a selection bias, sigmoid gating, node-limited routing. Model config.json files on Hugging Face for DeepSeek-V3, Kimi K2, Qwen3-235B-A22B, gpt-oss-120b, and Mixtral-8x7B (expert counts and routing fields).
  • vLLM source: vllm/model_executor/layers/fused_moe/moe_align_block_size.py (docstring example reproduced in section 5) and fused_moe.py (fused_moe_kernel, the per-token assignment heuristic, tuned configs).
  • PyTorch documentation: torch.nn.functional.grouped_mm (2.14); torchtitan MoE (_run_experts_grouped_mm).
  • Gale, Narayanan, Young, Zaharia, “MegaBlocks: Efficient Sparse Training with Mixture-of-Experts” (MLSys 2023, arXiv:2211.15841).
  • Fedus, Zoph, Shazeer, “Switch Transformers” (arXiv:2101.03961): capacity factor and token dropping.
  • DeepSeek-AI, DeepGEMM repository (m-grouped contiguous and masked FP8 GEMMs) and DeepEP repository (expert-parallel dispatch and combine kernels).
  • NVIDIA CUTLASS documentation: grouped GEMM. PTX ISA: wgmma.mma_async shapes (m64nNk16).
  • Triton documentation: tutorial “Group GEMM”, TRITON_INTERPRET=1, and triton.compile with GPUTarget.