Mixture of Experts
vllm/model_executor/layers/fused_moe/csrc/libtorch_stable/moe/moe_align_sum_kernels.cupython/sglang/srt/layers/moe/python/sglang/srt/models/deepseek_v2.py
a556f3f · sglang 7d89325DeepSeek-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.
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.
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.
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
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.
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:
# 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}]$:
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
| Quantity | DeepSeek-V3 | Qwen3-30B-A3B |
|---|---|---|
| per-expert params $3 d\, d_{ff}$ | 44.04 M | 4.72 M |
| routed experts, per layer | 11.27 B | 604.0 M |
| routed experts, whole model | 653.9 B | 28.99 B |
| share of total parameters | 97.5% | 95.0% |
| routed experts activated per token | 20.43 B | 1.81 B |
| shared expert, activated | 2.55 B | none |
| attention, activated | 11.41 B | — |
| total / active, published | 671 B / 37 B | 30.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:
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.
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:
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
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:
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$.
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:
| Batch $B$ | experts touched / 128 | expert bytes / step | stream time / step | per token |
|---|---|---|---|---|
| 1 | 8.0 | 1.81 GB | 541 µs | 541 µs |
| 8 | 51.6 | 11.69 GB | 3.49 ms | 436 µs |
| 16 | 82.4 | 18.67 GB | 5.57 ms | 348 µs |
| 32 | 111.8 | 25.31 GB | 7.56 ms | 236 µs |
| 64 | 125.9 | 28.52 GB | 8.51 ms | 133 µs |
| 128 | 128.0 | 28.98 GB | 8.65 ms | 67.6 µs |
| 512 | 128.0 | 28.99 GB | 8.65 ms | 16.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.
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:
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:
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:
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.
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.
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:
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.
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.
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.
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
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:
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:
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:
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:
"""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:
# 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:
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:
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:
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$ | ratio | padding factor $P(B)$ |
|---|---|---|---|---|
| 1 | 16 | 0.03 | — | 16.0× |
| 64 | 16 | 2.0 | 8× | 6.9× |
| 256 | 16 | 8 | 2× | 2.0× |
| 512 | 32 | 16 | 2× | 2.0× |
| 1024 | 64 | 32 | 2× | 2.0× |
| 2048 | 128 | 64 | 2× | 2.0× |
| 4096 | 128 | 128 | 1× | 1.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).
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.
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:
# 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.
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.)
| Weight dtype | Model bytes | GPUs to hold weights | Expert bytes / rank at 16 GPUs | Left for KV at 16 GPUs |
|---|---|---|---|---|
| bf16 | 1,342 GB | 19 | 81.7 GB | does not fit |
| fp8 | 671 GB | 10 | 40.9 GB | ~30 GB |
| w4 (experts only) | 344 GB | 5 | 20.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.
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.
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:
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.
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$.
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×.
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.
DeepseekV2MoE.forwardreshapes to[64, 7168]and calls the experts module withrouter_logits=hidden_states— the gate GEMM happens inside (vllm/model_executor/models/deepseek_v2.py:L411-L421).grouped_topkruns: sigmoid on the[64, 256]logits, adde_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). Outputtopk_ids [64, 8],topk_weights [64, 8].fused_experts_implquantises 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 tomoe_align_block_size(:L1557-L1584).- The CUDA kernel builds the layout. With
BLOCK_SIZE_M = 16,max_num_tokens_padded = 512 + 256 × 15 = 4{,}352andexpert_idsis 272 entries; the 512 assignments land on ~222 distinct experts, sonum_tokens_post_paddedcomes out near 3,552 — 6.9 padded rows per real row (derived). Absent experts getexpert_ids = -1. dispatch_fused_moe_kernellaunches over $\lceil 4352/16 \rceil \times \lceil 4096/64 \rceil$ program ids. Each loads its expert id; if it is $-1$ it callswrite_zeros_to_outputand returns (:L423-L440). Otherwise it gathers up to 16 rows viaoffs_token // top_kand streamsw13[e]. Across the layer, ~222 experts × 44.04 MB = 9.78 GB streamed for 64 tokens.apply_moe_activationruns SiLU-and-mul, halving 4096 to 2048; the result is re-quantised and fed to a seconddispatch_fused_moe_kernelagainstw2withtop_k=1, so that gather is the identity, and withmul_routed_weighton, so this kernel is wheretopk_weightsis applied (:L1815-L1852).ops.moe_sumthen 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.
Pitfalls and war stories
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.
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 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.
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.
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.")
Hands-on
Everything below runs without a GPU except the last item.
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.
Exercises
- Read and answer. Open
vllm/model_executor/layers/fused_moe/moe_align_block_size.pyand find wheremax_num_tokens_paddedis computed. For Qwen3-30B-A3B ($E = 128$, $k = 8$) at batch 4 withblock_size = 16, what buffer length does it allocate, and which of the two branches at lines 77–80 applies? - 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.
- Predict, then verify. vLLM's tuned config for
E=256,N=512on H100 pinsBLOCK_SIZE_M = 16from batch 1 all the way to batch 256. Predict whatBLOCK_SIZE_MtheE=128,N=768H200 config (Qwen3-30B-A3B) uses at batch 256, then open the file and check. Explain any discrepancy using $M_e = Bk/E$. - Trace. Follow
src2dstthrough 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? - 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.
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_Mat 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.
Further reading
- Shazeer et al., Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer (2017) — the top-k gate and the load-balancing loss, in their original form.
- GShard and Switch Transformer — where the capacity factor and token dropping come from. Read them knowing that §6 above shows inference engines discarded both.
- MegaBlocks: Efficient Sparse Training with Mixture-of-Experts — the block-sparse reformulation that made dropless MoE practical, and the direct ancestor of the alignment-plus-grouped-GEMM structure both engines use.
- Wang et al., Auxiliary-Loss-Free Load Balancing
Strategy for Mixture-of-Experts (2024) — the
e_score_correction_biasmechanism, and why separating selection scores from routing weights matters. - DeepSeek-V3 Technical Report — 671 B total, 37 B activated, 256 routed experts plus 1 shared, group-limited routing. The model card carries the config this chapter's shapes come from; vLLM's benchmark table is the in-tree copy.
- Qwen3-30B-A3B model card — 128 experts, top-8, no shared expert, every layer sparse.
- DeepGEMM — the contiguous and masked
grouped-GEMM layouts SGLang targets. SGLang's own source links
deep_gemm/jit_kernels/m_grouped_gemm.py#L165for the block-M alignment rule. - PRs worth reading: vLLM
#47785 (topk_ids padding in the
align-sum kernel) and #46643
(vectorised fp32
moe_sum, arbitrary top-k); SGLang #33128 (DeepGEMM for standard MoE dispatch) and #33474 (choosing the layout by memory budget). - In this book: §5.3 for expert parallelism, the all-to-all and EPLB; §4.4 for the quantization decision on expert weights; §7.2 for MLA, the other half of what makes DeepSeek-V3 servable.