本文是《Transformer 与 LLM:结构、算量与数值》系列的第 5 篇(共九篇)。上一篇:位置编码与长上下文;下一篇:浮点格式、数值稳定性与混合精度。
前四篇讨论的都是 dense 模型:每个 token 经过每一层时,会用到这一层的全部权重。参数量、每 token 算量、每步 decode 的权重读取量,三者之间只差一个常数——参数量 \(N\) 对应每 token \(2N\) FLOPs,对应每步读 \(N \times \text{bytes/elem}\) 字节。
混合专家(Mixture of Experts,MoE)把这三个数拆开了。DeepSeek-V3 的技术报告里写着”总参数 671B,每 token 激活 37B”,从算量看它比 Llama-3-70B 便宜一半,但实际部署时它需要几十张 GPU 组成的专家并行集群,而 70B 一台 8 卡机器就能跑得很好。本篇要回答的核心问题是:
DeepSeek-V3 每 token 只算 37B 参数,为什么部署它比部署一个 dense 70B 难得多?1 把”参数量”、”激活参数量”、”每步实际读取的参数量”三个数分开算。
一、总览:把三个”参数量”分开算
1. 思路与基线模型
要回答这个问题,需要把 MoE 层的结构、参数量的推导、decode 时的访存形态、专家并行引入的 all-to-all 通信、grouped GEMM 的形状与负载均衡逐个算一遍。全篇沿用系列的三个基线模型:Llama-3-70B 作为 dense 对照,Mixtral 8x7B 与 DeepSeek-V3 作为两种粒度的 MoE。所有数字都是从超参数推出来的理论值,不是实测。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 从 dense FFN 到 MoE 层 | dense FFN 的参数与算量、MoE 层的结构、一段参考实现、Mixtral 与 DeepSeek-V3 两种粒度 |
| 三 | 参数量与激活参数量 | Mixtral 8x7B、DeepSeek-V3 的总参数与激活参数;算量按激活参数算、显存按总参数算 |
| 四 | decode 的访存形态 | 期望激活专家数的推导、每步实际读取的参数量、三个数分开算、大规模 EP 的动机 |
| 五 | 专家并行与 all-to-all | dispatch 与 combine、每 token 每专家的字节数、节点受限路由、EP 与 TP 的对比 |
| 六 | grouped GEMM 的形态 | 每专家平均行数、为什么 MoE 的 GEMM 天然低效 |
| 七 | 负载均衡 | 辅助损失、容量因子与 token drop、aux-loss-free、负载不均对 EP 意味着什么 |
| 八 | 共享专家与 MTP | 共享专家其实是 dense FFN;MTP 是内置的投机草稿 |
| 九 | 实践 | llm_cost.py 的 MoE 支持 |
| 十 | 本文小结 | |
| 十一 | 自测 | 5 道题 |
二、从 dense FFN 到 MoE 层
1. dense FFN 的参数与算量
现代 LLM 的 FFN 几乎都是 SwiGLU 形式(Shazeer 2020):
\[\text{FFN}(x) = W_{down}\,\big(\text{SiLU}(W_{gate}\,x) \odot W_{up}\,x\big)\]其中 \(W_{gate}, W_{up} \in \mathbb{R}^{d_{ff} \times d}\),\(W_{down} \in \mathbb{R}^{d \times d_{ff}}\),三个矩阵合计:
\[N_{FFN} = 3 \cdot d \cdot d_{ff}\]每个参数在前向中参与一次乘加,即每 token 每参数 2 FLOPs,所以一层 FFN 对一个 token 的算量是 \(2 \cdot 3 d d_{ff} = 6 d d_{ff}\)。代入三个 dense 基线:
d d_ff 3·d·d_ff 每 token FLOPs
Llama-3-8B 4096 14336 176.16M 352.3 MFLOPs
Llama-3-70B 8192 28672 704.6M 1.409 GFLOPs
DeepSeek-V3 dense 层 7168 18432 396.4M 792.7 MFLOPs
Llama-3-70B 每层 attention 151.0M、FFN 704.6M,FFN 占每层参数的 82%;80 层合计 68.45B,加 embedding 与 lm_head 2.10B,总计 70.55B。dense 模型中 FFN 是参数的大头,也是 MoE 要改造的部分。
2. MoE 层的结构
MoE 把一层里的一个 FFN 换成 \(E\) 个结构相同、参数独立的 FFN(称为专家),再加一个 router 决定每个 token 用哪几个。对输入 \(x \in \mathbb{R}^d\):
第一步,router 是一个 \(d \times E\) 的线性层,输出每个专家的亲和度分数:
\[s = \text{softmax}(W_r\, x) \quad \text{或} \quad s = \sigma(W_r\, x), \qquad W_r \in \mathbb{R}^{E \times d}\]Mixtral 用 softmax,DeepSeek-V3 用 sigmoid。router 的参数量 \(d \cdot E\) 相对专家可以忽略——DeepSeek-V3 每层 \(7168 \times 256 = 1.8\)M,58 层 106M,在 671B 里占 0.016%。
第二步,取分数最高的 \(k\) 个专家(top-\(k\)),把它们的分数归一化为门控权重 \(g_i\),其余专家的权重为 0:
\[g_i = \frac{s_i}{\sum_{j \in \text{TopK}(s)} s_j} \ \text{(} i \in \text{TopK}\text{)}, \qquad g_i = 0 \ \text{(其余)}\]第三步,每个被选中的专家是一个独立的 SwiGLU FFN,输出按门控权重加权求和:
\[y = \sum_{i \in \text{TopK}(s)} g_i \cdot \text{FFN}_i(x) \ \big(+ \sum_{j=1}^{n_{shared}} \text{FFN}^{shared}_j(x)\big)\]括号里的共享专家是 DeepSeek 系列的做法:有 \(n_{shared}\) 个专家不经过路由、对所有 token 都激活,第八章再讨论它的意义。
把三步放在一起,一个 token 经过 MoE 层的数据流如下(以 \(k = 2\) 为例,灰色是未被选中、这一步不参与计算的专家;共享专家绕过 router 直接接收输入):
%% 图:MoE 层的结构:router 打分、top-k 选择,只有选中的专家参与计算,共享专家绕过 router
flowchart TB
TOK["输入 token x(d 维)"]
RT["router:W_r x,W_r 是 E × d<br/>softmax 或 sigmoid 得到 E 个分数 s_i"]
TK["top-k 选择(k = 2)<br/>g_i = s_i / Σ s_j(i ∈ TopK),其余 g_i = 0"]
TOK --> RT --> TK
subgraph routed["E 个路由专家:结构相同、参数独立的 SwiGLU FFN,各 3·d·d_ff 参数"]
E1["FFN_1<br/>选中,g_1 > 0"]
E2["FFN_2<br/>未选中,不计算"]
EE["… FFN_E<br/>选中,g_E > 0"]
end
subgraph shared["共享专家(DeepSeek 系列)"]
SH["FFN_shared<br/>不经 router,所有 token 都过"]
end
TK -->|"x,权重 g_1"| E1
TK -.->|"g_2 = 0"| E2
TK -->|"x,权重 g_E"| EE
TOK --> SH
SUM["y = g_1·FFN_1(x) + g_E·FFN_E(x) + FFN_shared(x)<br/>这一层只算了 k + n_shared 个 FFN,却持有 E + n_shared 个"]
E1 --> SUM
EE --> SUM
SH --> SUM
classDef active fill:#dff0d8,stroke:#3c763d;
classDef idle fill:#f5f5f5,stroke:#aaaaaa,color:#888888;
classDef sh fill:#fcf8e3,stroke:#8a6d3b;
class E1,EE active;
class E2 idle;
class SH sh;
对一个 token 而言,这一层只执行了 \(k\)(加共享)个 FFN 的计算,算量是 \(k \cdot 6 d d_{ff}\),与 \(E\) 无关。但这一层持有 \(E \cdot 3 d d_{ff}\) 个参数,全部要放在显存里。参数量与算量的解耦就发生在这里。
这个想法本身不新:Shazeer 等 2017 在 LSTM 上做过稀疏门控的 MoE 层,GShard(Lepikhin 等 2020)与 Switch Transformer(Fedus 等 2021)把它搬到 Transformer 并解决了大规模训练的均衡与并行问题。Mixtral(Jiang 等 2024)与 DeepSeek 系列让它成为开源模型的主流结构。结构层面几年没有大变,变的是粒度与规模,而正是粒度与规模决定了系统成本。
还要注意 MoE 只替换 FFN,attention 部分与 dense 模型完全相同。因此一个 MoE 模型里 attention 的参数、KV cache、对上下文长度的二次方算量,都和同结构的 dense 模型一样——第三、四篇的所有结论直接适用。MoE 改变的只是 FFN 那一块的参数、算量与访存之间的比例关系。
3. 一段参考实现
下面是 MoE 层的循环版参考实现,只为说明数据流;生产实现(vLLM 的 fused MoE kernel、Megatron 的 grouped GEMM)会把所有专家的计算合并成一个 grouped GEMM,第六章解释为什么:
import torch
import torch.nn as nn
import torch.nn.functional as F
class SwiGLUExpert(nn.Module):
def __init__(self, d, d_ff):
super().__init__()
self.w_gate = nn.Linear(d, d_ff, bias=False)
self.w_up = nn.Linear(d, d_ff, bias=False)
self.w_down = nn.Linear(d_ff, d, bias=False)
def forward(self, x):
return self.w_down(F.silu(self.w_gate(x)) * self.w_up(x))
class MoELayer(nn.Module):
def __init__(self, d, d_ff, n_experts, top_k, n_shared=0):
super().__init__()
self.router = nn.Linear(d, n_experts, bias=False) # W_r: d x E
self.experts = nn.ModuleList(
[SwiGLUExpert(d, d_ff) for _ in range(n_experts)])
self.shared = nn.ModuleList(
[SwiGLUExpert(d, d_ff) for _ in range(n_shared)])
self.top_k = top_k
def forward(self, x): # x: [T, d]
scores = F.softmax(self.router(x), dim=-1) # [T, E]
w, idx = scores.topk(self.top_k, dim=-1) # [T, k]
w = w / w.sum(dim=-1, keepdim=True) # 选中的 k 个重新归一化
out = torch.zeros_like(x)
for e, expert in enumerate(self.experts): # 循环版:逐专家
tok, slot = (idx == e).nonzero(as_tuple=True) # 选了专家 e 的 token
if tok.numel() == 0:
continue
out.index_add_(0, tok, w[tok, slot, None] * expert(x[tok]))
for expert in self.shared: # 共享专家:所有 token
out = out + expert(x)
return out
(idx == e).nonzero() 这一步就是”把 token 按专家分组”。在单卡上它是一次 gather;在专家并行下,它变成跨 GPU 的 all-to-all——第五章的主题。
4. 两种粒度:Mixtral 与 DeepSeek-V3
两个基线模型代表了 MoE 设计的两种粒度:
E(路由专家) d_ff(专家) k 共享专家 每专家参数 每 token 激活的 FFN 参数
Mixtral 8x7B 8 14336 2 0 176.16M 352.3M
DeepSeek-V3 256 2048 8 1 44.04M 9 × 44.04M = 396.4M
两者每 token 激活的 FFN 参数量相近(352M 与 396M),但 DeepSeek-V3 把它切成了 9 份而不是 2 份。DeepSeek-V2 的论文(DeepSeek-AI 2024)称之为 fine-grained expert segmentation,理由是组合数:
\[\binom{8}{2} = 28, \qquad \binom{256}{8} \approx 4.1 \times 10^{14}\]在同样的激活参数预算下,Mixtral 每个 token 只有 28 种”专家组合”可选,DeepSeek-V3 有 \(4 \times 10^{14}\) 种。每个专家更小、更专门化,组合的表达能力更强。这是建模上的收益。
系统上的代价从同一张表就能看出来:DeepSeek-V3 一层有 257 个矩阵组要管理,每个专家的 GEMM 只有 \(7168 \times 2048\),比 Mixtral 的 \(4096 \times 14336\) 窄得多;每个 token 要和 8 个专家通信而不是 2 个。后面几章的所有麻烦——访存、all-to-all、GEMM 效率、负载均衡——都在细粒度设计下被放大。
三、参数量与激活参数量
1. Mixtral 8x7B
Mixtral 的 attention 与 Llama-3-8B 同构(\(d = 4096\),32 头,8 个 KV 头,\(d_{head} = 128\)):
\[N_{attn} = 2 \cdot d \cdot n_h d_{head} + 2 \cdot d \cdot n_{kv} d_{head} = 2 \times 4096 \times 4096 + 2 \times 4096 \times 1024 = 41.94\text{M}\]每层 8 个专家:
\[8 \times 3 \times 4096 \times 14336 = 8 \times 176.16\text{M} = 1.409\text{B}\]每层合计 1.451B,32 层 46.4B;embedding 与 lm_head 各 \(32000 \times 4096 = 131\)M,合计 0.26B;总参数 46.7B。名字里的”8x7B”并不是 \(8 \times 7\text{B} = 56\text{B}\),因为 attention 与 embedding 在专家之间是共享的。
每 token 激活:每层 attention 41.94M + 2 个专家 352.3M = 394.3M,32 层 12.62B,加 embedding/lm_head 0.26B,约 12.9B。激活占总参数的 27.6%。
2. DeepSeek-V3 的总参数
DeepSeek-V3 有 61 层,前 3 层是 dense FFN,后 58 层是 MoE。逐项算:
路由专家。 每个专家 \(3 \times 7168 \times 2048 = 44.04\text{M}\);每层 256 个路由专家 + 1 个共享专家 = 257 个:
\[257 \times 44.04\text{M} = 11.32\text{B}\]58 个 MoE 层:
\[58 \times 11.32\text{B} = 656.5\text{B}\]其中路由专家 \(58 \times 256 \times 44.04\text{M} = 653.9\text{B}\),共享专家 \(58 \times 44.04\text{M} = 2.55\text{B}\)。
dense 层 FFN。 3 层,每层 \(3 \times 7168 \times 18432 = 396.4\text{M}\),合计 1.19B。
MLA attention。 DeepSeek-V3 用 MLA(第三篇有完整推导),每层的权重是六个矩阵:
W_DQ 7168 × 1536 11.01M q 的下投影
W_UQ 1536 × 24576 37.75M q 的上投影,24576 = 128 头 × 192
W_DKV 7168 × 576 4.13M kv 的联合下投影,576 = d_c 512 + d_h^R 64
W_UK 512 × 16384 8.39M k 的上投影,16384 = 128 头 × 128
W_UV 512 × 16384 8.39M v 的上投影
W_O 16384 × 7168 117.44M 输出投影
合计 187.1M
61 层 \(\times\) 187.1M = 11.41B。
embedding 与 lm_head。 \(129280 \times 7168 = 926.7\text{M}\),两个不共享,合计 1.85B。
加总:
\[656.5 + 1.19 + 11.41 + 1.85 \approx 671\text{B}\]再加 router(0.11B)与 RMSNorm,与技术报告的 671B 一致。结构非常倾斜:97.4% 的参数是路由专家,attention 只占 1.7%。
3. DeepSeek-V3 的激活参数
每个 token 经过一个 MoE 层时用到 8 个路由专家 + 1 个共享专家 = 9 个:
\[58 \times 9 \times 44.04\text{M} = 22.99\text{B}\]加上永远激活的部分:attention 11.41B、dense FFN 1.19B、embedding/lm_head 1.85B,合计:
\[22.99 + 11.41 + 1.19 + 1.85 \approx 37.4\text{B}\]技术报告取整写作 37B。注意这个 37B 里有 14.4B(attention + dense + embedding)是”dense 部分”,和 MoE 无关;路由专家只贡献 20.4B。
4. 算量按激活参数算,显存按总参数算
每 token 的前向 FLOPs 只和真正参与运算的参数有关:
\[\text{FLOPs/token} \approx 2 \times 37\text{B} = 74\text{ GFLOPs}\]对照 Llama-3-70B 的 \(2 \times 70.55\text{B} \approx 141\) GFLOPs,DeepSeek-V3 每 token 的算量只有它的一半。这是 MoE 的全部承诺:用 671B 的参数容量,付 37B 的算量。
但显存按总参数算。FP8 权重(DeepSeek-V3 原生以 FP8 训练与发布):
\[671\text{B} \times 1\text{ byte} = 671\text{ GB}\]一台 8 卡 H100 的 HBM 总量是 \(8 \times 80 = 640\) GB,放不下权重本身,更不用说 KV cache 与激活。至少要两台(1280 GB),而且两台也只是”放得下”。如果用 BF16,1342 GB,至少三台。DeepSeek 报告里的实际部署规模远大于此:prefill 用 4 节点 32 卡(EP32),decode 用 40 节点 320 卡(EP320)。为什么要用这么大的 EP,是第四章要算的东西。
值得一提的是,DeepSeek-V3 的显存压力几乎全部来自权重而不是 KV cache。MLA 让它的 KV cache 每 token 每层只有 \((512 + 64) \times 2 = 1152\) 字节,61 层 68.6 KiB,128K 上下文只要 8.6 GiB(第三篇的推导)。Llama-3-70B 的 GQA 每 token 320 KiB,128K 上下文 40 GiB。也就是说,两个模型的显存构成刚好相反:70B 是权重 141 GB、KV 随并发膨胀;V3 是权重 671 GB、KV 很小。这也是 V3 选择 MLA 的原因之一——权重已经占掉这么多,KV cache 必须压到极致,才能在 EP 集群的每张卡上留出足够的并发空间。
Mixtral 也有同样的问题,只是数量级小:BF16 权重 93.4 GB,单张 H100 放不下,至少两卡;算量却只有 \(2 \times 12.9 \approx 26\) GFLOPs/token,比 Llama-3-8B 的 15 GFLOPs 多不到一倍。它的 KV cache 与 Llama-3-8B 完全相同(每 token 128 KiB),两卡 TP 部署后剩余的显存足够放几十万 token 的 KV。从”算量像 13B、显存像 47B”这一点看,Mixtral 是理解 MoE 部署形态最温和的入门例子;DeepSeek-V3 则把同样的矛盾放大了一个数量级。
到这里,核心问题的前两个数已经有了:参数量 671B,激活参数量 37B。第三个数——每步实际读取的参数量——需要看 decode 时 batch 里的 token 是怎样分布在专家上的。
四、decode 的访存形态:稀疏在访存上不成立
1. 期望激活专家数的推导
decode 阶段每步为 batch 里的 \(B\) 个序列各生成一个 token,这 \(B\) 个 token 各自独立路由到 \(k\) 个专家。问题是:一层的 \(E\) 个专家中,有多少个至少被一个 token 选中?被选中的专家权重必须从 HBM 读出来,没被选中的不必。
做两个简化假设:路由均匀(每个专家被某个 token 选中的概率相同),且 token 之间独立。在均匀假设下,一个 token 选 \(k\) 个专家,某个特定专家在其中的概率是 \(k/E\),不在其中的概率是 \(1 - k/E\)。\(B\) 个 token 都不选它的概率是 \((1 - k/E)^B\),于是它被至少一个 token 选中的概率是 \(1 - (1 - k/E)^B\)。对 \(E\) 个专家求期望:
\[\mathbb{E}[\text{激活专家数}] = E \cdot \left[1 - \left(1 - \frac{k}{E}\right)^B\right]\]\(B = 1\) 时它等于 \(k\)(一个 token 恰好激活 \(k\) 个);\(B \to \infty\) 时趋于 \(E\)。代入 DeepSeek-V3(\(E = 256\),\(k = 8\),\(1 - k/E = 0.96875\))和 Mixtral(\(E = 8\),\(k = 2\),\(1 - k/E = 0.75\)):
B DeepSeek-V3 (E=256, k=8) Mixtral (E=8, k=2)
期望激活专家 占 E 比例 期望激活专家 占 E 比例
1 8.0 3.1% 2.0 25%
8 57.4 22.4% 7.2 90%
32 163.3 63.8% 8.0 100%
64 222.4 86.9% 8.0 100%
128 251.6 98.3% 8.0 100%
512 256.0 100% 8.0 100%
Mixtral 在 \(B = 8\) 时就几乎读全部专家;DeepSeek-V3 在 \(B = 32\) 时读 64%,\(B = 128\) 时读 98%。真实路由并不均匀(热门专家被选中的概率更高),这会让期望激活数比均匀假设略低,但结论不变:中等 batch 下,几乎所有专家的权重每一步都要读一遍。
2. 每步实际读取的参数量
把每步读取量拆成两部分:一部分是无论 batch 多大都要读的(attention、dense FFN、共享专家、lm_head),另一部分是被激活的路由专家。对 DeepSeek-V3:
\[N_{read}(B) = N_{always} + 58 \times \mathbb{E}[\text{激活专家数}](B) \times 44.04\text{M}\]其中 \(N_{always} = 671 - 653.9 \approx 17\text{B}\)(其中 embedding 0.93B 严格说只查表读一行,这里不细扣)。代入:
B 期望激活专家 路由专家读取量 每步读取参数量 FP8 字节数
1 8.0 20.4B 37.6B 37.6 GB
8 57.4 146.6B 163.8B 164 GB
32 163.3 417.1B 434.3B 434 GB
128 251.6 642.7B 659.8B 660 GB
512 256.0 653.9B 671.0B 671 GB
对照 Llama-3-70B:无论 batch 多大,每步读 70.55B 参数,BF16 141 GB。
也就是说,只有 \(B = 1\) 时 DeepSeek-V3 的”每步读 37B”才成立。\(B = 32\) 时它每步读的字节数(FP8 434 GB)已经是 Llama-3-70B(BF16 141 GB)的 3 倍;\(B = 128\) 时是 4.7 倍。第二篇给出的 decode 时间下界是”权重字节数 / HBM 带宽”,按 H100 的 3.35 TB/s,假设权重能放在一张卡上(当然放不下,这里只为比较):Llama-3-70B 每步下界 42 ms,DeepSeek-V3 在 \(B = 32\) 时每步下界 130 ms。稀疏节省了算量,但没有节省访存——而 decode 恰恰是访存瓶颈的阶段。
3. 三个数分开算
现在可以完整回答核心问题:
DeepSeek-V3 Llama-3-70B 两者之比
参数量(决定显存) 671B 70.55B 9.5x
激活参数量(决定 FLOPs/token) 37B 70.55B 0.52x
每步读取参数量(决定 decode 带宽)
B = 1 37.6B 70.55B 0.53x
B = 32 434B 70.55B 6.2x
B = 128 660B 70.55B 9.4x
“部署 DeepSeek-V3 比 dense 70B 难得多”的原因是第一行和第三行:显存要 9.5 倍,中等 batch 下每步访存要 6–9 倍。只有第二行对 MoE 有利,而 decode 阶段的瓶颈本来就不在第二行。
4. 大规模 EP 的动机
出路只有两条。一条是把 batch 压到极小,让每步只读 \(k\) 个专家——这放弃了吞吐,单卡也放不下 671 GB。另一条是把专家分散到很多张卡上:如果 256 个专家均匀分到 \(N\) 张卡,每张卡每层只持有 \(256/N\) 个专家,每步最多只需读自己那 \(256/N\) 个,而不是全部 256 个。这就是专家并行(Expert Parallelism,EP)。
以 EP32 为例(DeepSeek-V3 prefill 的配置),每卡每层 8 个路由专家,58 层共 \(58 \times 8 \times 44.04\text{M} = 20.4\text{B}\) 参数,FP8 20.4 GB;加上复制到每卡的 attention、dense、共享专家等约 17 GB,每卡权重约 37 GB。EP320(decode 配置)时每卡每层只有 1 个专家,路由专家部分只有 2.55 GB。
把不同 EP 规模下每卡的权重占用列出来。这是一个简化模型:FP8,路由专家均匀分布,非专家部分(attention、dense 层、共享专家)约 17 GB 按在每卡复制算。DeepSeek-V3 报告 3.4 节的真实部署与它有几处不同:attention 用 TP4 + 序列并行再做数据并行(prefill DP8、decode DP80),不是整份复制;共享专家在 decode 时也当作一个路由目标放在专门的卡上,每 token 实际选 9 个专家(8 路由 + 1 共享);320 卡里 64 卡专门托管冗余专家与共享专家。所以下表 EP320 行的 19.6 GB 是简化模型的数,不是实况——实况里每卡权重更少、通信多一份。
EP 规模 每卡每层路由专家数 每卡路由专家权重 每卡权重合计 剩余显存(80 GB 卡)
1 256 654 GB 671 GB 放不下
8 32 81.7 GB 98.7 GB 放不下
16 16 40.9 GB 57.9 GB 22 GB
32 8 20.4 GB 37.4 GB 43 GB
64 4 10.2 GB 27.2 GB 53 GB
320 1(256 + 64 冗余副本) 2.6 GB 19.6 GB 60 GB
(EP320 一行的”256 + 64”是按报告的卡数做的简化:真实配置是 256 个路由专家各一卡、另外 64 卡放冗余副本与共享专家,attention 部分另做 TP4 × DP80。)
EP16 是让 FP8 权重放得下的最小规模,但每卡只剩 22 GB 给 KV cache 与激活;EP32 之后剩余显存才宽裕起来。EP 让每卡的权重读取量从”随 batch 趋近 671B”回到几十 GB,让 HBM 装得下权重之外还留出 KV cache 的空间。代价是原本在一张卡内部完成的”按专家分组”,变成了跨卡通信。
五、专家并行与 all-to-all
1. dispatch 与 combine
EP 下一个 MoE 层的执行流程:
- 每张卡对自己持有的 token 跑 router,得到每个 token 的 \(k\) 个目标专家;
- dispatch:把每个 token 的 hidden state(\(d\) 个数)发到它的 \(k\) 个专家所在的卡——这是一次 all-to-all;
- 每张卡对收到的 token 跑自己的专家(grouped GEMM);
- combine:把每个专家的输出发回 token 所在的卡,按门控权重加权求和——第二次 all-to-all。
attention 部分在 EP 下通常是数据并行的(每卡处理 batch 的一个切片,各自持有自己序列的 KV cache),所以 token “所在的卡”是明确的。每个 MoE 层两次 all-to-all,58 层共 116 次,全部在前向的关键路径上。
用一个缩小的例子把 token、专家、卡三者的映射摆出来(EP4,\(E = 8\),\(k = 2\),每卡持有 2 个 token):
rank0 rank1 rank2 rank3
持有专家 e0 e1 e2 e3 e4 e5 e6 e7
持有 token t0 t1 t2 t3 t4 t5 t6 t7
router 结果(每 token 选 2 个专家):
t0 -> {e1, e4} t2 -> {e0, e5} t4 -> {e0, e4} t6 -> {e1, e7}
t1 -> {e2, e7} t3 -> {e3, e6} t5 -> {e0, e2} t7 -> {e5, e7}
dispatch 的 all-to-all 矩阵:行 = 发送方(token 所在卡),列 = 接收方
(专家所在卡),格子 = 发送的 hidden 向量份数(每份 d 个元素)
-> rank0 -> rank1 -> rank2 -> rank3 发出合计
rank0 1 1 1 1 4
rank1 1 1 1 1 4
rank2 2 1 1 0 4
rank3 1 0 1 2 4
收到合计 5 3 4 4 16 = 8 x k
各专家实际收到的行数(第六章 grouped GEMM 的 M):
e0:3 e1:2 e2:2 e3:1 e4:2 e5:2 e6:1 e7:3
每卡发出的份数恒为 \(B_{local} \times k = 4\),与路由无关;每卡收到的份数取决于路由结果,rank0 收 5 份、rank1 收 3 份,这就是负载不均在通信上的体现(第七章)。对角线上的格子是本卡专家,不走网络。combine 是这张矩阵的转置:每个专家把输出沿原路发回 token 所在卡,只是每份的字节数不同(下一节)。
2. 字节数
每个 token 发给每个专家的是一个长度 \(d = 7168\) 的向量。DeepSeek-V3 的做法是 dispatch 用 FP8、combine 用 BF16(combine 要做加权求和,精度要求更高):
dispatch 每 token 每专家 7168 × 1 B = 7168 B = 7 KiB
combine 每 token 每专家 7168 × 2 B = 14336 B = 14 KiB
top-8:
dispatch 每 token 每层 8 × 7 KiB = 56 KiB
combine 每 token 每层 8 × 14 KiB = 112 KiB
合计 每 token 每层 168 KiB
58 层 每 token 9744 KiB ≈ 9.5 MiB
严格说要扣掉恰好落在本卡的那部分:\(N\) 卡均匀分布时约 \(1/N\) 的专家在本地,EP32 时扣 3%,EP320 时扣 0.3%,可以忽略。
乘上 batch。一张卡持有 \(B_{local}\) 个 token 时,每层要发出并收回 \(B_{local} \times 168\) KiB:
B_local 每层 all-to-all 字节 58 层合计 按 50 GB/s(IB)的传输时间下界
32 5.25 MiB 305 MiB 6.4 ms
128 21 MiB 1.19 GiB 25 ms
prefill 阶段的数字更直观:一个 4096 token 的序列,每层 dispatch + combine 共 \(4096 \times 168\text{ KiB} = 672\) MiB,58 层 38 GiB。在 EP32 下这 4096 个 token 分摊在 32 张卡上,每卡每层收发 21 MiB、58 层 1.2 GiB;若全部走 50 GB/s 的 IB,每卡传输时间下界约 26 ms。而这 4096 个 token 的算量是 \(4096 \times 74\text{ GFLOPs} \approx 303\) TFLOP,在 32 张 H100 上按 FP8 60% MFU 大约 8 ms。通信是计算的 3 倍以上——这说明 EP 下的 prefill 如果不把 all-to-all 与计算充分重叠、不把大部分流量留在 NVLink 域内,通信会主导时间。DeepSeek-V3 的节点受限路由(下一节)和 EP32 只跨 4 个节点的配置,都是在压这个比例。
作为参照,第二篇算过 Llama-3-70B 在 8 卡 TP 下每步 decode 的权重读取下界约 5 ms(141 GB / 8 卡 / 3.35 TB/s)。EP 下每卡的权重读取只剩几十 GB(约 10 ms),但 all-to-all 又添上了同一量级的通信时间——而且这个时间与 \(B_{local}\) 线性增长,权重读取时间则不随 batch 增长。DeepSeek-V3 报告用 DualPipe、把通信 kernel 限制在少量 SM 上与计算重叠、以及自定义 all-to-all kernel,都是在处理这项开销。
3. 节点受限路由
跨节点走 InfiniBand,节点内走 NVLink,两者带宽差一个档次。DeepSeek-V3 报告中给出的数字是 IB 约 50 GB/s、NVLink 约 160 GB/s,比值约 1 : 3.2。
如果 8 个专家随机分布在 40 个节点上,一个 token 要跨 IB 发到接近 8 个不同节点。DeepSeek-V3 在路由时加了一条限制:每个 token 最多发到 4 个节点(node-limited routing)。做法是先按每个节点上专家的亲和度之和选出 4 个节点(报告 2.1.2:每个节点取其上最高的 \(K_r / M = 8/4 = 2\) 个专家分数相加),再在这 4 个节点的专家里取 top-8。这样每 token 的跨 IB 流量最多是 4 份 dispatch + 4 份 combine,而不是 8 份。
这个限制之所以能省流量,是因为一份 hidden state 只需跨 IB 到达目标节点一次,节点内再由 NVLink 分发给该节点上的多个专家。下图以一个 token 的 8 个专家落在 4 个节点(1 + 3 + 2 + 2)为例:
%% 图:节点受限路由:一份 hidden state 跨 IB 只到达每个目标节点一次,节点内再由 NVLink 分发
flowchart TB
T["token 的 hidden state(本节点 N0)<br/>先按节点亲和度选 4 个节点,再在其中取 top-8"]
subgraph n0["节点 N0(本节点)"]
G0["本地 GPU<br/>专家 a"]
end
subgraph n1["节点 N1:3 个专家"]
G1["入口 GPU<br/>专家 b"]
F1["NVLink 转发<br/>专家 c、d 所在 GPU"]
end
subgraph n2["节点 N2:2 个专家"]
G2["入口 GPU<br/>专家 e"]
F2["NVLink 转发<br/>专家 f 所在 GPU"]
end
subgraph n3["节点 N3:2 个专家"]
G3["入口 GPU<br/>专家 g"]
F3["NVLink 转发<br/>专家 h 所在 GPU"]
end
T -->|"NVLink"| G0
T -->|"IB 1 份"| G1
T -->|"IB 1 份"| G2
T -->|"IB 1 份"| G3
G1 --> F1
G2 --> F2
G3 --> F3
classDef ib fill:#f2dede,stroke:#a94442;
classDef nv fill:#dff0d8,stroke:#3c763d;
class G1,G2,G3 ib;
class G0,F1,F2,F3 nv;
不限制节点数时,8 个专家随机落在 40 个节点上,跨 IB 几乎要发 7–8 份;限制到 4 个节点后至多 4 份(图中本节点占了一个名额,所以是 3 份),其余复制发生在带宽高 3 倍的 NVLink 上。
报告还给了一个基于带宽比的说法:一份数据经 IB 到达某节点后,可以经 NVLink 转发给节点内的多张 GPU;由于 NVLink 带宽是 IB 的 3.2 倍,在 IB 传输时间内 NVLink 可以把它转发给约 3.2 个目标而不成为瓶颈——报告的表述是每个 token 可以”等价地”路由到 \(4 \times 3.2 \approx 13\) 个专家而不增加通信开销,top-8 在这个上限之内。这是报告的近似论证,转述于此供理解设计意图。
节点受限路由是训练时就加进路由规则的,不是部署时的优化——它改变了模型,因此必须在训练时就决定。这是 MoE 设计里”系统约束反过来塑造模型结构”的一个直接例子。
4. EP 与 TP 的对比
MoE 的 FFN 有两种切法。先看两种切法下权重与 token 各自怎么分布(4 个专家、4 张卡,\(f = d_{ff}\)):
TP-4:切矩阵。每卡持有所有专家的 1/4(W_gate/W_up 切列,W_down 切行)
expert0 expert1 expert2 expert3
rank0 [d, f/4] [d, f/4] [d, f/4] [d, f/4] 每卡 E 个瘦矩阵
rank1 [d, f/4] [d, f/4] [d, f/4] [d, f/4]
rank2 [d, f/4] [d, f/4] [d, f/4] [d, f/4]
rank3 [d, f/4] [d, f/4] [d, f/4] [d, f/4]
token 不移动:每卡对全部 token 算全部专家的局部结果,
最后 all-reduce 一个 [B, d],通信量与 k、E 无关
EP-4:切专家。每卡持有 E/4 个完整专家
expert0 expert1 expert2 expert3
rank0 [d, f] - - - 每卡 E/N 个完整矩阵
rank1 - [d, f] - -
rank2 - - [d, f] -
rank3 - - - [d, f]
token 移动:dispatch all-to-all -> 本地 grouped GEMM -> combine all-to-all,
通信量与 k 成正比
DeepSeek-V3 的专家 [7168, 2048]:TP-8 每卡切片 [7168, 256],EP 每卡完整 [7168, 2048]
张量并行(TP) 切每个专家的矩阵:\(n\) 卡 TP 下,每个专家的 \(W_{gate}, W_{up}\) 按列切成 \(n\) 份、\(W_{down}\) 按行切成 \(n\) 份,每张卡持有所有 \(E\) 个专家的 \(1/n\)。每张卡对所有 token 算所有专家的局部结果,最后做一次 all-reduce。all-reduce 的通信量是标准结论:对 \(B\) 个 token 的 \([B, d]\) 输出,每卡收发
\[2 \cdot \frac{n-1}{n} \cdot B \cdot d \cdot \text{bytes/elem}\]DeepSeek-V3 的 \(d = 7168\)、BF16、\(n = 8\):每 token 每层 \(2 \times 7/8 \times 7168 \times 2 = 25\) KiB。这个数与 dense FFN 的 TP 通信完全相同,与 \(k\)、\(E\) 都无关。
专家并行(EP) 按专家切,通信是上一节的 168 KiB 每 token 每层,与 \(k\) 成正比,与 \(E\) 无关。
按字节数看,DeepSeek-V3 的 EP 通信是 TP-8 的 6.7 倍。那为什么 DeepSeek-V3 不用 TP?三个原因:
第一,TP 的规模上限是一台机器。NVLink 域是 8 卡,跨节点 TP 会让每层两次 all-reduce(attention 与 FFN 各一次)走 IB,延迟不可接受。而 671 GB 权重两台机器都放不下多少 KV cache,TP-8 从一开始就不够。
第二,TP 不减少每卡的专家读取数。TP-8 下每卡持有全部 256 个专家的 1/8,batch 32 时仍然要读 163 个专家的 1/8 切片——每卡读取量是 \(434 / 8 = 54\) GB,而 EP32 每卡只读自己 8 个专家中被激活的(至多 \(8 \times 58 \times 44.04\text{M} = 20.4\) GB,加上复制的 17 GB)。
第三,也是最根本的:GEMM 形状。专家的 \(d_{ff} = 2048\),TP-8 切完每卡只剩 256 列,\(W_{gate}\) 的切片是 \([7168, 256]\)。这么瘦的矩阵,GEMM 的 N 维只有 256,Tensor Core 的 tile 利用率与访存效率都差。而 EP 下每卡的专家 GEMM 保持完整的 \([7168, 2048]\) 形状。Mixtral 的专家 \(d_{ff} = 14336\),TP-8 切完还有 1792 列,GEMM 形状仍然健康,所以 Mixtral 在单机 8 卡上用 TP 是完全可行的选择——vLLM 的默认 MoE 部署就是这样。
粗略的判据:专家少、每个专家宽(Mixtral),TP 划算,通信量小且 GEMM 形状好;专家多、每个专家窄(DeepSeek-V3),TP 切出来的矩阵太瘦,必须用 EP,接受 all-to-all 的代价。 实际部署常常两者混用:attention 部分 TP 或数据并行,专家部分 EP。
六、grouped GEMM 的形态
1. 每专家平均行数
dense FFN 对 \(T\) 个 token 做的 GEMM 是 \([T, d] \times [d, d_{ff}]\),M 维等于 \(T\)。prefill 一个 4096 token 的序列,M = 4096,是 Tensor Core 最喜欢的形状。
MoE 层把 \(T\) 个 token 各复制 \(k\) 份分到 \(E\) 个专家,每个专家平均收到
\[\frac{T \cdot k}{E}\]个 token。代入:
T k E 每专家平均行数 Tk/E dense FFN 的 M
DeepSeek-V3 prefill 4096 8 256 128 4096
DeepSeek-V3 decode 32 8 256 1 32
DeepSeek-V3 decode 128 8 256 4 128
Mixtral prefill 4096 2 8 1024 4096
Mixtral decode 32 2 8 8 32
DeepSeek-V3 prefill 4096 token,每个专家只有 128 行;decode batch 32,每个专家平均 1 行。同样的 token 数,dense FFN 是一个 M = 4096 的大 GEMM,MoE 是 256 个 M = 128 的小 GEMM。
2. 为什么 MoE 的 GEMM 天然低效
Tensor Core GEMM kernel 以 tile 为单位计算,典型的 tile 是 128 × 128 或 128 × 256(M × N)。M = 128 恰好填满一个 tile,没有浪费,但一个专家的 GEMM 只有一个 tile 行,无法在 M 方向上做多 tile 的流水与负载分配;M = 1 时 tile 的 128 行中只有 1 行有效,算力利用率是 1/128。
grouped GEMM 是对这个问题的工程回答:把 \(E\) 个不同形状(行数各异)、共享 K 与 N 维的小 GEMM 打包成一个 kernel launch,让 GPU 的 SM 在专家之间做负载分配,避免 256 次 launch 的开销和 SM 空闲。CUTLASS 的 grouped GEMM、vLLM 的 fused MoE Triton kernel、Megatron 的 grouped GEMM 后端都是这个思路。它解决了 launch 开销与 SM 利用率问题,但没有改变每个专家 M 小这一事实:decode 阶段的 MoE 层,本质上还是在为每个专家读一遍 \([7168, 2048]\) 的权重然后只乘 1–4 行——第四章算过的”访存量按激活专家数”,正是这里的直接体现。
第一篇的参数量、第二篇的 FLOPs 在 MoE 上都成立;不成立的是第二篇 Roofline 分析中”batch \(B\) 时权重 GEMM 算术强度约为 \(B\) FLOP/byte”这条:MoE 层里每个专家的算术强度是 \(Tk/E\) 而不是 \(T\),比 dense 低 \(E/k = 32\) 倍(DeepSeek-V3)。要让专家 GEMM 越过 H100 的 ridge point(约 295 FLOP/byte,BF16),需要每专家至少 300 行左右,即 \(T \geq 300 \times 32 \approx 9600\) 个 token 同时在一层——这在 prefill 可以做到,在 decode 只有靠 EP 把大量并发请求的 token 汇聚到同一个专家上。DeepSeek-V3 用 EP320 做 decode,320 卡上的所有请求在每个专家上汇聚,是让专家 GEMM 有足够 M 的另一个理由。
七、负载均衡
前面所有推导都假设路由均匀。实际训练中 router 会自发地偏爱少数专家(被选多的专家训练得更好,因此被选得更多),如果不加干预会塌缩到几个专家上。负载不均既是建模问题,也是系统问题。
1. 辅助损失
Switch Transformer(Fedus 等 2021)的做法是在训练目标里加一项辅助损失。对一个 batch 的 \(T\) 个 token,定义
\[f_i = \frac{1}{T} \sum_{t=1}^{T} \mathbb{1}\{\text{token } t \text{ 选中专家 } i\}, \qquad P_i = \frac{1}{T} \sum_{t=1}^{T} s_{t,i}\]\(f_i\) 是实际分到专家 \(i\) 的 token 比例(不可微),\(P_i\) 是 router 给专家 \(i\) 的平均概率(可微)。辅助损失:
\[\mathcal{L}_{aux} = \alpha \cdot E \cdot \sum_{i=1}^{E} f_i \cdot P_i\]直觉上,\(f\) 与 \(P\) 都均匀(\(1/E\))时这个内积等于 \(1/E\)、损失等于 \(\alpha\);某个专家的 \(f_i\) 偏高时,梯度会压低它的 \(P_i\)。但要说清:均匀不是这个式子的全局最小值——\(f\) 与 \(P\) 是两个不同的向量,只要它们”错开”,内积可以更小:两个 token 的路由概率分别是 \((0.51, 0, 0.49)\) 与 \((0, 0.51, 0.49)\),top-1 分给专家 1、2,于是 \(f = (0.5, 0.5, 0)\)、\(P = (0.255, 0.255, 0.49)\),\(E \sum f_i P_i = 0.765 < 1\)。所以它是一个把”超载专家的概率往下压”的梯度信号,设计目标是均衡,不是一个在均衡处取最小的势能。系数 \(\alpha\) 取 0.01 量级:太小压不住塌缩,太大会为了均衡牺牲路由质量——这是 aux loss 方法的固有张力。
2. 容量因子与 token drop
训练时每个专家的缓冲区大小是固定的,由容量因子(capacity factor)\(C\) 决定:
\[\text{capacity} = C \cdot \frac{T \cdot k}{E}\]\(C = 1\) 时容量恰好等于均匀分配下的平均行数;Switch Transformer 用 1.0–1.25。收到的 token 超过容量的专家丢弃多余 token——这些 token 在这一层不经过 FFN,只沿残差连接直接通过。\(C\) 越大丢弃越少但填充(padding)越多、计算浪费越大。
固定容量的动机是系统性的:训练框架希望每个专家的输入是一个形状固定的张量 \([\text{capacity}, d]\),这样 all-to-all 的缓冲区大小、GEMM 的形状在编译期就能确定,不需要动态分配。代价是两头浪费——欠载的专家要 padding 到 capacity,超载的专家要丢 token。以 DeepSeek-V3 prefill 4096 token、\(C = 1.25\) 为例,每专家容量 160 行;若某个专家实际收到 200 行,40 行被丢弃(20%);若只收到 80 行,另外 80 行是零填充,GEMM 的一半算力浪费。把几个专家的实际行数画在同一条容量线上:
T = 4096, k = 8, E = 256:平均 Tk/E = 128 行;C = 1.25 -> capacity = 160 行
每个字符 = 8 行:# 实际 token . 零填充 padding x 超出容量被 drop
avg cap
128 160
expert 0 rows=200 ################:####|xxxxx 40 行 drop(20%)
expert 1 rows=160 ################:####| 恰好填满
expert 2 rows=128 ################:....| 32 行 padding
expert 3 rows= 80 ##########......:....| 80 行 padding,GEMM 一半浪费
<-- 每专家固定 [160, d] 输入 -->
容量线两侧都是浪费:线右边的 token 被丢,线左边的空位被算。\(C\) 只能在两种浪费之间移动,不能同时消除。
推理时一般不丢弃(dropless),改用动态大小的 grouped GEMM——每个专家有多少行就算多少行,这正是 grouped GEMM 支持不等行数的原因。因此训练与推理在 token drop 上的行为存在差异,训练时被丢弃过的 token 在推理时会正常经过专家;这个差异在实践中通常可以接受,但它是”训练时的均衡策略如何影响推理形态”的一个例子。DeepSeek-V3 训练时就不做 token drop,避免了这个差异。
3. aux-loss-free:DeepSeek-V3 的偏置调节
DeepSeek-V3(DeepSeek-AI 2024)采用 auxiliary-loss-free 的均衡策略。给每个专家一个标量偏置 \(b_i\),top-\(k\) 选择时用 \(s_i + b_i\) 排序,但门控权重仍用原始的 \(s_i\):
\[\text{TopK}(s_1 + b_1, \ldots, s_E + b_E) \quad \text{选专家}; \qquad g_i = \frac{s_i}{\sum_{j \in \text{TopK}} s_j} \quad \text{算权重}\]每个训练步结束后统计各专家的负载:超载的专家 \(b_i \leftarrow b_i - \gamma\),欠载的 \(b_i \leftarrow b_i + \gamma\),\(\gamma\) 是很小的常数(报告中 0.001)。偏置只影响”选谁”,不影响”选中后的权重”,因此不会像 aux loss 那样把梯度噪声注入主目标;负载均衡变成了一个在训练目标之外运行的控制回路。报告中还保留了一项系数极小的序列级均衡损失(\(\alpha = 10^{-4}\))作为兜底,防止单个序列内的极端不均。
4. 负载不均对 EP 意味着什么
EP 下一层的时间由最慢的那张卡决定——所有卡都要等 combine 的 all-to-all 完成才能进入下一层。设每卡持有 1 个专家(EP256/EP320 的情形),平均每个专家收到 \(Tk/E\) 个 token。如果某个专家收到 2 倍平均的 token:
- 它所在卡的 grouped GEMM 时间翻倍(M 翻倍;在访存瓶颈的 decode 阶段 M 翻倍时间不一定翻倍,但在 prefill 是接近线性的);
- 它收到的 dispatch 字节数翻倍,发出的 combine 字节数翻倍——all-to-all 的完成时间由最大的收发方决定;
- 其余 255 张卡在这一层的后半段空闲。
用 4 张卡的时间线看”谁在等谁”(每格 = 均衡时一个阶段的时间):
均衡(每专家都收到 Tk/E 行):
rank0-3 |disp|GEMM|comb|
0 1 2 3 全层 = 3 格
不均衡(e2 收到 2x 平均):
rank0 (e0) |disp |GEMM|....|comb |
rank1 (e1) |disp |GEMM|....|comb |
rank2 (e2) |disp |GEMM GEMM|comb | <- 收 2x 字节、算 2x 行、发 2x 字节
rank3 (e3) |disp |GEMM|....|comb |
0 2 3 4 6 全层 = 6 格
disp / comb 是 all-to-all,结束时间由最大收发方 rank2 决定,所有卡一起等;
GEMM 阶段其余 3 张卡 1 格后算完(....),要等 rank2 算完才能进入 combine。
结果是这一层的耗时约为均衡时的 2 倍,全层的算力利用率降到约 50%。58 层里只要几层出现热点,整体吞吐就明显下降。这就是为什么 DeepSeek-V3 的部署方案里有”冗余专家”(把热门专家复制到多张卡上)与周期性根据负载统计重排专家的机制——EP 下负载均衡不再只是训练时的建模问题,而是推理时的调度问题。
八、共享专家与 MTP
1. 共享专家其实是 dense FFN
DeepSeek-V3 每个 MoE 层有 1 个共享专家,\(d_{ff} = 2048\),不经过 router,对每个 token 都执行。从系统角度看它就是一个 dense 的窄 FFN:参数 44.04M,每 token 算量 \(2 \times 44.04\text{M}\),每步无论 batch 多大都要读一遍。它可以在每张卡上复制、不参与 all-to-all——这是最简单的放法;DeepSeek-V3 的 decode 部署(报告 3.4.2)却没有这样做,而是把它也当作一个”每个 token 都会选中”的专家放在专门的卡上,一起走 all-to-all,每 token 发往 9 个目标。两种放法都合法,取舍是每卡显存与一份额外通信。58 层共 2.55B 参数,算进 37B 激活参数,也算进第四章的 \(N_{always}\)。
建模上的动机(DeepSeek-V2 论文)是让共享专家吸收所有 token 都需要的”通用知识”,使路由专家更专门化、减少专家间的冗余。系统上它是 MoE 层里唯一”行为像 dense”的部分,可以和 attention 一起用 TP、直接复制、或如上所述当作路由目标——前两种不增加 all-to-all 通信。
2. MTP:内置的投机草稿
DeepSeek-V3 在主模型之外训练了一个多 token 预测(Multi-Token Prediction,MTP)模块,用第 \(t\) 个位置的 hidden state 额外预测第 \(t+2\) 个 token。推理时这个模块可以直接当作投机解码的草稿模型:主模型一步产生一个 token,MTP 头顺带猜出下一个,再由主模型验证。报告中的接受率在 85%–90%,相当于每步 decode 平均产出接近 1.8 个 token。投机解码的期望加速与接受率的关系,第七篇会展开。
九、实践:llm_cost.py 的 MoE 支持
在系列脚本 llm_cost.py 的 ModelConfig 上增加 MoE 字段,并增加四个函数:moe_param_count、active_params、expected_active_experts、ep_all_to_all_bytes_per_layer。为了让本篇代码独立可运行,把用到的 attention 参数函数(含 MLA)也一并给出:
from dataclasses import dataclass
from math import comb
@dataclass
class ModelConfig:
name: str
hidden: int
layers: int
n_heads: int
n_kv_heads: int
head_dim: int
d_ff: int
vocab: int
tie_embeddings: bool = False
# 第三篇加入:MLA(mla_rank=0 表示 GQA/MHA)
mla_rank: int = 0 # d_c
rope_dim: int = 0 # d_h^R
q_lora_rank: int = 0
# 第五篇加入:MoE(n_experts=0 表示 dense)
n_experts: int = 0 # 路由专家数 E
top_k: int = 0 # 每 token 激活的路由专家数 k
expert_d_ff: int = 0 # 每个专家的 d_ff
n_shared: int = 0 # 共享专家数(永远激活)
moe_layers: int = 0 # MoE 层数,其余 layers - moe_layers 层为 dense
dense_d_ff: int = 0 # dense 层的 d_ff(0 表示与 d_ff 相同)
LLAMA3_70B = ModelConfig("Llama-3-70B", 8192, 80, 64, 8, 128, 28672, 128256)
MIXTRAL_8X7B = ModelConfig(
"Mixtral-8x7B", 4096, 32, 32, 8, 128, 14336, 32000,
n_experts=8, top_k=2, expert_d_ff=14336, n_shared=0, moe_layers=32,
)
DEEPSEEK_V3 = ModelConfig(
"DeepSeek-V3", 7168, 61, 128, 128, 192, 18432, 129280,
mla_rank=512, rope_dim=64, q_lora_rank=1536,
n_experts=256, top_k=8, expert_d_ff=2048, n_shared=1,
moe_layers=58, dense_d_ff=18432,
)
def attn_params(cfg):
"""每层 attention 的权重参数量(GQA/MHA 或 MLA)。"""
d, h = cfg.hidden, cfg.n_heads
if cfg.mla_rank:
nope = cfg.head_dim - cfg.rope_dim # 128
w_dq = d * cfg.q_lora_rank # 7168 x 1536
w_uq = cfg.q_lora_rank * h * cfg.head_dim # 1536 x 24576
w_dkv = d * (cfg.mla_rank + cfg.rope_dim) # 7168 x 576
w_uk = cfg.mla_rank * h * nope # 512 x 16384
w_uv = cfg.mla_rank * h * nope # 512 x 16384
w_o = h * nope * d # 16384 x 7168
return w_dq + w_uq + w_dkv + w_uk + w_uv + w_o
q_o = 2 * d * h * cfg.head_dim
k_v = 2 * d * cfg.n_kv_heads * cfg.head_dim
return q_o + k_v
def ffn_params(d, d_ff):
"""SwiGLU FFN:gate、up、down 三个矩阵。"""
return 3 * d * d_ff
def moe_param_count(cfg):
"""返回 (总参数量, 分项字典)。n_experts=0 时退化为 dense 模型。"""
d = cfg.hidden
n_dense = cfg.layers - cfg.moe_layers
dense_ff = cfg.dense_d_ff or cfg.d_ff
per_expert = ffn_params(d, cfg.expert_d_ff) if cfg.n_experts else 0
parts = {
"attention": cfg.layers * attn_params(cfg),
"dense_ffn": n_dense * ffn_params(d, dense_ff),
"router": cfg.moe_layers * d * cfg.n_experts,
"routed_experts": cfg.moe_layers * cfg.n_experts * per_expert,
"shared_experts": cfg.moe_layers * cfg.n_shared * per_expert,
"norms": (2 * cfg.layers + 1) * d,
"embedding": cfg.vocab * d * (1 if cfg.tie_embeddings else 2),
}
return sum(parts.values()), parts
def active_params(cfg):
"""每 token 激活的参数量:总参数减去未被选中的路由专家。"""
total, parts = moe_param_count(cfg)
if not cfg.n_experts:
return total
per_expert = ffn_params(cfg.hidden, cfg.expert_d_ff)
return total - parts["routed_experts"] + cfg.moe_layers * cfg.top_k * per_expert
def expected_active_experts(cfg, batch):
"""batch 个 token 独立、均匀路由时,一层里期望被至少一个 token 选中的专家数。"""
E, k = cfg.n_experts, cfg.top_k
if not E:
return 0.0
return E * (1 - (1 - k / E) ** batch)
def params_read_per_step(cfg, batch):
"""一步 decode 期望从 HBM 读取的参数量(假设全模型在一张卡上,仅用于比较)。"""
total, parts = moe_param_count(cfg)
if not cfg.n_experts:
return total
per_expert = ffn_params(cfg.hidden, cfg.expert_d_ff)
read_experts = cfg.moe_layers * expected_active_experts(cfg, batch) * per_expert
return total - parts["routed_experts"] + read_experts
def ep_all_to_all_bytes_per_layer(cfg, batch, dispatch_bytes=1, combine_bytes=2,
ep_size=None):
"""一个 MoE 层 dispatch + combine 的 all-to-all 字节数(batch 个 token 合计)。
dispatch_bytes / combine_bytes 是每个元素的字节数(DeepSeek-V3:FP8 / BF16)。
给出 ep_size 时,按均匀假设扣掉落在本卡的 1/ep_size。"""
per_token = cfg.top_k * cfg.hidden * (dispatch_bytes + combine_bytes)
total = batch * per_token
if ep_size:
total *= (ep_size - 1) / ep_size
return total
def routing_combinations(cfg):
return comb(cfg.n_experts, cfg.top_k) if cfg.n_experts else 1
if __name__ == "__main__":
KiB = 1024
for cfg in (MIXTRAL_8X7B, DEEPSEEK_V3, LLAMA3_70B):
total, parts = moe_param_count(cfg)
print(f"== {cfg.name}")
for key, val in parts.items():
print(f" {key:16s} {val/1e9:8.3f} B")
print(f" total {total/1e9:8.2f} B")
print(f" active {active_params(cfg)/1e9:8.2f} B")
print(f" flops/token {2*active_params(cfg)/1e9:8.1f} GFLOPs")
if cfg.n_experts:
# Mixtral 的 dispatch/combine 都按 BF16 算;DeepSeek-V3 按 FP8/BF16
db = 1 if cfg.mla_rank else 2
print(f" C(E,k) {routing_combinations(cfg):.3e}")
for B in (1, 8, 32, 128, 512):
n_act = expected_active_experts(cfg, B)
read = params_read_per_step(cfg, B)
a2a = ep_all_to_all_bytes_per_layer(cfg, B, dispatch_bytes=db) / KiB
print(f" B={B:4d} active experts {n_act:7.1f} read {read/1e9:7.1f} B"
f" a2a/layer {a2a:9.0f} KiB")
运行输出(节选):
== Mixtral-8x7B
attention 1.342 B
routed_experts 45.097 B
embedding 0.262 B
total 46.70 B
active 12.88 B
flops/token 25.8 GFLOPs
C(E,k) 2.800e+01
B= 1 active experts 2.0 read 12.9 B a2a/layer 32 KiB
B= 8 active experts 7.2 read 42.2 B a2a/layer 256 KiB
B= 32 active experts 8.0 read 46.7 B a2a/layer 1024 KiB
B= 128 active experts 8.0 read 46.7 B a2a/layer 4096 KiB
== DeepSeek-V3
attention 11.413 B
dense_ffn 1.189 B
router 0.106 B
routed_experts 653.909 B
shared_experts 2.554 B
embedding 1.853 B
total 671.03 B
active 37.55 B
flops/token 75.1 GFLOPs
C(E,k) 4.097e+14
B= 1 active experts 8.0 read 37.6 B a2a/layer 168 KiB
B= 8 active experts 57.4 read 163.8 B a2a/layer 1344 KiB
B= 32 active experts 163.3 read 434.3 B a2a/layer 5376 KiB
B= 128 active experts 251.6 read 659.8 B a2a/layer 21504 KiB
B= 512 active experts 256.0 read 671.0 B a2a/layer 86016 KiB
== Llama-3-70B
total 70.55 B
active 70.55 B
flops/token 141.1 GFLOPs
几点核对:
- Mixtral 总参数 46.70B、激活 12.88B,与第三章手算一致;
- DeepSeek-V3 总参数 671.03B,与技术报告的 671B 一致;激活 37.55B 比报告的 37B 略高,差别来自 router(0.11B)与 embedding 的计法(报告的 37B 是取整数字),正文统一用 37B 与 74 GFLOPs;
- 期望激活专家数 \(B = 32\) 时 163.3、\(B = 128\) 时 251.6,与第四章的表一致;
- all-to-all 每 token 每层 168 KiB(DeepSeek-V3)、32 KiB(Mixtral,top-2、\(d = 4096\)、dispatch 与 combine 都按 BF16)。
第六篇会在这个脚本上加 dtype 字节表与训练状态显存,第七篇加量化、投机解码与 LoRA。
十、本文小结
MoE 把 dense 模型里绑在一起的三个数拆开了:
- 参数量决定显存:DeepSeek-V3 的 671B 在 FP8 下也是 671 GB,一台 8 卡 H100 放不下;
- 激活参数量决定 FLOPs/token:37B 对应 74 GFLOPs,是 Llama-3-70B 的一半;
- 每步实际读取的参数量决定 decode 带宽:随 batch 从 37B 趋近 671B,\(B = 32\) 时已是 434B,因为期望激活专家数 \(E[1 - (1 - k/E)^B]\) 在中等 batch 下就接近 \(E\)。
第三个数是”37B 的模型比 70B 难部署”的直接原因,也是 DeepSeek-V3 必须用大规模 EP 的原因:只有把专家分散到很多卡上,每卡的读取量才回到可控范围。EP 的代价是每层两次 all-to-all(每 token 每层 dispatch 56 KiB + combine 112 KiB)、每专家 GEMM 只有 \(Tk/E\) 行(decode 时可能只有 1 行)、以及负载不均时最慢的卡决定全层时间。节点受限路由、aux-loss-free 均衡、冗余专家、共享专家、grouped GEMM,都是围绕这几项代价的应对。
本篇算出的数字汇总:
Mixtral 8x7B DeepSeek-V3 Llama-3-70B(dense 对照)
专家配置 8 × 14336, top-2 256 × 2048, top-8 —
+ 1 共享
每专家参数 176.16M 44.04M —(FFN 704.6M)
总参数 46.7B 671B 70.55B
激活参数 / token 12.9B 37B 70.55B
FLOPs / token(≈ 2 × 激活) ≈ 26 GFLOPs 74 GFLOPs 141 GFLOPs
权重字节 BF16 93.4 GB FP8 671 GB BF16 141 GB
BF16 1342 GB
期望激活专家数 B=1 / 32 / 128 2 / 8.0 / 8.0 8 / 163 / 252 —
每步读取参数量 B=1 12.9B 37.6B 70.55B
B=32 46.7B 434B 70.55B
B=128 46.7B 660B 70.55B
EP all-to-all / token / 层 32 KiB(BF16) 168 KiB —
(FP8 56 + BF16 112)
TP-8 all-reduce / token / 层 — 25 KiB 28 KiB
grouped GEMM 每专家行数
prefill T=4096 1024 128 4096(dense M)
decode B=32 8 1 32(dense M)
所有数字都是从超参数推导的理论值。它们回答的是数量级问题:MoE 在哪里省了、在哪里没省、代价转移到了哪里。
下一篇离开结构与算量,进入数值:同样的 GEMM 用 FP16、BF16、FP8 算,结果会差多少,为什么 DeepSeek-V3 的 FP8 训练要每 128 个元素就提升到 FP32 累加一次。
一个数用多少位表示,决定了它能算多快、放多少,也决定了它在哪里会悄悄算错。
配套代码:transformer-and-llm/llm_cost_05_moe.py;第二章的最小 MoE 层在 moe_layer_minimal.py。
十一、自测
-
DeepSeek-V3:256 个路由专家 + 1 个共享专家,每 token 选 8 个,每个专家 \(d = 7168\)、\(d_{ff} = 2048\)、三个矩阵。一层 MoE 的参数量与每 token 的激活参数量各多少?
答案
每个专家 \(3 \times 7168 \times 2048 = 44\)M;257 个 11.3B(一层);激活 9 个(8 路由 + 1 共享)396M。61 层里 58 层 MoE。
-
batch 为 1、8、64 时,一层里期望被激活的路由专家数各约多少(256 个专家、每 token 选 8)?
答案
\(256 \times [1 - (1 - 8/256)^B]\):\(B = 1\) 是 8;\(B = 8\) 是 \(256 \times (1 - 0.968^8) \approx 59\);\(B = 64\) 是 \(256 \times (1 - 0.968^{64}) \approx 224\)——中等 batch 就几乎读全部专家。
-
MoE 一层为什么要两次 all-to-all?通信量与什么成正比?
答案
dispatch 把每个 token 的隐状态送到它选中的专家所在的卡,combine 把专家输出送回原卡加权求和;每 token 每层通信 \(2 \times k \times d \times\) bytes(去与回),与 top-k、\(d\)、序列长度成正比,与专家参数量无关。
-
专家负载不均衡为什么会拖慢整个 step?DeepSeek-V3 用什么办法平衡而不影响 loss?
答案
同步执行下最忙的专家(卡)决定一层的时间,其他卡空等;V3 用无辅助 loss 的偏置调节——给过载专家的路由分数加负偏置,只影响选择不进梯度,避免辅助 loss 伤害主目标。
-
同样 74 GFLOPs/token,MoE 37B 激活与 dense 37B 在 decode 带宽上差在哪?
答案
dense 37B 每步读 37B 参数、与 batch 无关;MoE 每步读的参数随 batch 从 37B 涨到 671B——batch 稍大就读 5–18 倍的字节,memory-bound 的 decode 时间随之涨。
下一篇
-
把三个「参数量」分开算:总参数量 671B 决定显存——FP8 下也是 671 GB,一台 8 卡 H100(640 GB)放不下,必须多机(第三章);激活参数量 37B 决定每 token 的 FLOPs——74 GFLOPs,只有 dense 70B 的一半;每步实际读取的参数量决定 decode 带宽——batch 为 1 时读 37B,但每个 token 各选 8 个专家,一个 batch 里被激活的专家数 \(E[1 - (1 - k/E)^B]\) 在 \(B = 32\) 时已覆盖 434B 的权重,趋近 671B,所以 decode 读的字节几乎是 dense 70B 的 5 倍而算力用不满(第四章)。难在哪:显存逼着专家并行到多机,每层两次 all-to-all 把 token 送到专家所在的卡再送回来(第五章);小 batch 下每个专家只分到几个 token,GEMM 碎成一堆小矩阵、MFU 极低(第六章);专家负载不均让最忙的卡拖住所有卡(第七章)。dense 70B 一台机器 TP 就跑起来了,这些问题一个都没有。 ↩
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/moe-compute-and-communication.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。