ML Interview Notes
44 min read16 sections
Part 7 · Architectures that change the inference story · 07-01

Mixture of Experts

Status
SOURCE PINNED
Primary sources
  • vllm/model_executor/layers/fused_moe/
  • csrc/libtorch_stable/moe/moe_align_sum_kernels.cu
  • python/sglang/srt/layers/moe/
  • python/sglang/srt/models/deepseek_v2.py
Edition pins
vllm a556f3f · sglang 7d89325

DeepSeek-V3 activates 37 B of its 671 B parameters per token. That buys you a 37 B model's FLOP bill and a 671 B model's HBM bill — but only at batch 1. By the time you are serving a real batch, the FLOP bill is still 37 B and the bandwidth bill has climbed all the way to 671 B. This chapter derives that curve, because it is the single fact that decides how MoE models are served.

§1

The problem

Here is the symptom, in bytes. Take one DeepSeek-V3 MoE layer on one H100 and count the routed expert weights it must pull out of HBM for one decode step. At batch 1 the router picks 8 experts of 256, so the kernel reads 8 experts' worth: $8 \times 3 \times 7168 \times 2048 = 352.3$ M fp8 bytes. At batch 256 — a modest serving batch — 2,048 independent top-8 draws land on essentially every expert, and the kernel reads all 256: 11.27 GB. 256× the tokens, 32× the bytes.

Now do the same for a dense layer of the same width. Batch 1 reads the weight matrix once. Batch 256 reads the weight matrix once. 256× the tokens, 1× the bytes.

32×
DeepSeek-V3 expert bytes, batch 256 vs. batch 1 (derived)
9,456
batch needed to reach H100's fp8 ridge (derived)
296
batch a dense fp8 layer needs (derived)

That is the whole chapter in three numbers. A dense decode GEMM amortises its weight read across the batch, so its arithmetic intensity climbs at $2B/b$ and hits the roofline ridge quickly (§0.4). An MoE layer's climbs at $2Bk/(bE)$ once the batch hits every expert — a factor $E/k$ slower, 32 for DeepSeek-V3. MoE looks like a compute optimisation. It is a memory-capacity problem wearing a compute problem's clothes.

§5.3 owns the answer at cluster scale — expert parallelism, the all-to-all, EPLB. This chapter owns MoE as a model and a kernel on one device: what the router computes, why the expert GEMM cannot be a GEMM, and where the bytes go.

§2

Mental model

A dense feed-forward block is one matrix applied to every token. An MoE block is $E$ matrices, a small learned classifier that picks $k$ of them per token, and a weighted sum of the $k$ results. Every token still does $k$ matrices' worth of arithmetic — the FLOPs per token are fixed and small. But which $k$ is decided at run time, per token, and the union over a batch grows fast. The consequence: the layer's compute scales with $Bk$ and its traffic scales with the number of distinct experts the batch touched, which saturates at $E$.

Mechanically, that turns one clean GEMM into a scatter, a set of ragged GEMMs, and a gather.

Figure 1 — one DeepSeek-V3 MoE layer, 64 tokens, end to end. Shapes are real: $d = 7168$, $d_{ff} = 2048$, $E = 256$, $k = 8$, from vLLM's checked-in model shape table. Row counts marked derived are expectations under uniform routing, not measurements. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

Two things in that picture are worth pausing on. First, the alignment step: the grouped GEMM needs each expert's row count to be a multiple of the tile height, so 512 real rows become about 3,552 padded ones. Second, the weight traffic: 64 tokens touched 87% of the layer's experts. There is no small-batch regime in which MoE reads only a little.

§3

First principles: total parameters, active parameters, and the batch curve

Shapes, sourced

vLLM checks in a table of real MoE shapes for its kernel benchmark. Two of its rows are the two models this chapter uses:

benchmarks/kernels/benchmark_moe_defaults.py:L156-L175 vLLM
# Model configurations: (name, E, N, K, topk, dtype_str, use_fp8, block_shape)
# N = moe_intermediate_size // tp_size (the value used in config file lookup)
MODELS = [
# ...
    # Qwen3-30B-A3B: E=128, topk=8, N=768, K=2048
    ("Qwen3-MoE bf16", 128, 768, 2048, 8, None, False, None),
    # DeepSeek-V3 / MiMo-V2-Flash: E=256, topk=8, N=2048, K=7168
    ("DeepSeek-V3 bf16", 256, 2048, 7168, 8, None, False, None),

So DeepSeek-V3: $d = 7168$, $d_{ff,\text{moe}} = 2048$, $E = 256$, $k = 8$. Qwen3-30B-A3B: $d = 2048$, $d_{ff,\text{moe}} = 768$, $E = 128$, $k = 8$. Layer counts (61 with the first 3 dense for V3, 48 for Qwen3) and the group-limited routing parameters ($n_{\text{group}} = 8$, $\text{topk\_group} = 4$) come from the published configs that §4.4 read; SGLang's config loader confirms the V3 template's topk_group=4 in passing (python/sglang/srt/configs/model_config.py:L411-L420) and both engines' routing tests instantiate num_expert_group=8, topk_group=4 (tests/kernels/moe/test_routing.py:L83-L84 and test/registered/moe/test_topk_renormalize_degenerate.py:L102-L103).

An expert is three matrices — gate, up, down — stored as a fused w13_weight of $[E,\,2 d_{ff},\, d]$ plus a w2_weight of $[E,\, d,\, d_{ff}]$:

vllm/model_executor/layers/fused_moe/unquantized_fused_moe_method.py:L64-L95 vLLM
        if self.moe.is_act_and_mul:
            w13_up_dim = 2 * intermediate_size_per_partition
        else:
            w13_up_dim = intermediate_size_per_partition
        # Fused gate_up_proj (column parallel)
        w13_weight = torch.nn.Parameter(
            torch.empty(
                num_experts,
                w13_up_dim,
                hidden_size,
                dtype=params_dtype,
            ),
            requires_grad=False,
        )
# ...
        # down_proj (row parallel)
        w2_weight = torch.nn.Parameter(
            torch.empty(
                num_experts,
                hidden_size,
                intermediate_size_per_partition,
                dtype=params_dtype,
            ),

So one expert holds $3 d\, d_{ff}$ parameters and costs $6 d\, d_{ff}$ FLOPs per token that routes to it. For DeepSeek-V3 that is $3 \times 7168 \times 2048 = 44.04$ M parameters and $6 \times 7168 \times 2048 = 88.08$ MFLOP per token-expert.

Two parameter counts, two bills

Derived — parameter accounting from the shapes above. Layer counts from the published configs (61 layers, first 3 dense for V3; 48 for Qwen3-30B-A3B). Attention total for V3 is §5.3's derivation, reused.
QuantityDeepSeek-V3Qwen3-30B-A3B
per-expert params $3 d\, d_{ff}$44.04 M4.72 M
routed experts, per layer11.27 B604.0 M
routed experts, whole model653.9 B28.99 B
share of total parameters97.5%95.0%
routed experts activated per token20.43 B1.81 B
shared expert, activated2.55 Bnone
attention, activated11.41 B
total / active, published671 B / 37 B30.5 B / 3.3 B

The three derived rows for V3 sum to 34.40 B against a published 37 B activated; the balance is the three dense MLP layers and the embedding and output matrices, which I did not count. The check matters — a parameter count that does not reconcile with the model card means you read a shape wrong.

The FLOP bill follows the active count. A decode step does $F \approx 2 N_{\text{active}}$ FLOPs per token: 74 GFLOP for DeepSeek-V3, 6.6 GFLOP for Qwen3-30B-A3B — the profiles of a 37 B and a 3 B dense model. The HBM bill follows neither. It follows how many distinct experts the batch touched.

How many experts does a batch touch?

Assume uniform routing and independent tokens. This is a reference model, not a conservative bound on all costs: skew may reduce distinct weights read while increasing the busiest expert's latency. Under this assumption an expert is missed by a token with probability 1 - k/E, giving:

$$E_{\text{touched}}(B) \;=\; E\left(1 - \left(1 - \frac{k}{E}\right)^{B}\right).$$

This is the same coupon-collector shape as $\rho$ in §5.3, applied to experts instead of ranks. Its two limits are the story: $E_{\text{touched}}(1) = k$, and $E_{\text{touched}}(B) \to E$ fast. The half-way batch — where you already stream half the layer — is $B_{1/2} = \ln(1/2)\,/\,\ln(1 - k/E)$: 21.8 tokens for DeepSeek-V3, 10.7 for Qwen3-30B-A3B. You reach 95% of experts at $B = 94$ and $B = 46$ respectively.

Read that again

At batch 94 the uniform independent model touches about 95% of experts in expectation. Small or correlated production batches need not stream every expert. Sparsity's byte savings depend on batch occupancy, weight cache reuse and the kernel's actual reads.

Arithmetic intensity versus batch

Now put both bills together for one MoE layer, at batch $B$, with $b_w$ bytes per weight element. FLOPs are $F = B\,k\,6\,d\,d_{ff}$. Compulsory weight traffic is $Q = E_{\text{touched}}(B)\cdot 3\,d\,d_{ff}\,b_w$; activations are $O(Bd)$ and negligible against $O(E d\, d_{ff})$. Dividing, $d$ and $d_{ff}$ cancel completely:

$$I_{\text{MoE}}(B) \;=\; \frac{2\,B\,k}{b_w\, E_{\text{touched}}(B)} \;=\; \frac{2\,B\,k}{b_w\, E\left(1 - (1 - k/E)^{B}\right)}.$$

Two limits, and both are load-bearing. At $B = 1$, $E_{\text{touched}} = k$ and $I = 2/b_w$ — exactly the dense decode GEMV intensity from §0.4. At batch 1 an MoE layer and a dense layer are the same kind of object: you read a weight byte and do one multiply-add with it. As $B \to \infty$, $E_{\text{touched}} \to E$ and

$$I_{\text{MoE}}(B) \;\longrightarrow\; \frac{2\,B\,k}{b_w\,E} \;=\; I_{\text{dense}}(B) \cdot \frac{k}{E}.$$

The asymptotic slope is the dense slope divided by $E/k$. For DeepSeek-V3 that ratio is 32; for Qwen3-30B-A3B, 16. Setting $I_{\text{MoE}} = I^{*}$ and solving gives the batch at which the expert GEMM finally becomes compute-bound:

$$B^{*}_{\text{MoE}} \;=\; \frac{b_w\,I^{*}\,E}{2\,k} \;=\; \frac{591 \times 256}{2 \times 8} \;=\; 9{,}456 \text{ tokens (fp8, } b_w = 1, I^{*} = 591).$$

Which is §4.4's $B^{*}_{\text{MoE}} = M^{*} \cdot E/k$ arrived at from the other end: the same 296-token dense crossover, multiplied by the same $E/k = 32$.

Figure 2 — arithmetic intensity versus batch, dense against MoE. Derived from $I_{\text{MoE}}(B) = 2Bk/(b_w E_{\text{touched}}(B))$ with DeepSeek-V3's $E = 256$, $k = 8$, fp8 weights. Both axes are log scale. The two curves start at the same point and separate by exactly $E/k = 32$.

Arithmetic intensity against batch size for dense and MoE layers Log-log plot. The dense line rises as two times batch, crossing the H100 fp8 ridge of 591 FLOP per byte at batch 296. The MoE curve starts at the same intensity of 2 at batch 1, stays nearly flat through batch 32, and only reaches 256 FLOP per byte at batch 4096 — it would need batch 9456 to reach the ridge. H100 fp8 ridge, I* = 591 1 8 64 512 4096 arithmetic intensity, FLOP/byte 1 8 64 512 4096 batch size B (tokens in the step) dense hits the ridge at B = 296 dense: I = 2B MoE: I = 2Bk / E_touched(B) B = 4096, I = 256 ridge needs B = 9,456 both start at I = 2/b_w

What that costs in wall-clock, on one H100

DeepSeek-V3 in fp8 is 671 GB and does not fit on one GPU, so take Qwen3-30B-A3B, which at 28.99 GB of fp8 expert weights does. Per decode step, expert-weight streaming alone, at the H100's 3.35 TB/s:

Derived — Qwen3-30B-A3B, fp8, one H100. $E_{\text{touched}}(B) \times 48$ layers $\times$ 4.719 MB per expert, divided by 3.35 TB/s. Routed experts only; attention, the router, embeddings and all activation traffic are excluded. Not measured.
Batch $B$experts touched / 128expert bytes / stepstream time / stepper token
18.01.81 GB541 µs541 µs
851.611.69 GB3.49 ms436 µs
1682.418.67 GB5.57 ms348 µs
32111.825.31 GB7.56 ms236 µs
64125.928.52 GB8.51 ms133 µs
128128.028.98 GB8.65 ms67.6 µs
512128.028.99 GB8.65 ms16.9 µs

Read the last two columns together. Step time stops growing at batch 128 — past that point every additional token is free in bandwidth terms, which is why MoE serving wants the largest batch it can assemble. The batch-1 row is the other half: 541 µs of expert weights for a 30 B model, against the book's dense-8 B bf16 decode floor of 4.48 ms. At batch 1 a sparse 30 B model is cheaper than a dense 8 B one; at batch 512 it costs the same as a dense 30 B one. MoE has the small model's compute profile always, the small model's memory profile at batch 1, and the big model's memory profile everywhere else.

§4

The router: gate, top-k, and the bias that DeepSeek added

The gate is a single linear layer, $d \times E$, with no bias — 1.84 M parameters for DeepSeek-V3, 0.02% of the layer. Everything interesting is what happens to its output. vLLM's generic path is fused_topk, which delegates to a CUDA kernel and branches only on the scoring function:

vllm/model_executor/layers/fused_moe/router/fused_topk_router.py:L80-L100 vLLM
def fused_topk(
    hidden_states: torch.Tensor,
    gating_output: torch.Tensor,
    topk: int,
    renormalize: bool,
    indices_type: torch.dtype | None = None,
    scoring_func: str = "softmax",
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    assert hidden_states.size(0) == gating_output.size(0), "Number of tokens mismatch"

    M, _ = hidden_states.size()

    topk_weights = torch.empty(
        M, topk, dtype=torch.float32, device=hidden_states.device
    )
    topk_ids = torch.empty(
        M,
        topk,
        dtype=torch.int32 if indices_type is None else indices_type,
        device=hidden_states.device,
    )

Note the two live choices. Softmax before or after top-k: Mixtral-style routing softmaxes the full logit vector first, so the $k$ selected weights do not sum to 1; renormalize=True divides by their sum afterwards, which is equivalent to softmaxing only over the selected $k$. DeepSeek-V3 instead scores with sigmoid — independent per-expert gates, not a distribution — and then renormalises. The model config carries the choice (scoring_func, norm_topk_prob) and vLLM passes it straight through (vllm/model_executor/models/deepseek_v2.py:L370-L386).

DeepSeek's actual routing is the grouped, bias-corrected variant, and this is the code worth reading carefully:

vllm/model_executor/layers/fused_moe/router/grouped_topk_router.py:L112-L161 vLLM
    if scoring_func == "softmax":
        scores = torch.softmax(gating_output, dim=-1)
    elif scoring_func == "sigmoid":
        scores = gating_output.sigmoid()
    else:
        raise ValueError(f"Unsupported scoring function: {scoring_func}")

    num_token = scores.size(0)
    if e_score_correction_bias is not None:
        # Store original scores before applying correction bias. We use biased
        # scores for expert selection but original scores for routing weights
        original_scores = scores
        scores = scores + e_score_correction_bias.unsqueeze(0)
        group_scores = (
            scores.view(num_token, num_expert_group, -1).topk(2, dim=-1)[0].sum(dim=-1)
        )
# ...
    group_idx = torch.topk(group_scores, k=topk_group, dim=-1, sorted=use_sorted)[
        1
    ]  # [n, top_k_group]
    group_mask = torch.zeros_like(group_scores)  # [n, n_group]
    group_mask.scatter_(1, group_idx, 1)  # [n, n_group]
    score_mask = (
        group_mask.unsqueeze(-1)
        .expand(num_token, num_expert_group, scores.size(-1) // num_expert_group)
        .reshape(num_token, -1)
    )  # [n, e]
    tmp_scores = scores.masked_fill(~score_mask.bool(), float("-inf"))  # [n, e]

    if e_score_correction_bias is not None:
        topk_ids = torch.topk(tmp_scores, k=topk, dim=-1, sorted=use_sorted)[1]
        # Use original unbiased scores for the routing weights
        topk_weights = original_scores.gather(1, topk_ids)
    else:
        topk_weights, topk_ids = torch.topk(
            tmp_scores, k=topk, dim=-1, sorted=use_sorted
        )

    if renormalize:
        topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)

Three mechanisms, stacked:

bias correction

Selection is biased, scores are not

The correction bias changes which experts are selected. Their unadjusted scores are gathered for weighting, but changing the selected expert set generally changes the forward output and selected-score normalization. Auxiliary-loss-free balancing means no added balancing loss, not an unchanged forward function.

group limiting

Top-4 of 8 groups, then top-8

The 256 experts are partitioned into $n_{\text{group}} = 8$ groups of 32. Each group scores as the sum of its top two experts; the best $\text{topk\_group} = 4$ survive and the top-8 is taken within them. At inference this bounds each token to 4 of 8 groups — and under a group-aligned EP placement, to 4 destination ranks.

renormalise + scale

Weights sum to 1, then to $s$

renormalize divides by the selected sum; routed_scaling_factor then multiplies (2.5 for V3). SGLang guards the division with an explicit epsilon in fp32, noting it "is not representable in fp16" (python/sglang/srt/layers/moe/topk.py:L1352-L1361).

SGLang's reference implementation is the same algorithm with one extra move — it can splice the shared expert in as a fake routed expert:

python/sglang/srt/layers/moe/topk.py:L1309-L1350 SGLang
    scores = gating_output.sigmoid()
    num_token = scores.shape[0]
    num_experts = scores.shape[1]
    scores_for_choice = scores.view(num_token, -1) + correction_bias.unsqueeze(0)
    group_scores = (
        scores_for_choice.view(num_token, num_expert_group, -1)
        .topk(2, dim=-1)[0]
        .sum(dim=-1)
    )  # [n, n_group]
# ...
    topk_weights = scores.gather(1, topk_ids)

    if num_fused_shared_experts:
        topk_ids[:, -1] = torch.randint(
            low=num_experts,
            high=num_experts + num_fused_shared_experts,
            size=(topk_ids.size(0),),
            dtype=topk_ids.dtype,
            device=topk_ids.device,
        )
        if routed_scaling_factor is not None:
            topk_weights[:, -1] = (
                topk_weights[:, :-1].sum(dim=-1) / routed_scaling_factor
            )

That is worth understanding before §7: the shared expert is appended as expert id 256 and given a synthetic weight, so top-8 becomes top-9 and the shared FFN disappears into the same grouped GEMM instead of being a separate launch.

Where they differ

The algorithms are identical; the packaging is not. vLLM exposes routing as a pluggable BaseRouter hierarchy under fused_moe/router/FusedTopKRouter, GroupedTopKRouter, ZeroExpertRouter, a RoutingSimulatorRouter — chosen by a factory. SGLang keeps one TopK module with a TopKConfig dataclass (python/sglang/srt/layers/moe/topk.py:L215-L223) and branches internally over a dozen backends. A new routing scheme is a new class in vLLM and a new branch in SGLang; that is why vLLM's DeepSeek-V4 dsv4_topk.py could land as a sibling file.

§5

Grouped GEMM: why the expert matmul is not a GEMM

After routing, expert $e$ owns $m_e$ rows, data-dependent, with $\sum_e m_e = Bk$. A GEMM wants one $M$. Padding every expert to the batch size is out — for DeepSeek-V3 at $B = 512$ that is $256 \times 512 = 131{,}072$ rows to do 4,096 rows of work. Looping over experts in Python is also out: 256 launches per layer over 58 layers is 14,848 kernel launches per decode step, against a step budget of a few milliseconds. Two workable answers remain — index into the activations in place, or physically permute them into per-expert segments. Both engines implement both; which one you get is a dispatch decision, and the defaults below are the ones that matter.

Which path actually runs

Neither engine has a single MoE kernel, and the two paths below are not "vLLM's" and "SGLang's" — they are what each engine happens to pick under one configuration. As of a556f3f / 7d89325, for DeepSeek-V3's block-fp8 weights on an H100:

vLLM runs a priority list and moves Triton to the front only when ep_size == 1; with expert parallelism on it moves FlashInfer CUTLASS to the front instead, falling through to DeepGEMM and then Triton if the FlashInfer kernel is unavailable (vllm/model_executor/layers/fused_moe/oracle/fp8.py:L112-L123, L271-L418). --moe-backend (default auto, vllm/config/kernel.py:L193, registered at vllm/engine/arg_utils.py:L1621-L1623) overrides the list; VLLM_USE_DEEP_GEMM / VLLM_MOE_USE_DEEP_GEMM both default to 1 but are only consulted when explicitly set, so the priority list wins on a plain launch (vllm/envs.py:L197-L198).

SGLang's --moe-runner-backend defaults to auto and --moe-a2a-backend to none (python/sglang/srt/server_args.py:L2374-L2400). Under auto, the DeepGEMM runner is selected only if the all-to-all backend is deepep, mooncake or nixl — otherwise it resolves to Triton (python/sglang/srt/layers/quantization/fp8.py:L1096-L1119, L2319-L2337). DeepGEMM also needs the external deep_gemm package importable, SM90+ but not SM120, and SGLANG_ENABLE_JIT_DEEPGEMM (default 1) (python/sglang/srt/layers/deep_gemm_wrapper/configurer.py:L17-L35). A default single-node SGLang launch does not run the permute path described below; you get it with --moe-runner-backend deep_gemm, or for free once you turn on DeepEP, as §5.3's command does.

Figure 3 — the two grouped-GEMM strategies. Left: sort the token-expert pairs into a contiguous per-expert layout and run a grouped GEMM over segments. Right: leave the activations where they are and give the kernel a token→expert map, gathering rows through it. Both must round each expert's segment up to a tile boundary. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

vLLM's Triton path: align, then index

vLLM's moe_align_block_size is the clearest single artefact in either codebase for understanding MoE kernels, because its docstring works the example:

vllm/model_executor/layers/fused_moe/moe_align_block_size.py:L59-L72 vLLM
    Example:
    Given topk_ids = [[2, 3, 4], [1, 2, 4], [1, 3, 4], [1, 2, 3]],
    block_size = 4, and num_experts = 4:
    - We initially have 12 tokens (after repeating 'top_k' times) and 4 experts,
        with each expert needing to process 3 tokens.
    - As block_size is 4, we pad 1 token for each expert.
    - First, flatten topk_ids to [2, 3, 4, 1, 2, 4, 1, 3, 4, 1, 2, 3].
    - Then append padding tokens [12, 12, 12, 12] for each block.
    - After sorting by expert index, we obtain token_ids
        [3, 6, 9, 12, 0, 4, 10, 12, 1, 7, 11, 12, 2, 5, 8, 12].
        Tokens 12 are non-existent (padding) and are ignored in
        the subsequent matrix multiplication.
    - The padding ensures that the total number of tokens is now divisible
        by block_size for proper block matrix operations.

The upstream example uses labels 1 through 4. Executable zero-based IDs for four experts must be 0 through 3: subtract one from each expert ID; assignment indices are unchanged.

SGLang carries a byte-identical docstring at python/sglang/srt/layers/moe/moe_runner/triton_utils/moe_align_block_size.py:L60-L73 — the Triton MoE path has a shared ancestor and the two projects have been trading patches on it since. The CUDA implementation counts per-expert hits in shared memory, ceils each count to the block size, block-scans them into offsets, and then has every thread atomicAdd its way into the right slot:

csrc/libtorch_stable/moe/moe_align_sum_kernels.cu:L160-L194 vLLM
  int expert_count = 0;
  int expert_id = threadIdx.x;
  if (expert_id < num_experts) {
    int warp_idx = expert_id / experts_per_warp;
    int expert_offset = expert_id % experts_per_warp;
    expert_count = shared_counts[warp_idx * experts_per_warp + expert_offset];
    expert_count = CEILDIV(expert_count, block_size) * block_size;
  }

  int cumsum_val;
  BlockScan(temp_storage).ExclusiveSum(expert_count, cumsum_val);
  if (expert_id <= num_experts) {
    cumsum[cumsum_offset + expert_id] = cumsum_val;
  }
# ...
  if (threadIdx.x < num_experts) {
    for (int i = cumsum[cumsum_offset + threadIdx.x];
         i < cumsum[cumsum_offset + threadIdx.x + 1]; i += block_size) {
      expert_ids[expert_ids_offset + i / block_size] = threadIdx.x;
    }
  }

  // Fill remaining expert_ids with -1
  const size_t fill_start_idx =
      cumsum[cumsum_offset + num_experts] / block_size + threadIdx.x;
  for (size_t i = fill_start_idx; i < max_num_m_blocks; i += blockDim.x) {
    expert_ids[expert_ids_offset + i] = inactive_expert_id;
  }

The line that matters is expert_count = CEILDIV(expert_count, block_size) * block_size. Each expert's segment is rounded up to a whole tile. That single ceiling is the source of most of MoE's tensor-core waste at decode, quantified below.

The payoff is that the activations are never physically permuted. The Triton kernel reads the sorted index list and divides by top_k to recover the original row:

vllm/model_executor/layers/fused_moe/fused_moe.py:L404-L423, L466-L479 vLLM
    offs = tl.arange(0, BLOCK_SIZE_M).to(tl.int64)
    num_tokens_post_padded = tl.load(num_tokens_post_padded_ptr)
    if pid_m * BLOCK_SIZE_M >= num_tokens_post_padded:
        return
    if not naive_block_assignment:
        offs_token_id = pid_m * BLOCK_SIZE_M + offs
        offs_token = tl.load(sorted_token_ids_ptr + offs_token_id)
# ...
    token_mask = offs_token < num_valid_tokens

    off_experts = tl.load(expert_ids_ptr + pid_m).to(tl.int64)
# ...
        a_ptrs = a_ptr + (
            offs_token[:, None] // top_k * stride_am + offs_k[None, :] * stride_ak
        )
        b_ptrs = (
            b_ptr
            + off_experts * stride_be
            + (offs_bn[:, None] * stride_bn + offs_k[None, :] * stride_bk)
        )

Each M-block reads one expert id, offsets into B by off_experts * stride_be, and gathers its $\le$ BLOCK_SIZE_M activation rows through offs_token // top_k. Invalid slots are masked. The unpermute is equally cheap: the output cache is $[M, \text{topk}, N]$, and ops.moe_sum reduces the topk axis (vllm/model_executor/layers/fused_moe/fused_moe.py:L1854-L1857) — no gather, no scatter, just a strided add. The routing weight is not applied here: the second grouped GEMM is dispatched with mul_routed_weight = not apply_router_weight_on_input, so w2's kernel scales each row by its topk_weight on the way out and moe_sum is a plain unweighted sum (:L1830-L1857).

At very small batches vLLM skips the alignment kernel entirely:

vllm/model_executor/layers/fused_moe/fused_moe.py:L1557-L1570 vLLM
    """Prepare expert assignments for the aligned and low-latency Triton paths."""
    # SPARSITY_FACTOR is a heuristic margin ensuring tokens_in_chunk * top_k
    # activates only a small fraction of total experts
    # Skips moe_align_block_size and activates the `sorted_token_ids is None`
    # path of the fused_moe_kernel kernel
    naive_block_assignment = (
        expert_map is None
        and num_tokens * top_k_num * 4 <= global_num_experts
        and not (
            (use_int8_w8a16 or use_int4_w4a16)
            and block_shape is not None
            and block_shape[1] > 0
        )
    )

For DeepSeek-V3, $B \cdot 8 \cdot 4 \le 256$ means $B \le 8$: at batch 8 or below, every M-block is one token and the sort is pure overhead. Note the first conjunct, expert_map is None — under expert parallelism there is a map to apply, so the shortcut is off no matter how small the batch, and the alignment kernel runs at every batch size.

SGLang under DeepGEMM: permute for real, because DeepGEMM demands it

When SGLang's DeepGEMM runner is selected — not the default, per the callout above — the contiguous grouped GEMM requires each expert's segment to start at a 128-row boundary in one packed buffer. So it physically copies:

python/sglang/srt/layers/moe/moe_runner/deep_gemm.py:L881-L896 SGLang
    # The compact layout avoids scaling masked buffers with the expert count.
    # Scatter and post-permute skip non-local experts mapped to -1.
    block_e = 128
    num_experts = runner_config.num_local_experts
    num_assignments = topk_ids.numel()
    all_tokens = _get_compact_all_tokens(num_assignments, num_experts, block_e)

    tokens_per_expert, unused_masked_dst = fused_moe_dispatch_index(
        topk_ids, num_experts, 1
    )
    dispose_tensor(unused_masked_dst)
    valid_tokens_per_expert = tokens_per_expert
    tokens_per_expert = (ceil_div(tokens_per_expert, block_e) * block_e).to(torch.int32)
    # Keep graph-static shapes by appending padding to the final segment.
    # Its m_indices stay -1, so DeepGEMM skips those rows.
    tokens_per_expert[-1].add_(all_tokens - tokens_per_expert.sum())

Then ep_scatter writes the permuted rows and the segment map, the grouped GEMM runs once over m_indices, and post_reorder_deepgemm reverses it:

python/sglang/srt/layers/moe/moe_runner/deep_gemm.py:L951-L1028, L1020-L1034 SGLang
    expert_start_loc = torch.empty(
        num_experts, device=hidden_states_device, dtype=torch.int32
    )
    m_indices = torch.empty(all_tokens, device=hidden_states_device, dtype=torch.int32)
    src2dst = torch.empty_like(topk_ids, dtype=torch.int32)
    ep_scatter(
        packed_input_source,
        packed_input_source_scale,
        topk_ids,
        tokens_per_expert,
        valid_tokens_per_expert,
        expert_start_loc,
        packed_input,
        packed_input_scale,
        m_indices,
        src2dst,
# ...
    post_reorder_deepgemm(
        runner_output.hidden_states,
        output,
        src2dst,
        topk_ids,
        topk_weights,
        runner_config.top_k,
        hidden_states_shape[0],
        hidden_states_shape[1],

The tradeoff is explicit. vLLM's index-gather avoids a full copy of the activations — for DeepSeek-V3 at $B = 512$ that copy is $Bk \times d = 29.4$ MB of fp8 written and read again. SGLang pays it to get contiguous, aligned inputs, which is what lets it hand the GEMM to DeepGEMM's hand-tuned Hopper kernels instead of Triton. Whether that wins on your shapes is a measurement, and neither tree contains one.

The padding tax, quantified

Define the padding factor as padded rows over real rows. When each expert's mean row count $\bar m = Bk/E$ is well below the tile height $M_{\text{blk}}$, every touched expert costs a full tile:

$$P(B) \;=\; \frac{\sum_e \lceil m_e / M_{\text{blk}} \rceil M_{\text{blk}}}{B\,k} \;\approx\; \frac{E_{\text{touched}}(B)\; M_{\text{blk}}}{B\,k}.$$

This is a tensor-core tax, not a bandwidth tax — the padded rows are zeros that still occupy MMA slots. And it explains an artefact you can read straight out of vLLM's repository. The project ships autotuned Triton configs per $(E, N, \text{device})$; here is the tile height it found for DeepSeek-V3 at TP=4 on an H100:

Cited — vllm/model_executor/layers/fused_moe/configs/E=256,N=512,device_name=NVIDIA_H100_80GB_HBM3.json, the project's checked-in autotuning result. $M_e = Bk/E$ and $P(B)$ columns are derived, not measured. The $P(B)$ rows through $B = 2048$ use the $E_{\text{touched}}(B)\,M_{\text{blk}}/(Bk)$ approximation. The last row does not: at $B = 4096$ the mean row count $\bar m$ equals $M_{\text{blk}}$ exactly, the approximation collapses to 1.0, and the exact expectation of $\sum_e \lceil m_e/128\rceil\,128$ under binomial routing is used instead — about half the experts land above 128 rows and are charged 256.
Batch $B$tuned BLOCK_SIZE_M$M_e = Bk/E$ratiopadding factor $P(B)$
1160.0316.0×
64162.06.9×
2561682.0×
51232162.0×
102464322.0×
2048128642.0×
40961281281.5×

The autotuner independently rediscovered §4.4's $M_e = Bk/E$: from batch 256 upward the winning tile is consistently $2 M_e$, and below batch 256 it floors at 16 because Triton cannot go lower usefully. A dense GEMM at batch 2048 would want a 128-row tile; the MoE layer at batch 2048 also wants 128 — because its effective $M$ is 64, not 2048. The heuristic fallback encodes the same insight in a comment: "Use a small M tile for decode-like batches where tokens are spread thin across experts" (vllm/model_executor/layers/fused_moe/fused_moe.py:L1320-L1324).

§6

Capacity factor and token dropping: what these engines actually do

The training literature will tell you that MoE layers have a capacity factor: each expert gets a fixed buffer of $C = \lceil \alpha B k / E \rceil$ rows, and tokens that arrive at a full expert are dropped — their contribution from that expert is zero and only the residual carries them forward. GShard and Switch Transformer both work that way, and it exists because training wants static shapes.

Neither engine drops tokens at inference

I grepped both trees for capacity_factor, drop_tokens and token drop at these SHAs. In vLLM the only hits are a vision-tower model (vllm/models/dots3_note/nvidia/vision.py) and Arctic's HF config object carrying moe_train_capacity_factor / moe_eval_capacity_factor as inert fields (vllm/transformers_utils/configs/arctic.py:L150-L151). In SGLang the only hits are a video VAE. No LLM MoE path in either engine has a capacity factor, and no token is ever dropped. Every buffer is sized so the worst case fits.

That decision has a real cost, and you can watch it being paid. Three buffer-sizing strategies appear in the two trees. Contiguous/compact sizes to what actually happened: vLLM allocates max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1) (vllm/model_executor/layers/fused_moe/moe_align_block_size.py:L74), so the worst case is one wasted partial tile per expert — bounded and small. Batched/masked gives every expert a rectangle of $m_{\max}$ rows; vLLM's BatchedPrepareAndFinalize, the reference format for all-to-all backends, allocates $[E_{\text{local}},\ m_{\max},\ d]$ with $m_{\max}$ set to the scheduler's entire token budget (vllm/model_executor/layers/fused_moe/layer.py:L259, L343) — a capacity factor of $E/k$ in effect, enough that all $B$ tokens could pile onto one expert. Correct by construction, and expensive. Measured cap is SGLang's fix after hitting exactly that wall — opt-in: SGLANG_OPT_DG_MASKED_M_CAP defaults to False (python/sglang/srt/environ.py:L989), so on a stock launch the uncapped $m_{\max}$ is what allocates. Its comment is the most instructive text in either codebase on this topic:

python/sglang/kernels/ops/moe/ep_moe_kernels.py:L1547-L1576 SGLang
    # For masked grouped GEMM, shape M should be multiple of the block M (current block M: {block_m}) https://github.com/deepseek-ai/DeepGEMM/blob/main/deep_gemm/jit_kernels/m_grouped_gemm.py#L165
    m_max = (hidden_states.size(0) // 256 + 1) * 256
    if (
        envs.SGLANG_OPT_DG_MASKED_M_CAP.get()
        and not torch.cuda.is_current_stream_capturing()
    ):
# ...
        # m_max reserves capacity for ALL rank tokens in EVERY local expert:
        # the [num_local_experts, m_max, *] masked-GEMM intermediates reach
        # 7+ GiB per 32k-token chunk and OOM saturated serving.  The hottest
        # expert only ever holds max(masked_m) rows, so cap the padded
        # capacity there (rounded up to the DeepGEMM block-M).  Costs one
        # probe dispatch-index launch + one D2H sync per MoE layer;
        # correctness is unconditional (m_cap >= max(masked_m) by
        # construction, and the final src2dst below is built with the same
        # capped stride).
        masked_m_probe, _ = fused_moe_dispatch_index(topk_ids, num_local_experts, m_max)
        m_cap = (int(masked_m_probe.max().item()) + 255) // 256 * 256
        m_max = min(m_max, max(m_cap, 256))
    expected_m = (topk_ids.numel() - 1) // num_local_experts + 1

    masked_m, src2dst = fused_moe_dispatch_index(topk_ids, num_local_experts, m_max)

    gateup_input = torch.empty(
        (num_local_experts, m_max, hidden_states.size(1)),
        device=hidden_states.device,
        dtype=output_dtype,
    )

Read the tradeoff: a device-to-host sync per MoE layer, deliberately accepted, to avoid allocating for a worst case that never happens — and two guards. The env flag is off unless you set it, and even when set the sync is illegal during CUDA-graph capture, so decode keeps the uncapped $m_{\max}$. SGLang also picks between the masked and compact layouts by estimating each one's peak bytes against a memory budget (python/sglang/srt/layers/moe/moe_runner/deep_gemm.py:L117-L152, landed in SGLang #33474).

The cited serving paths preserve all routed token assignments rather than using training-style token dropping. They still need static allocations during graph capture: worst-case/reserved buffers, masks and padding coexist with dynamic logical row counts. Overflow must trigger resizing, fallback or explicit failure, not silent token loss.

§7

Shared experts: the part that is never sparse

DeepSeek-V3 has one always-on expert per MoE layer alongside the 256 routed ones; Qwen3-30B-A3B has none (shared_expert_intermediate_size defaults to 0 and the module is skipped, vllm/model_executor/models/qwen3_moe.py:L175-L197). The argument is redundancy: without one, every routed expert must independently learn the general-purpose transformations all tokens need, spending capacity on $E$ copies of the same thing.

At batch 1, one same-size shared expert adds 1/8 = 12.5% to the eight routed experts' weight bytes, and accounts for 1/9 = 11.1% of their combined total. When all 256 routed experts are touched, it adds 1/256 and contributes 1/257 of the total. These byte fractions do not prove negligible latency: shared-expert compute, launches and overlap still matter. Placement and fusion decide whether work is local and whether a separate launch is needed. Both engines can fuse it into the routed path rather than launching it separately, but only one of them does so on NVIDIA by default. SGLang remaps the checkpoint tensor into an extra expert slot:

python/sglang/srt/models/deepseek_v2.py:L584-L604 SGLang
        # num_fused_shared_experts drives weight remapping in deepseek_weight_loader:
        # mlp.shared_experts → mlp.experts.256 when > 0.
        self.num_fused_shared_experts = 0 if _fusion_disabled else n_shared_experts

        # DeepEP and MegaMOE shared expert fusion: shared expert is fused into
        # the same MoE kernel as a local expert at each EP rank. Expert layout
        # is expanded from 256 routed to 256+EP_size (e.g. 272 for EP=16).
        _uses_per_rank_shared_slots = has_per_rank_fused_shared_slots(
            self.num_fused_shared_experts
        )

        if _uses_per_rank_shared_slots:
            # 256 routed + EP_size shared slots = 272 experts total (for EP=16)
            num_experts_for_moe = config.n_routed_experts + self.moe_ep_size
            top_k_for_moe = config.num_experts_per_tok + 1  # 8 routed + 1 shared

vLLM has the same lever — fuse_shared_experts on the MoE factory (vllm/model_executor/models/deepseek_v2.py:L391-L394) — but at this SHA it is never pulled on NVIDIA. resolve_layer_fused_shared_expert gates entirely on rocm_aiter_ops.is_fusion_moe_shared_experts_enabled() (vllm/model_executor/layers/fused_moe/utils.py:L73-L85), and that classmethod is wrapped in @if_aiter_supported, which returns None off ROCm gfx9 (vllm/_aiter_ops.py:L195-L207, L1836-L1838). So on an H100 vLLM builds a plain DeepseekV2MLP at $\text{moe\_intermediate\_size} \times n_{\text{shared}}$ (vllm/model_executor/models/deepseek_v2.py:L355-L368) and hands it to the MoE layer as a separate module.

Fusion, and when you lose it

SGLang fuses by default for DeepSeek-V3/R1 — --disable-shared-experts-fusion defaults to False (python/sglang/srt/server_args.py:L2543-L2550) — but shared_experts_fusion_disable_reason turns it off again for a long list of configurations: any DeepEP-class a2a backend ("fusion off by default (use --enforce-shared-experts-fusion to enable)"), SBO or TBO, expert parallelism with moe_ep_size > 1 on anything but AMD gfx942+, a quantization scheme that keeps the shared expert at higher precision than the routed ones, and any n_routed_experts outside $\{256, 384\}$ (python/sglang/srt/models/deepseek_v2.py:L3019-L3074). The 272-expert layout the quote above describes therefore needs --enforce-shared-experts-fusion on NVIDIA, because has_per_rank_fused_shared_slots requires both a DeepEP-class backend and a non-zero num_fused_shared_experts (python/sglang/srt/layers/moe/utils.py:L496-L505). vLLM on NVIDIA never fuses, so it always pays the extra launch — the arithmetic above says that costs bandwidth, not much, but it is a real kernel launch per layer.

§8

Memory arithmetic, and why MoE and quantization belong together

Expert weights dominate everything. DeepSeek-V3's 653.9 GB of routed experts in fp8 is 97.5% of the model; in bf16 it is 1,308 GB. Put that against §2.1's budget. Mind the units, because this table is in decimal GB and the card is not: an “80 GB” H100 is 79.65 GiB. At gpu_memory_utilization=0.92 that is 73.28 GiB, and after the 6.0 GiB §2.1 charges for activations, graphs and non-torch allocations, 67.28 GiB is left for weights plus KV — which, in the decimal GB this table uses, is ~72 GB per GPU. (That it also equals a careless 80 × 0.9 is a coincidence of two unit errors cancelling; the derivation above is the real one.)

Derived — minimum H100 count to hold DeepSeek-V3's weights, at 67.28 GiB = ~72 decimal GB usable per GPU (79.65 GiB card, 0.92 utilisation, less 6.0 GiB overhead). Leaves nothing for KV at the minimum; the practical floor is higher. Not measured.
Weight dtypeModel bytesGPUs to hold weightsExpert bytes / rank at 16 GPUsLeft for KV at 16 GPUs
bf161,342 GB1981.7 GBdoes not fit
fp8671 GB1040.9 GB~30 GB
w4 (experts only)344 GB520.4 GB~50 GB

Quantization on a dense model buys latency in the memory-bound regime and stops mattering once you are past the ridge. On an MoE model it buys GPU count, and — per the derivation in §3 — the expert layers do not reach the ridge until batch 9,456, so the latency win never runs out either. That is why the natural pairing exists, and why both engines have quantization schemes that touch only the MoE. vLLM's moe_wna16 is one: its get_quant_method routes RoutedExperts to MoeWNA16Method while sending every LinearBase off to the underlying GPTQ or AWQ config (vllm/model_executor/layers/quantization/moe_wna16.py:L171-L197). §4.4 owns the decision procedure and the accuracy side; this chapter only supplies the reason it works.

A repository as evidence

The pinned config directory contains tuned files for certain TP-sharded shapes but not every unsharded shape. This is evidence of shipped tuning coverage only. It does not establish that nobody benchmarked or deployed an absent shape, nor that fallback tuning is impossible.

§9

Load imbalance as a kernel problem

§5.3 treats imbalance as a placement problem: which rank holds which expert, measured as balancedness, fixed by EPLB. On one device there is no placement to fix, and imbalance still costs you — through the grouped GEMM's geometry.

Two distinct mechanisms:

contiguous layout

Padding, paid per expert

Each segment rounds up to $M_{\text{blk}}$. A cold expert with 3 rows costs a full tile; a hot one with 300 costs 3 tiles at $M_{\text{blk}} = 128$. Total work is $\sum_e \lceil m_e/M_{\text{blk}}\rceil M_{\text{blk}}$, so imbalance costs at most one extra tile per expert — bounded, and dominated by the small-batch padding tax of §5.

masked layout

Capacity is not executed work

The allocated tensor has shape [E, m_max, d], so its capacity scales with E*m_max. DeepGEMM's masked interface also receives valid row counts and can skip invalid work. Do not equate capacity with executed MMA tiles or HBM bytes. Hot experts affect scheduling and buffer capacity, but actual work depends on valid counts, tile rounding and the selected kernel.

Figure 4 — expert occupancy in one grouped GEMM, and what each layout charges for it. Illustrative — 12 experts sampled from a DeepSeek-V3 layer at $B = 4096$, constructed to a mean of 128 rows and a balancedness of 0.50 to show the shape of the problem. Not measured. Solid bars are real rows; outlines are what the contiguous layout charges at $\text{block\_e} = 128$.

Rows per expert in one grouped GEMM with a straggler expert marked Twelve experts have between 32 and 256 valid rows. Contiguous layout rounds each valid count to a tile. Masked layout reserves a common row capacity, but that capacity is not a claim that all reserved rows execute matrix multiplication. 128 = mean 256 = m_max 0 token rows for this expert 32 56 80 100 110 118 126 136 150 172 200 256 straggler 12 experts of one DeepSeek-V3 layer, batch 4096 1,536 real rows; 2,176 contiguous tile rows; capped masked capacity 3,072 rows Uncapped masked capacity: 52,224 rows; allocated capacity is not executed valid-row work.

The masked column is the one to internalise: the slowest group sets the time, exactly as it does across ranks in §5.3, but here on a single device with no placement lever to pull. Your only moves are the layout choice and the batch composition. A perfectly balanced batch at $B = 4096$ pays 1.0× in the contiguous layout and 1.0× in the masked one; the batch drawn above pays 1.42× and 2.00×.

§10

Worked trace: 64 tokens through vLLM's fused MoE

DeepSeek-V3, vLLM, single GPU (imagine it fits), fp8 block-quantised weights. One decode step with 64 sequences enters one MoE layer.

  1. DeepseekV2MoE.forward reshapes to [64, 7168] and calls the experts module with router_logits=hidden_states — the gate GEMM happens inside (vllm/model_executor/models/deepseek_v2.py:L411-L421).
  2. grouped_topk runs: sigmoid on the [64, 256] logits, add e_score_correction_bias, score the 8 groups by their top-2 sum, keep the best 4, mask the rest to $-\infty$, take top-8, gather weights from the unbiased scores, renormalise, scale (vllm/model_executor/layers/fused_moe/router/grouped_topk_router.py:L112-L161). Output topk_ids [64, 8], topk_weights [64, 8].
  3. fused_experts_impl quantises activations to fp8, then calls _prepare_expert_assignment. The naive shortcut needs $64 \times 8 \times 4 \le 256$, which is false, so it falls through to moe_align_block_size (:L1557-L1584).
  4. The CUDA kernel builds the layout. With BLOCK_SIZE_M = 16, max_num_tokens_padded = 512 + 256 × 15 = 4{,}352 and expert_ids is 272 entries; the 512 assignments land on ~222 distinct experts, so num_tokens_post_padded comes out near 3,552 — 6.9 padded rows per real row (derived). Absent experts get expert_ids = -1.
  5. dispatch_fused_moe_kernel launches over $\lceil 4352/16 \rceil \times \lceil 4096/64 \rceil$ program ids. Each loads its expert id; if it is $-1$ it calls write_zeros_to_output and returns (:L423-L440). Otherwise it gathers up to 16 rows via offs_token // top_k and streams w13[e]. Across the layer, ~222 experts × 44.04 MB = 9.78 GB streamed for 64 tokens.
  6. apply_moe_activation runs SiLU-and-mul, halving 4096 to 2048; the result is re-quantised and fed to a second dispatch_fused_moe_kernel against w2 with top_k=1, so that gather is the identity, and with mul_routed_weight on, so this kernel is where topk_weights is applied (:L1815-L1852).
  7. ops.moe_sum then adds the already-scaled [64, 8, 7168] down to [64, 7168] (:L1854-L1857); the shared expert and residual are added. Net for the layer's routed experts: $k \cdot 2 \cdot 44.04\,\text{M} = 705$ MFLOP per token against 9.78 GB of weight reads for the 64-token step — an intensity of 4.6 FLOP/byte on a machine whose fp8 ridge is 591.
§11

Pitfalls and war stories

The masked-layout OOM

Real error path, real message: "Masked grouped-GEMM workspace allocation failed (num_groups=%d m=%d n=%d). If this happens under saturated dp-attention prefill, try SGLANG_OPT_DG_MASKED_M_CAP=1." (python/sglang/srt/layers/moe/moe_runner/deep_gemm.py:L608-L617). It fires when a large prefill chunk makes $[E_{\text{local}},\, m_{\max},\, d]$ exceed free memory. The comment above the cap quantifies it: 7+ GiB per 32k-token chunk. Note it says prefill — the cap is disabled during CUDA-graph capture, so decode never benefits from it.

Your MoE tile is not your dense tile

If you autotune a MoE kernel with a dense-GEMM mental model you will pick $M$ tiles that are 8× too large and wonder why throughput is flat. The tile must track $M_e = Bk/E$, not $B$. vLLM's tuned configs land on $2 M_e$ (see §5); if you are writing configs by hand, start there. A corollary: a config tuned for $E = 128$ is wrong for $E = 256$ at the same batch, which is why the config filenames key on $E$ and $N$.

EPLB needs even division

"EPLB currently only supports even distribution of experts across ranks. Got {global_num_experts} experts and {ep_size} EP ranks." (vllm/model_executor/layers/fused_moe/layer.py:L247-L252). The count it checks is global_num_experts = num_experts + num_redundant_experts (:L78) — note that shared-expert fusion does not enter it, since determine_expert_counts returns num_fused_shared_experts separately (:L72-L85). So the two ways to hit this are an EP size that does not divide the routed expert count (256 divides only by powers of two, so --data-parallel-size 6 with EP on is enough), and redundant experts that push 256 to a number that no longer divides. vLLM also asserts "Redundant experts are only supported with EPLB." (:L255-L257) — eplb_config.num_redundant_experts defaults to 0 and enable_eplb to False (vllm/config/parallel.py:L72, L174), so you see this one only by setting the first without the second.

Sorted top-k is not free, and not default

Both engines pass sorted=False to torch.topk unless something needs order — vLLM gates it on VLLM_BATCH_INVARIANT (grouped_topk_router.py:L133-L134), SGLang on num_fused_shared_experts > 0 (topk.py:L1331-L1336). With sorted=False leaves output order unspecified. At equal scores, even selected IDs need not be stable; sorted=True orders returned values but does not supply a general stable tie-breaking contract. Different selected experts or reduction order can change logits. The engine's comment motivates its batch-invariance path, but verify tied-score behavior in the actual backend rather than deriving deterministic selection from this flag alone False (vllm/envs.py:L91). If you are chasing a batch-size-dependent reproducibility bug in an MoE model, this is the first place to look.

Weighted dispatch and bias-dependent output

Selection bias may leave the raw selected scores unchanged while changing the selected expert and therefore the output. For top-1 normalized routing the selected weight becomes one. The example also groups assignments by expert, scatters their weighted outputs back, and compares with a direct per-token reference. Empty experts and allocation capacity do not contribute outputs.

Independent CPU reference; not an engine or GPU benchmark
import numpy as np

x = np.array([[1., 2.], [3., 4.], [-1., 1.]])
weights = np.array([np.eye(2), 2*np.eye(2), -np.eye(2)])
ids = np.array([[0, 1], [1, 2], [0, 2]])
routing = np.array([[0.7, 0.3], [0.4, 0.6], [0.5, 0.5]])
direct = sum(routing[:, j, None] *
    np.stack([x[t] @ weights[ids[t, j]].T for t in range(len(x))])
    for j in range(2))
grouped = np.zeros_like(x)
for expert in range(len(weights)):
    tokens, slots = np.where(ids == expert)
    np.add.at(grouped, tokens,
              routing[tokens, slots, None] * (x[tokens] @ weights[expert].T))
np.testing.assert_allclose(grouped, direct)
scores = np.array([0.6, 0.4])
before = np.argmax(scores)
after = np.argmax(scores + np.array([0., 0.3]))
assert before != after
assert not np.allclose(x[0] @ weights[before].T, x[0] @ weights[after].T)
print("Grouped scatter matches direct outputs; selection bias changes the function.")
§12

Hands-on

Everything below runs without a GPU except the last item.

reproduce this chapter's numbers shell
V=~/Documents/other_git_repos/vllm

# 1. The expert-touch curve and the intensity curve, for any (E, k).
python3 - <<'PY'
E, k, bw, ridge = 256, 8, 1, 591          # DeepSeek-V3, fp8, H100
for B in (1, 8, 32, 64, 128, 256, 512, 4096):
    touched = E * (1 - (1 - k / E) ** B)
    print(f"B={B:5d}  touched={touched:7.1f}  I={2*B*k/(bw*touched):8.2f}")
print("ridge at B =", bw * ridge * E / (2 * k))
PY

# 2. Read the autotuner's answer for the tile height.
python3 -c "
import json, pathlib
p = pathlib.Path('$V/vllm/model_executor/layers/fused_moe/configs')
d = json.load(open(p / 'E=256,N=512,device_name=NVIDIA_H100_80GB_HBM3.json'))
for b, c in d.items():
    print(f\"B={b:>5}  BLOCK_SIZE_M={c['BLOCK_SIZE_M']:>4}  M_e={int(b)*8/256:7.1f}\")
"

# 3. Confirm the no-token-dropping claim yourself.
grep -rn "capacity_factor\|drop_tokens" $V/vllm/ | grep -v vision | grep -v arctic

On a GPU, the flag to flip is the MoE backend. For block-fp8 weights on a single-node Hopper box both engines default to the index-gather Triton path, so you have to ask for the other one. On vLLM, --moe-backend deep_gemm selects it directly, while --enable-expert-parallel (default False, vllm/config/parallel.py:L165, and it needs more than one GPU) instead promotes FlashInfer CUTLASS on Hopper, which needs flashinfer installed or it falls through. On SGLang, --moe-runner-backend deep_gemm is the direct route and --moe-a2a-backend deepep (default none) is the indirect one — the latter additionally needs the external deep_ep package and multiple ranks, and both need deep_gemm importable. Measure with the standard benchmark harness at fixed input and output lengths, sweeping --max-num-seqs and measure actual resident batch sizes. The reference byte model predicts amortized cost falling as occupancy grows. Once distinct weight bytes saturate, constant step time would mean per-token cost continues falling as 1/B, until compute or other work grows. A plateau in per-token cost is not implied by saturated weight traffic. §10.1 owns methodology.

§13

Exercises

  1. Read and answer. Open vllm/model_executor/layers/fused_moe/moe_align_block_size.py and find where max_num_tokens_padded is computed. For Qwen3-30B-A3B ($E = 128$, $k = 8$) at batch 4 with block_size = 16, what buffer length does it allocate, and which of the two branches at lines 77–80 applies?
  2. Derive. A hypothetical model has $E = 64$, $k = 2$ — Mixtral's top-2 routing, but with 64 experts rather than Mixtral-8x7B's 8. At what batch is half the layer streamed? At what batch does the expert GEMM reach the H100's bf16 ridge of 295? Compare both to DeepSeek-V3 and say in one sentence what $E/k$ controls.
  3. Predict, then verify. vLLM's tuned config for E=256,N=512 on H100 pins BLOCK_SIZE_M = 16 from batch 1 all the way to batch 256. Predict what BLOCK_SIZE_M the E=128,N=768 H200 config (Qwen3-30B-A3B) uses at batch 256, then open the file and check. Explain any discrepancy using $M_e = Bk/E$.
  4. Trace. Follow src2dst through SGLang's DeepGEMM path: which function writes it, which reads it, and what would break if the grouped GEMM reordered rows within an expert's segment?
  5. Design. You must serve DeepSeek-V3 with a strict 20 ms TPOT SLO. Using §3's numbers, argue for a batch size, then say what §5.3 must do to make that batch reachable across ranks. Where does your argument depend on the uniform-routing assumption?
Answers

1. topk_ids.numel() = 4 × 8 = 32, which is less than num_experts = 128, so the second branch applies: max_num_tokens_padded = min(32 × 16, 32 + 128 × 15) = min(512, 1952) = 512. The guard exists precisely because the general formula $Bk + E(M_{\text{blk}}-1)$ is dominated by the $E$ term at small batch and would over-allocate by 4×.

2. $B_{1/2} = \ln(1/2)/\ln(1 - 2/64) = 21.8$ tokens — the same as DeepSeek-V3, because $k/E$ is identical ($2/64 = 8/256$). The ridge batch is $B^{*} = b_w I^{*} E/(2k) = 2 \times 295 \times 64/4 = 9{,}440$. $E/k$ controls both: it is how much flatter MoE's intensity curve is than dense, and it is the multiplier on §4.4's dense crossover $M^{*}$.

3. $M_e = 256 \times 8/128 = 16$, so the $2M_e$ rule predicts BLOCK_SIZE_M = 32 — one step larger than DeepSeek-V3 at the same batch, because halving $E$ doubles $M_e$. Check E=128,N=768,device_name=NVIDIA_H200.json. If it reads 16, the likely reason is that $N = 768$ is narrow enough that the tuner preferred more thread blocks over larger tiles — $M_e$ constrains only one axis of a two-dimensional tile.

4. ep_scatter writes it (python/sglang/srt/layers/moe/moe_runner/deep_gemm.py:L955-L966); post_reorder_deepgemm reads it (:L1020-L1034). It maps each of the $Bk$ assignments to its row in the packed buffer. If the GEMM reordered rows within a segment the map would be stale and every token would receive another token's expert output — silently, with no shape error. That is why padding rows carry m_indices = -1 rather than being compacted away.

5. Expert-weight streaming is the floor. DeepSeek-V3 at EP=16 streams 705 MB per layer per rank (§5.3's table) = 12.2 ms per step for experts alone, so a 20 ms TPOT needs EP $\ge$ 16 and leaves ~8 ms for attention, routing, the all-to-all and the padding tax. Batch should be as large as the SLO allows, because past $B \approx 94$ the bytes stop growing — so target the largest batch whose compute fits in 8 ms. The argument assumes uniform routing; at balancedness 0.55 the slowest rank does ~1.8× the mean work, which is what EPLB exists to recover.

§14

Key takeaways

  • Total parameters set capability; active parameters set FLOPs; neither sets bytes. Bytes follow $E_{\text{touched}}(B) = E(1 - (1-k/E)^B)$, which passes half of $E$ by batch 22 for DeepSeek-V3 and 95% by batch 94 under uniform independent routing, not every production workload.
  • $I_{\text{MoE}}(B) = 2Bk / (b_w E_{\text{touched}}(B))$ starts exactly where a dense layer starts and ends up $E/k$ times flatter. DeepSeek-V3's expert GEMM needs batch 9,456 to reach an H100's fp8 ridge, against 296 for a dense layer — so MoE serving needs both a large batch and a lot of HBM, and the second requirement is why expert parallelism exists.
  • The ragged expert GEMM costs tensor-core work. Both engines round each expert's row count up to a tile boundary; at decode-like batches that padding is 2–16× the real rows. vLLM's autotuned configs independently rediscover $M_e = Bk/E$, pinning BLOCK_SIZE_M at 16 through batch 256.
  • The cited paths preserve routed tokens. Logical row counts may be dynamic while captured buffers remain statically reserved. Capacity overflow must be handled explicitly; buffer shape is not evidence of either token dropping or full padded compute.
  • Shared-expert fractions need a denominator. One expert adds 12.5% to eight routed experts, or is 11.1% of the combined bytes. At full 256-expert occupancy those become 1/256 added and 1/257 of total, without guaranteeing negligible latency.
  • Separate allocated capacity from executed work. E*m_max describes masked-buffer rows, not necessarily MMA or HBM work. Inspect valid-row masks, tiling and profiler counters before attributing a slowdown to padding rather than occupancy, imbalance or communication.
§15

Further reading

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px