MoE 与专家并行

DeepSeek-V3 有 671B 参数,每个 token 却只用到其中 37B:每层 256 个专家,路由器只挑 8 个。混合专家(Mixture of Experts, MoE)就这样把参数量和计算量拆开了。账单落在两处:路由必须均衡,否则大部分专家闲着、最忙的那张卡拖慢所有人;专家分到不同的卡上之后,每层前向要做两次 all-to-all,把 token 送过去再取回来。流水线并行里的 DualPipe 费那么大劲去重叠的,正是这两次 all-to-all。

从稠密 FFN 到 MoE

先回顾一下 Transformer 块里的前馈网络(FFN,也叫 MLP)。对每个 token 的隐状态 \(u \in \mathbb{R}^h\),现代 LLM 常用 SwiGLU 形式:

$$\mathrm{FFN}(u) = W_{down}\,\big(\mathrm{SiLU}(W_{gate}\,u) \odot W_{up}\,u\big),\quad W_{gate}, W_{up} \in \mathbb{R}^{f\times h},\ W_{down}\in\mathbb{R}^{h\times f}$$

三块矩阵共 \(3hf\) 个参数,前向每个 token 约 \(2\cdot 3hf = 6hf\) 次浮点运算(一次乘加记 2 FLOPs)。FFN 有个关键性质:它逐 token 独立,不同 token 之间没有交互(交互都在注意力里)。这就给了「不同 token 走不同参数」的空间。

MoE 把一个 FFN 换成 \(E\) 个结构相同的 FFN(称为专家,expert),再加一个小小的路由器(router,也叫 gating network)。每个 token 只被送到其中 \(k\) 个专家,输出是这 \(k\) 个专家输出的加权和:

$$h'_t = u_t + \sum_{i=1}^{E} g_{i,t}\,\mathrm{FFN}_i(u_t),\qquad g_{i,t}=0\ \text{对未选中的专家}$$

于是参数量涨了约 \(E\) 倍,而每个 token 的计算只涨了 \(k\) 倍。\(E/k\) 常被称为稀疏度(sparsity),一般在 8 到 64 之间;DeepSeek-V3 是 \(E=256\)、\(k=8\),稀疏度 32。这种「参数多、但每个输入只用一小部分」的思路叫条件计算(conditional computation):模型很大,但每个 token 只「走过」其中一条窄路。它在大规模神经网络里的现代版本来自 Shazeer 等人 2017 年的 Sparsely-Gated MoE。

一个 \(E=8\)、\(k=2\) 的 MoE 层:路由器给 8 个专家打分(分数为示意),只有得分最高的 2 个真正参与计算;门控值是这两个分数归一化后的结果(0.80 和 0.49 归一化成 0.62 和 0.38),两个专家的输出按它加权求和,再加上残差。

为什么值得这么做?因为在训练 FLOPs 固定时,更多参数通常意味着更低的 loss:参数可以看作模型能存下的「知识容量」,而 FLOPs 是每次查询的成本,MoE 把这两者解耦了。MoE 让我们用同样的每 token 计算量撑起大得多的参数量。代价全在系统侧:参数显存变大、每个专家拿到的 batch 变小(Shazeer 等人称为 shrinking batch problem)、路由会不均衡、专家分布在不同卡上时要搬运 token。后面基本都在处理这些代价。

shrinking batch 值得算一下。一张卡上 \(T\) 个 token、\(E\) 个专家、top-\(k\),均匀路由时每个专家只拿到 \(Tk/E\) 个 token。专家的矩阵乘形状是 \([Tk/E,\ h]\times[h,\ d_e]\)(\(d_e\) 是专家的中间维度)。它的算术强度(每从显存读一字节权重能做多少 FLOPs)正比于这个行数:权重 \(h\times d_e\) 读一次,被多少行 token 复用,就做多少倍的计算。稠密模型里每个 FFN 吃满 \(T\) 行,MoE 里只有 \(Tk/E\) 行:以 \(E/k=32\) 为例,同样的 \(T\) 下专家 GEMM 的行数只有稠密的 1/32,很容易掉进带宽受限区。解法只有一个:让每个专家看到更多 token——把很多数据并行副本的 token 汇聚到同一个专家上,这正是后面专家并行的出发点。

路由器:打分、top-k、加权求和

路由器本身就是一个线性层。设一批里有 \(T\) 个 token,隐状态堆成 \(U \in \mathbb{R}^{T\times h}\),路由器参数 \(W_r \in \mathbb{R}^{h\times E}\)(第 \(i\) 列 \(e_i\) 可以看成专家 \(i\) 的「质心」向量),得到路由 logits:

$$H = U W_r \in \mathbb{R}^{T\times E},\qquad H_{t,i} = u_t^\top e_i$$

然后三步:

  1. 打分:把 logits 变成亲和度(affinity)\(s_{i,t}\),用 softmax 或 sigmoid。
  2. 选 top-k:对每个 token 取分数最高的 \(k\) 个专家,记为集合 \(\mathcal{T}_t\)。
  3. 加权:门控值 \(g_{i,t}\) 由选中专家的分数得到(可能再归一化),未选中的为 0;输出 \(\sum_{i\in\mathcal{T}_t} g_{i,t}\mathrm{FFN}_i(u_t)\)。

路由器怎么学?top-k 选择本身不可导,但门控值 \(g_{i,t}\) 是可导的:输出对 \(g_{i,t}\) 的梯度是 \(\mathrm{FFN}_i(u_t)\) 与上游梯度的内积,再经 softmax/sigmoid 传回 \(W_r\)。所以路由器学的是「被选中的专家里,谁该多给权重」,而谁被选中这件事只通过分数的相对大小间接地被改变。这也是为什么输出要乘门控值,而不是直接把选中专家的输出相加:没有这个乘法,路由器就收不到任何梯度。

写成式子:设上游传回的梯度为 \(\delta_t = \partial \mathcal{L}/\partial h'_t\),则

$$\frac{\partial \mathcal{L}}{\partial g_{i,t}} = \delta_t^\top\,\mathrm{FFN}_i(u_t)\quad (i\in\mathcal{T}_t),$$

再乘上 \(\partial g/\partial H\) 传到 logits、传到 \(W_r\) 和 \(u_t\)。直观地说:如果专家 \(i\) 的输出方向与「让 loss 下降的方向」一致(\(\delta^\top \mathrm{FFN}_i\lt 0\)),它的门控值就被调大,进而它在这类 token 上的 logit 被调高,下次更可能被选中。未被选中的专家完全拿不到这种信号,这也是坍缩的根源之一:没被选中就没有机会证明自己。

Shazeer 2017 的版本是 noisy top-k:在 logits 上加一个可学习幅度的高斯噪声再取 top-k,未选中的位置置为 \(-\infty\) 后再 softmax:

$$G(x)=\mathrm{Softmax}(\mathrm{KeepTopK}(H(x),k)),\quad H(x)_i = (xW_g)_i + \mathcal{N}(0,1)\cdot\mathrm{Softplus}((xW_{noise})_i)$$

噪声的作用是帮助负载均衡(让分数接近的专家都有机会被选到)。他们当时猜测 \(k>1\) 是必要的,否则路由器没有「比较两个专家」的梯度信号;Switch Transformer 反过来证明 \(k=1\) 也能工作,而且路由计算更省、每个专家的容量可以减半、通信更少。GShard 用 top-2,并对第二个专家做随机路由:以正比于其(归一化后)门控值 \(g_2\) 的概率才真正派发,门控值很小时干脆省掉,节省容量。

从张量形状的角度看一次 MoE 前向(单卡、不考虑并行):

U       : [T, h]          # 本批 token
logits  = U @ W_r          # [T, E]
s       = score(logits)    # [T, E]  softmax / sigmoid
idx, w  = topk(s, k)       # [T, k] 专家编号, [T, k] 门控值(可能归一化)
# 按专家分组:把 T*k 个 (token, 专家) 对按专家编号排序
perm    = argsort(idx.flatten())
X       = U[perm // k]     # [T*k, h]  每个专家的输入连续存放
Y       = grouped_ffn(X, counts_per_expert)   # [T*k, h]
out     = zeros(T, h).index_add(0, perm // k, Y * w.flatten()[perm, None])

\(T\cdot k\) 个 (token, 专家) 对展平后,第 \(j\) 个对属于 token \(\lfloor j/k \rfloor\),所以 perm // k 就是排序后每一行对应的 token 编号。这个「排序—按专家分组算—按原顺序加回去」的结构,到了专家并行只是把中间那步换成跨卡的 all-to-all。

softmax 门控还是 sigmoid 门控

打分函数有几种常见组合,差别在在哪一步归一化:

做法公式代表
先 softmax 再 top-k(不再归一化)\(s=\mathrm{softmax}(H)\),\(g_i = s_i\ (i\in\mathcal{T})\)Switch(k=1)、DeepSeekMoE、DeepSeek-V2
先 softmax 再 top-k,再在选中者间归一化\(g_i = s_i/\sum_{j\in\mathcal{T}}s_j\)GShard 的 top-2
先 top-k 再 softmax\(g=\mathrm{softmax}(\mathrm{TopK}(H))\)Shazeer 2017、Mixtral
sigmoid,再在选中者间归一化\(s_i=\sigma(u^\top e_i)\),\(g_i = s_i/\sum_{j\in\mathcal{T}}s_j\)DeepSeek-V3

后两种「选中者之间归一化」在数学上是一回事吗?先 top-k 再 softmax 等价于对选中的 logits 做 softmax,即 \(g_i = e^{H_i}/\sum_{j\in\mathcal{T}}e^{H_j}\);先 softmax 再在选中者间归一化,\(e^{H_i}/Z\) 除以 \(\sum_{j\in\mathcal T} e^{H_j}/Z\),结果完全相同。区别只在不归一化的那一类:门控值之和小于 1,且受未选中专家分数的影响。

softmax 和 sigmoid 的本质区别是专家之间是否竞争。softmax 的分数和为 1,一个专家分数上升必然压低其他专家;sigmoid 对每个专家独立打分,\(s_i \in (0,1)\) 只取决于 \(u^\top e_i\)。DeepSeek-V3 从 V2 的 softmax 换成了 sigmoid,再在选中的分数之间归一化得到门控值;报告没有解释原因,也没有给出这一项的消融。从结构上看,sigmoid 至少有两点方便:专家数到 256 时,softmax 的分数平均只有 \(1/256\),彼此强耦合,而 sigmoid 让每个专家的分数有自己的尺度;后面那个只用于选择的偏置项(见「无辅助损失的负载均衡」)加在一个与其他专家解耦的分数上,也更容易控制。

还有一个容易混的概念:Expert Choice 路由(Zhou et al., 2022)反过来让每个专家挑 top-C 个 token,天然均衡;但一个 token 被谁选中取决于同一批里其他 token(包括序列中后面的 token),对自回归语言模型来说等于泄露了未来信息。这也是 DeepSeek 不采用 Expert Choice 的原因。

专家容量与丢 token

路由是数据相关的:同一批里,有的专家可能被几百个 token 选中,有的一个都没有。但加速器(尤其 TPU/XLA 这种静态形状的编译器)希望每个专家的输入是固定形状 \([C, h]\)。于是有了专家容量(expert capacity):

$$C = \left\lceil \mathrm{CF}\cdot\frac{T\cdot k}{E} \right\rceil$$

\(T k/E\) 是完全均衡时每个专家应收到的分配数(\(T\) 个 token,每个分给 \(k\) 个专家,共 \(Tk\) 个分配,平摊到 \(E\) 个专家),\(\mathrm{CF}\)(capacity factor)是留出的余量,比如 1.25 表示允许比平均多 25%。Switch Transformer(\(k=1\))和 GShard(\(k=2\))用的都是这个式子。

\(T=12\)、\(E=4\)、\(k=1\)、\(\mathrm{CF}=1\),容量 \(C=3\)。token 按顺序填入目标专家的槽位:专家 1 收到 5 个,后到的 2 个被丢弃(只走残差);专家 4 只收到 1 个,剩下 2 个槽是 padding,照样参与计算。

所以 CF 是一个直接的权衡:CF 大,丢得少,但计算和通信(固定形状的 buffer 要整块传)都变多;CF 小则反之。具体地,所有专家的槽位总数是 \(E\cdot C \approx \mathrm{CF}\cdot Tk\),即专家计算量和 all-to-all 的数据量都按 CF 倍放大。举个数:\(T=4096\)、\(E=64\)、\(k=8\)、\(\mathrm{CF}=1.25\),均衡负载是 \(4096\times8/64=512\),容量 \(C=640\);若最热门的专家收到 900 个分配,就要丢 260 个,而冷门专家可能只用了 300 个槽、白算 340 个。Switch Transformer 的经验是丢 token 的比例要压低(他们的实验里通常 <1%),而只要辅助损失系数足够,负载就足够均衡。

后来的工作逐渐走向不丢 token(dropless):MegaBlocks 把 MoE 计算重写成 block-sparse 矩阵乘,GPU 上处理变长的专家输入,「既不丢 token 也不浪费在 padding 上」;DeepSeek-V2 训练时用的是设备级丢 token:先算出每个设备的平均计算预算(相当于设备级 CF = 1.0),在超预算的设备上丢掉亲和度最低的 token,并保证约 10% 的训练序列的 token 永远不丢;DeepSeek-V3 则说由于负载均衡做得好,训练和推理都不丢 token。

还有一个细节:top-k 时丢谁?GShard 的算法先为所有 token 的第一选择占位,再处理第二选择,这样容量紧张时优先保住每个 token 的首选专家。下面的模拟器也按这个顺序(先所有 token 的第 1 选择,再第 2 选择……)。

负载均衡辅助损失:推导与梯度

不加约束地训练,路由器很容易坍缩(routing collapse):某几个专家早期碰巧分数高、被选得多、于是被训练得更好、于是分数更高……最后大部分专家几乎没有 token。坍缩有两重坏处:参数浪费,以及专家分布在多张卡上时,最忙的那张卡决定整步的时间。

从「想要什么」开始

设 \(f_i\) 是这批 \(T\) 个 token 中被分配给专家 \(i\) 的比例。完全均衡时 \(f_i = 1/E\)。一个自然的目标是最小化 \(\sum_i f_i^2\):在 \(\sum_i f_i = 1\) 的约束下,由柯西–施瓦茨不等式

$$\Big(\sum_i f_i\Big)^2 \le E\sum_i f_i^2 \;\Rightarrow\; \sum_i f_i^2 \ge \frac{1}{E},$$

等号当且仅当所有 \(f_i\) 相等。问题是 \(f_i\) 由 top-k 计数得来,对参数不可导。GShard 的做法(Switch 继承并简化)是把平方中的一个 \(f_i\) 换成可导的近似——路由器分给专家 \(i\) 的平均概率 \(P_i\):

$$P_i = \frac{1}{T}\sum_{t} s_{i,t},\qquad \mathcal{L}_{aux} = \alpha\cdot E\cdot \sum_{i=1}^{E} f_i\, P_i$$

为什么不干脆用 \(\sum_i P_i^2\),它本身就可导?因为 \(P\) 是「平均概率」,而真正决定负载的是 argmax/top-k 的计数。路由器完全可以让每个 token 的概率分布都接近均匀(\(P_i\approx 1/E\)),但在 top-k 时总是同一个专家以微弱优势胜出——\(\sum P_i^2\) 很小,负载却完全坍缩。这正是 Shazeer 2017 区分 importance(概率和)和 load(实际分到的样本数)的原因。\(f_iP_i\) 的写法把真实负载 \(f\) 作为「权重」,梯度只推 \(P\),两者兼顾。

这就是 Switch Transformer 的负载均衡损失(GShard 里写作 \(\frac1E\sum_e \frac{c_e}{S} m_e\),是同一个东西差一个常数)。乘 \(E\) 是为了让完全均衡时损失恒为 \(\alpha\),不随专家数变化:\(E\cdot\sum_i \frac1E\cdot\frac1E = 1\)。Switch 用 \(\alpha = 10^{-2}\),并说在 \(10^{-1}\) 到 \(10^{-5}\) 之间扫过,\(10^{-2}\) 既能很快均衡又不干扰主损失。

梯度往哪推

\(f\) 当作常数(它没有梯度),只有 \(P\) 传梯度。对 softmax 打分 \(s_{\cdot,t}=\mathrm{softmax}(H_{t,\cdot})\),利用 \(\partial s_i/\partial H_k = s_i(\delta_{ik}-s_k)\):

$$\frac{\partial}{\partial H_{t,k}}\sum_i f_i P_i = \frac{1}{T}\sum_i f_i\, s_{i,t}(\delta_{ik} - s_{k,t}) = \frac{1}{T}\, s_{k,t}\Big(f_k - \sum_i f_i s_{i,t}\Big)$$

读法:对每个 token,括号里是「专家 \(k\) 的负载」减去「这个 token 的分数加权的平均负载」。负载高于平均的专家,其 logit 被往下推;低于平均的被往上推,推的力度与该 token 给它的分数 \(s_{k,t}\) 成正比。这就是一个平滑版的「谁忙就压谁」。

举个小例子:\(E=4\),这一批负载 \(f=(0.55, 0.25, 0.10, 0.10)\),某个 token 的分数 \(s=(0.4, 0.3, 0.2, 0.1)\)。加权平均负载 \(\sum_i f_i s_i = 0.22+0.075+0.02+0.01=0.325\)。对四个 logit 的梯度(忽略 \(1/T\))分别是 \(0.4\times0.225=0.090\)、\(0.3\times(-0.075)=-0.0225\)、\(0.2\times(-0.225)=-0.045\)、\(0.1\times(-0.225)=-0.0225\)。梯度下降减去它们:专家 1 的 logit 被压低,其余三个被抬高,而且专家 3 比专家 4 抬得多——因为这个 token 本来就更偏向专家 3,把它「让」给专家 3 的代价最小。

DeepSeek-V3 用 sigmoid 打分、\(P_i\) 用归一化后的 \(s'_{i,t}=s_{i,t}/\sum_j s_{j,t}\)。同样推一下:\(\partial \log s'_i/\partial H_k = \delta_{ik}(1-s_i) - s'_k(1-s_k)\),于是

$$\frac{\partial}{\partial H_{t,k}}\sum_i f_i P_i = \frac{1}{T}\, s'_{k,t}\,(1-s_{k,t})\Big(f_k - \sum_i f_i s'_{i,t}\Big),$$

结构和 softmax 版本一样,只是多了 sigmoid 的饱和因子 \(1-s_{k,t}\):分数已经接近 1 的专家,logit 的梯度很小。下面的模拟器两种都按这两个式子实现。

注意副作用:这个梯度直接改的是 logits,也就改了门控值 \(g\),而 \(g\) 是乘在专家输出上的——均衡的压力和语言建模的目标在同一组参数上拉扯。\(\alpha\) 太大,模型质量下降;太小,均衡不住。这就是负载均衡与模型性能之间的两难(dilemma),也是下面无辅助损失方法要解决的问题。

各家的变体

Router z-loss 与数值稳定

ST-MoE(Zoph et al., 2022)发现,在最大规模上,即使按 Switch 的建议把路由器放在 float32 里计算,训练仍然会不稳定。他们提出 router z-loss:

$$\mathcal{L}_z = \frac{1}{T}\sum_{t=1}^{T}\Big(\log\sum_{j=1}^{E} e^{H_{t,j}}\Big)^2$$

它惩罚的是每个 token 路由 logits 的 log-sum-exp(softmax 的归一化常数的对数)的平方。梯度是

$$\frac{\partial \mathcal{L}_z}{\partial H_{t,k}} = \frac{2}{T}\,\mathrm{LSE}(H_{t,\cdot})\cdot \mathrm{softmax}(H_{t,\cdot})_k,$$

只要 LSE 为正,所有 logits 都被往下压,且分数越大压得越多——效果就是把 logits 的整体尺度控制在小范围内。为什么这有用?因为 bfloat16 只有 7 位尾数,数越大舍入误差越大,而指数函数会把小的输入误差放大。ST-MoE 给的例子:10 个 logit 等于 128、一个等于 128.5,在 bfloat16 里 128.5 可能被舍入成 128,softmax 输出就从 \(\frac{1}{1+10e^{-0.5}}\approx 0.142\) 变成 \(\frac{1}{11}\approx 0.091\)。他们也解释了为什么不直接裁剪 logits:裁剪发生在舍入之后,本身就是一种更大的不连续。

总损失写成 \(\mathcal{L} = \mathcal{L}_{CE} + c_B\mathcal{L}_B + c_z\mathcal{L}_z\),他们扫参后取 \(c_z = 0.001\),在他们的实验里稳定了全部 3 次运行且质量没有下降(而 Adafactor 的 update clipping 虽然也稳定,但质量掉得很厉害)。

细粒度专家与共享专家

前面的均衡讨论都默认「专家」是一个完整大小的 FFN。DeepSeekMoE(Dai et al., 2024)换了个角度:每个专家到底学得有多「专」?它提了两点改动,后来贯穿 DeepSeek-V2、V3。

细粒度切分(fine-grained expert segmentation)

如果专家数少,一个专家要接收各种各样的 token,被迫学很杂的知识。于是把每个专家的 FFN 中间维度切成 \(1/m\),专家数变成 \(mN\),同时激活数变成 \(mK\)。每个小专家有 \(3hf/m\) 个参数,激活 \(mK\) 个就是 \(3Khf\),和原来一样;总参数 \(mN\cdot 3hf/m = 3Nhf\) 也不变。变的是组合空间:一个 token 可以从更多种「专家组合」里挑出最适合自己的那一种,每个小专家因此可以只负责更窄的一类知识。例如\(N=16\)、top-2 只有 \(\binom{16}{2}=120\) 种组合;每个专家切成 4 份后 top-8,有 \(\binom{64}{8}=4{,}426{,}165{,}368\) 种。

共享专家隔离(shared expert isolation)

不同专家往往都需要一些通用知识(语法、常见模式),于是每个都学一份,造成冗余。DeepSeekMoE 单独拿出 \(K_s\) 个共享专家,每个 token 必经、不经路由器;为保持计算量不变,路由专家的激活数相应减少 \(K_s\)。V3 的形式:

$$h'_t = u_t + \sum_{i=1}^{N_s}\mathrm{FFN}^{(s)}_i(u_t) + \sum_{i=1}^{N_r} g_{i,t}\,\mathrm{FFN}^{(r)}_i(u_t)$$

类似的结构更早在 DeepSpeed-MoE(Rajbhandari 等人)里出现过,那里主要出于工程考虑;DeepSeekMoE 是从「让路由专家只学差异化的知识」这个角度引入的。

三种结构参数量与每 token 计算量都相同:(a) 16 个专家选 2;(b) 每个专家切 4 份,64 个选 8;(c) 再拿出 1 个(切分后的)专家作为共享专家,路由部分 63 选 7。深色为被激活的专家。

几个真实模型的配置:

模型共享路由专家每 token 激活专家中间维度总参 / 激活
Mixtral 8x7B0821433647B / 13B
DeepSeek-V2216061536236B / 21B
DeepSeek-V3125682048671B / 37B

Mixtral 每层都是 MoE;DeepSeek-V2 的第 1 层、V3 的前 3 层仍是稠密 FFN,其余层为 MoE。

无辅助损失的负载均衡

辅助损失的毛病在于:均衡的压力通过梯度作用在门控值上,和语言建模目标互相拉扯。DeepSeek 的 Wang et al.(2024)提出 Loss-Free Balancing,V3 把它作为主要的均衡手段。思路是:既然辅助损失的问题在于它通过梯度改动了门控值,那就不走梯度,直接给每个专家一个偏置 \(b_i\),只用于选择:

$$g'_{i,t} = \begin{cases} s_{i,t}, & s_{i,t} + b_i \in \mathrm{Topk}\big(\{s_{j,t}+b_j\}_{j},\,K\big) \\ 0, & \text{otherwise}\end{cases}$$

关键是:门控值仍然用原始的 \(s_{i,t}\),偏置只影响谁进 top-k。偏置的更新规则:每个 batch 训练完,统计每个专家分到的 token 数 \(c_i\) 及其平均 \(\bar c\),

$$e_i = \bar c - c_i,\qquad b_i \leftarrow b_i + u\cdot\mathrm{sign}(e_i)$$

超载的专家偏置减小、欠载的增大,每步固定走 \(u\)。Loss-Free 的实验对比了 \(u=0.0001\)(早期收敛慢)、\(0.001\)、\(0.01\)(后期偏置抖动、均衡变差),选 \(0.001\);也试过 \(b_i \leftarrow b_i + u\,e_i\) 和乘性偏置,均衡略好或相近,但困惑度没有更好,所以保留 sign 版本。V3 中把这个步长叫 bias update speed \(\gamma\),前 14.3T token 用 \(0.001\),最后 500B token 设为 0。

6 个专家、每步 2400 个随机 token、top-1(示意,步长 \(u=0.01\),比 V3 大得多,好让几步之内就看到效果)。上面是每个专家这一步收到的 token 数,虚线是平均值;下面是偏置 \(b_i\):超载的专家每步减 \(u\),欠载的加 \(u\),负载随之向平均靠拢。

可以把它看成一个简单的反馈控制器:目标是「每个专家的负载等于平均」,\(b_i\) 是控制量,sign 更新相当于固定步长的积分控制——负载持续偏高,偏置就持续下降,直到稳定在一个能抵消该专家「天然受欢迎程度」的值附近,然后在附近以 \(\pm u\) 抖动。也可以把 \(b_i\) 看成约束 \(c_i \le \bar c\) 的对偶变量(拉格朗日乘子),sign 更新就是对偶上升的一种粗糙版本。这个视角能解释上面调步长的实验:\(u\) 太小,收敛慢;\(u\) 太大,稳态抖动大。还有一点:偏置是加在分数上的,它的合适量级取决于分数的尺度。sigmoid 分数在 \((0,1)\) 上分布较开,0.001 量级的步长足够;如果是 256 个专家的 softmax,分数本身只有 \(10^{-3}\) 量级,同样的步长意义完全不同(在下面的模拟器里切到 softmax 也能看到偏置法收敛变慢)。

两个细节值得注意:

衡量均衡程度可以用 MaxVio:\(\mathrm{MaxVio} = \frac{\max_i \mathrm{Load}_i - \overline{\mathrm{Load}}}{\overline{\mathrm{Load}}}\),即最忙的专家比平均超出多少。实验里 1B / 3B 模型上 Loss-Free 的全局 MaxVio 为 0.04,辅助损失方法分别为 0.72 / 0.52,同时困惑度更低。

演示:路由器模拟器

这个路由器很小:\(T\) 个 token 的隐状态是随机向量,\(E\) 个专家各有一个随机质心,亲和度 logits 是两者的内积,再加上一个「专家受欢迎程度」偏斜项(拖 skew 让前面的专家天然更受欢迎,模拟坍缩的早期)。上半部分是这一批的路由结果:每个专家的负载柱、容量线和被丢掉的分配。下半部分可以选一种均衡方法跑若干「训练步」(每步换一批新 token):

token 和专家质心是随机生成的(示意);容量、丢弃数、\(\sum f_iP_i\)、z-loss、MaxVio、梯度和偏置更新都按上文的公式计算。演示里只跑几百步,远少于真实训练,所以 \(\gamma\) 默认取得比 V3 的 0.001 大。

路由器模拟器
打分:
容量:

均衡方法:

左:每步 batch 的 MaxVio(紫)与「门控值偏移」(橙,\(\frac1T\sum_t\sum_i |s_{i,t}(\theta)-s_{i,t}(0)|\),衡量均衡手段改了多少门控分数)。右:每个专家当前的 \(b_i\)(偏置法)或 \(\theta_i\)(辅助损失法)。

可以试试:把 skew 拉到 2 以上、选「不均衡」看 MaxVio 很高、丢弃很多;切到「无辅助损失偏置」跑 200 步,MaxVio 降下来而橙线一直是 0;切到「辅助损失」也能降下来,但橙线随之上升——它是靠改门控分数实现均衡的。再把 CF 调到 1.0 附近,即使平均均衡,随机波动也会造成丢弃;选 dropless 则不丢,但最忙专家的负载(决定这一步的时间)就是 MaxVio 所描述的那部分开销。

专家并行:dispatch 与 combine

专家多了以后,所有专家的参数放不进一张卡,而且前面算过,单卡上每个专家分到的 token 太少。两个问题有同一个解法:把专家分到不同的卡上,再把各卡的 token 汇集到专家所在的卡,这就是专家并行(Expert Parallelism, EP)。因为各专家的 FFN 完全独立,EP 比 TP 轻得多:不需要切矩阵乘,只需要把 token 的隐状态送到对的专家那里,算完再送回来。

设 EP 组有 \(P\) 张卡,每卡放 \(E/P\) 个专家。注意力等非 MoE 部分在这 \(P\) 张卡上是数据并行的:每张卡处理自己那份 token。于是一个 MoE 层的流程是:

  1. 路由:每张卡对本地 \(T\) 个 token 算路由,得到 \(T\cdot k\) 个 (token, 专家) 分配。
  2. permute:按目标专家(从而按目标卡)排序,同一目标卡的数据在内存里连续。
  3. dispatch(all-to-all):每张卡把发往卡 \(j\) 的那段发给卡 \(j\)。之后每张卡持有所有卡发来的、属于自己专家的 token。
  4. 专家计算:本地 \(E/P\) 个专家各自处理收到的变长批次(grouped GEMM)。
  5. combine(all-to-all):把结果按原路发回 token 所在的卡。
  6. unpermute + 加权求和:恢复原顺序,乘门控值,把 \(k\) 个专家的输出加起来,再加残差。

all-to-all 是这里唯一的新通信原语。\(P\) 张卡,每张卡把自己的数据切成 \(P\) 块,第 \(j\) 块发给卡 \(j\);同时从每张卡各收一块。把「卡 \(i\) 要发给卡 \(j\) 的那块」记作 \(X_{ij}\),排成一个 \(P\times P\) 的方阵:发之前卡 \(i\) 持有第 \(i\) 行,发之后卡 \(j\) 持有第 \(j\) 列——all-to-all 就是一次分块矩阵转置。用分片的记号写,就是把一个下标的分片轴挪到另一个下标上:\(A[I_X, J]\to A[I, J_X]\)。在 MoE 里,\(I\) 是 token、\(J\) 是专家:dispatch 之前按 token 分片(每卡一份 token),之后按专家分片(每卡一组专家)。

带容量的实现(GShard、Switch)写得更「张量化」:路由先产生一个形状为 \([\text{token}, E, C]\) 的 0/1 派发张量(token \(t\) 去专家 \(e\) 的第 \(c\) 个槽位),和输入做一次 einsum 得到 \([E, C, h]\) 的专家输入,然后用 all-to-all 把分片维从 token 换成专家;combine 是反过来乘一个带门控值的 \([\text{token}, E, C]\) 张量。每块的大小因此是固定的,这正是容量存在的工程原因。

4 张卡、每卡 2 个专家、top-1,点「下一步」看一个 MoE 层的数据怎么走:

EP 数据流(单步)

写成代码是什么样

在 PyTorch 里,一个最朴素的 EP 前向大致如下(省略共享专家和数值细节)。注意 dispatch 之前多了一次小的 all-to-all:每张卡发往每个目标卡的 token 数是动态的,接收方必须先知道每段有多长才能分配缓冲区。

# 每张卡:U [T, h],本卡持有专家 [r*E_loc, (r+1)*E_loc)
idx, w   = router(U)                       # [T, k], [T, k]
flat     = idx.flatten()                   # T*k 个分配
order    = flat.argsort()                  # 按专家(也就按目标卡)排序
X        = U[order // k]                   # permute: [T*k, h]
send_cnt = bincount(flat // E_loc, P)      # 发往每张卡的数量 [P]
recv_cnt = all_to_all(send_cnt)            # 先交换计数(小)
Xr       = all_to_all_v(X, send_cnt, recv_cnt)      # dispatch: [sum(recv_cnt), h]
Yr       = grouped_ffn(Xr, tokens_per_local_expert) # 本卡 E_loc 个专家
Y        = all_to_all_v(Yr, recv_cnt, send_cnt)     # combine: 原路返回 [T*k, h]
out      = zeros_like(U).index_add_(0, order // k, Y * w.flatten()[order, None])

反向时自动微分会把两次 all_to_all_v 反过来各做一次:combine 的反向是把输出梯度按 dispatch 的方向发出去,dispatch 的反向是把输入梯度按 combine 的方向发回来。所以每个 MoE 层每个训练步共 4 次大的 all-to-all,它们都在关键路径上:下一层的计算要等这一层的 combine 完成。这就是为什么 DeepSeek 要把一个 micro-batch 的 all-to-all 和另一个 micro-batch 的计算重叠(DualPipe 里一对前向/反向块,推理里两个 micro-batch 交叠),单个 micro-batch 内部很难藏。

还有一个容易忽略的点:同一张卡上,路由到本卡专家的那部分分配其实不用经过网络,all_to_all_v 会把它当成本地拷贝;而一个 token 选了同一张远端卡上的两个专家时,朴素实现会发两份,更好的实现只发一份、在接收端复制——V3 的「每个目标节点只过一次 IB」是同一思路在节点层面的版本。

通信量与节点受限路由

每层传多少字节

设每卡本地 \(T\) 个 token、隐维 \(h\)、每元素 \(b\) 字节。dispatch 时每张卡要发出 \(T\cdot k\) 份 \(h\) 维向量(一个 token 选了 \(k\) 个专家就要发 \(k\) 份,除非落在同一张卡上可以去重)。路由均匀时,目标在其他卡上的比例是 \((P-1)/P\):

$$V_{dispatch} \approx T\cdot k\cdot h\cdot b\cdot\frac{P-1}{P}\ \text{bytes / GPU},\qquad V_{combine}\ \text{同量级}$$

前向一次 dispatch 一次 combine,反向再各一次(梯度沿相反方向走),所以每个 MoE 层每步共 4 次 all-to-all。拿 DeepSeek-V3 的形状感受一下:\(h=7168\)、\(k=8\),每个 token 每层 dispatch 约 \(8\times7168 = 57{,}344\) 个数——这比 PP 在级边界上每 token 传一份 \(h\) 维向量多了一个量级,而且每一个 MoE 层都要传。

和计算比呢?专家计算每个分配 \(6\,h\,d_e\) FLOPs(SwiGLU,\(d_e\) 是专家中间维度)。在节点间带宽 \(BW\) 上传 \(k h b\) 字节需要 \(khb/BW\),计算 \(k\cdot 6hd_e/F\),比值

$$\frac{t_{comm}}{t_{comp}} \approx \frac{b\cdot F}{6\,d_e\cdot BW}\quad(\text{每份副本都跨节点时})$$

与 \(h\)、\(k\)、\(T\) 都无关,只看专家有多「细」(\(d_e\) 越小越吃亏)和算力/带宽比。细粒度专家(\(d_e=2048\))让这个比值变差,这就是 DeepSeek-V3 报告说跨节点 EP 让计算:通信接近 1:1 的原因。

放在环形网络(比如 TPU 的环面)上看,all-to-all 比同样数据量的 all-gather 便宜得多:all-gather 里每一份数据都要送到所有卡,绕环一整圈;all-to-all 里每一小块只去一个目的地,在双向环上平均只走 \(N/4\) 跳,总代价约是 all-gather 的 1/4。还要注意,这两次 all-to-all 只在 token 的分片轴和专家的分片轴是同一组卡时才出现,这正是下文「EP 与 DP 共用卡」的情形。

设备受限与节点受限路由

细粒度专家让 \(k\) 变大,一个 token 的 \(k\) 个专家可能散落在很多卡、很多节点上。DeepSeek-V2 的设备受限路由:对每个 token,先选出包含最高亲和度专家的 \(M\) 个设备,再只在这些设备的专家里做 top-k;他们发现 \(M\ge 3\) 时效果与不受限的 top-k 大致相当,V2 用 \(M=3\)。

DeepSeek-V3 换成节点受限路由:每个 token 最多发往 \(M=4\) 个节点,节点的选择依据是「该节点上专家的最高 \(K_r/M\) 个亲和度之和」。配合的通信实现是:

这样跨节点流量的上界从「每 token \(k\) 份」变成「每 token 至多 \(M\) 份」。下面的计算器把这两种情况都算出来。

演示:EP all-to-all 成本计算器

估算一个 MoE 层前向的 dispatch + combine 时间和专家计算时间。模型:路由均匀随机;「节点受限 + 每节点去重」时,每个 token 跨 IB 的份数取「\(k\) 个专家落在的不同远端节点数」的期望(并截断到 \(M\));NVLink 上按每份分配都转发一次计。时间取 IB 与 NVLink 中较慢者(假设二者重叠,如 V3 的做法),计算只算路由专家与共享专家的矩阵乘。结果只用来看量级。

EP all-to-all 成本计算器
预设:
dispatch 精度:
combine 固定 BF16

每节点 8 卡。「朴素」指不做节点限制、也不按节点去重(每份分配单独过 IB)。有效算力默认 800 TFLOPs,是一个假设的 FP8 矩阵乘可达算力;改它看比值怎么变。

默认参数下(V3 形状、64 路 EP、M=4、IB 50 GB/s)可以看到:朴素方案的 IB 时间约是专家计算的 3 倍;节点受限 + 去重把每个 token 的 IB 份数从 7 份压到 3.5 份,通信时间仍与计算同量级。这就是 DeepSeek-V3 说的「计算:通信约 1:1」,也是 DualPipe 要专门安排计算和通信重叠的原因。把 \(d_e\) 拉大(粗专家),比值迅速下降;切到 Mixtral 形状(单节点 8 路 EP)则完全没有 IB 流量。

EP 与 DP、TP、PP 的组合

只用 EP 有个问题:EP 只作用在 MoE 层,注意力等其他层没被切分。如果 \(P\) 张卡只是 EP 而在非 MoE 部分处理同一批 token,那就是重复计算。所以 EP 几乎总是和数据并行共用一组卡:非 MoE 部分这组卡是 DP(各自处理不同 token),MoE 部分同一组卡变成 EP。这也是 MoE 最早的分布式做法(Shazeer 等人,2017):普通层和路由器按常规数据并行,每个专家只保留一份,收到的是所有数据并行副本里路由到它的 token 的合集。这样顺带解决了开头的 shrinking batch:每个专家的有效 batch 从单卡的 \(kT/E\) 变成 \(kTP/E\),随 EP 规模增长。举个数:\(T=4096\)、\(k=8\)、\(E=256\),单卡上每个专家只有 128 行;64 路 EP 后是 8192 行,矩阵乘终于「喂饱」了。也正因为 EP 在处理输入的方式上和 DP 一样,有些框架干脆把 EP 当成 DP 的一个子维度来实现。

于是梯度同步也要分开看:

和其他维度:

负载不均在 EP 下的真实代价

单卡时负载不均只是浪费 padding;EP 下它直接变成等待。一层 MoE 的专家计算时间由最忙的那张卡决定,其他卡算完就空等下一次 all-to-all。若某张卡的负载是平均的 \(1+\mathrm{MaxVio}\) 倍,这一层专家计算的效率上限就是

$$\eta = \frac{\overline{\text{load}}}{\max \text{load}} = \frac{1}{1+\mathrm{MaxVio}}.$$

MaxVio = 0.5 意味着专家部分只有 67% 的效率;all-to-all 也一样,接收最多的卡决定传输完成的时间(这就是 V2 那个「通信均衡损失」要管接收端的原因)。另外,接收缓冲区的大小也由最坏情况决定:dropless 时一张卡可能收到远超平均的 token,显存必须按峰值预留,或者用动态分配。卡越多,batch 越大,统计上越容易均衡;这也是 V3 说 batch 级均衡的缺点「被大规模 EP + DP 带来的大 micro-batch 天然缓解」的意思。

低精度通信

dispatch 的数据量和精度成正比。DeepSeek-V3 把 MoE up-projection 之前的激活量化为 FP8 再 dispatch(与 up-projection 的 FP8 前向计算正好衔接,缩放因子取 2 的整数次幂),反向时 down-projection 之前的激活梯度也同样处理;而前向和反向的 combine 都保留 BF16,以保护训练精度的关键部分。所以 dispatch 每元素 1 字节、combine 2 字节——计算器里默认就是这个设置。

专家计算:padding、block-sparse 与 grouped GEMM

dispatch 之后,每张卡上有 \(E/P\) 个专家,每个专家拿到的 token 数不同(比如 37、0、210、96……)。怎么算?

  1. 逐专家循环:对每个专家调一次矩阵乘。简单,但专家多、每个专家的 token 少时,kernel 启动开销和小 GEMM 的低利用率很要命。
  2. padding 到容量:每个专家固定 \(C\) 个槽,堆成 \([E_{local}, C, h]\) 的批量矩阵乘(bmm)。形状静态、很快,但溢出要丢、空槽白算——这就是 CF 那套逻辑的由来。
  3. block-sparse(MegaBlocks):把「所有专家的 FFN」看成一个块对角稀疏矩阵乘,token 数是 block 大小的倍数即可,不丢 token,也几乎不 padding。端到端比基于 Tutel 的 MoE 训练最多快 40%。
  4. grouped GEMM:一次 kernel 调用里做一组形状各不相同的 GEMM(每个专家一组 \([n_e, h]\times[h, d_e]\)),由 kernel 内部按 tile 调度。只需要知道每个专家的 token 数和偏移,这正是 permute 之后天然得到的。现在主流框架的 MoE 层大多走这条路。

SwiGLU 专家的一次前向在 grouped GEMM 下是三次分组矩阵乘:gate 和 up(可以合并为一个 \([h, 2d_e]\) 的分组 GEMM),逐元素 SiLU·乘,再一次 down。反向同样是分组 GEMM,对输入和对权重各一组——和流水线那篇讲的 B/W 拆分对得上。

推理部署中的 EP(DeepSeek-V3)

推理部署把「均衡」和「每个专家的 batch」这两个问题放大得更明显:推理时 batch 由线上流量决定,没法靠调超参解决。DeepSeek-V3 的部署方案是个好例子:

报告还提到在探索「动态冗余」:每卡放更多专家(例如 16 个),每步只激活其中 9 个,在每层 all-to-all 之前即时计算全局最优的路由方案。这是一种在部署侧而不是训练侧解决均衡问题的思路。

总参数、激活参数与 FLOPs

训练的 FLOPs 只和激活参数有关:每 token 约 \(6N_{active}\)(前向 2、反向 4),再加注意力的序列相关项。显存则和总参数有关。用 V3 的形状算一下,看看 671B / 37B 是怎么来的:

Mixtral 同理:\(32\times 8\times 3\times4096\times14336\approx 45\text{B}\) 专家参数,激活 2 个约 11.3B,加注意力约 1.3B 和 embedding 约 0.26B,总 47B、激活 13B。

推理(decode)时 MoE 的账反过来更难看。设一步有 \(B\) 个 token,看一层专家的一个权重矩阵:\(E\) 个专家各一份 \(h\times d_e\),int8 下要读 \(E h d_e\) 字节;而每个 token 只用 \(k\) 个专家,计算量是 \(2kBhd_e\) FLOPs。想要算力受限,算术强度得超过硬件的算力/带宽比,以 TPU v5e(约 240 FLOPs/字节)为例:

$$\frac{2kBhd_e}{Ehd_e} > 240 \;\Rightarrow\; B > 120\cdot\frac{E}{k}$$

对 DeepSeek-V3 的 \(E=256, k=8\),要 \(B > 3840\) 个 token。直观地说:每一步都要把所有专家的权重读一遍,但每个专家只服务 \(k/E\) 的 token,所以 batch 要比稠密模型大 \(E/k\) 倍才能摊平读权重的成本。这就是 MoE 推理特别依赖大 batch 和大规模 EP 的原因。

其他稳定训练的技巧

小结

参考与延伸阅读

想系统了解大规模训练里的各种并行,推荐 Ultra-Scale Playbook;想从 TPU 和分片的角度看 MoE 的通信与推理成本,推荐 Scaling Book 的相关章节。