专家混合(MoE)
一个 MoE 层对每个 token 只激活 256 个专家中的 8 个。算力降了,显存没降,路由则变成了负载均衡问题。
- 路由器
- top-k 门控
- 专家分发
- token 分组
- 专家并行
为什么重要
问题
模型越大越好,可速度也随规模成比例下降,因为每个参数都参与每一个 token。专家混合打破的正是这种耦合。
思路是:把占全部参数约三分之二的 FFN 换成 N 份副本,然后让每个 token 只路由到其中 k 份。Mixtral 8×7B 存 470 亿参数,每个 token 用 130 亿。DeepSeek-V3 存 6710 亿,每个 token 用 370 亿。
对推理引擎来说,这件事喜忧参半:算力随激活参数量伸缩,而显存随总参数量伸缩。 下一个 token 可能路由到任何一个专家,所以它们全都必须常驻。
核心思路
解法
路由器是一层小小的线性层,它为每个 token 给所有专家打分。取 top k,只跑这几个,再按路由器 softmax 的权重把它们的输出组合起来。
def moe_layer(x, router_w, experts, top_k=2):
# x: [n_tokens, d_model]
logits = x @ router_w # [n_tokens, n_experts]
idx = np.argpartition(-logits, top_k, axis=-1)[:, :top_k]
weights = softmax(np.take_along_axis(logits, idx, -1), axis=-1)
out = np.zeros_like(x)
for e in range(len(experts)):
# GROUP: all tokens routed to expert e, processed in one matmul
rows, slot = np.where(idx == e)
if len(rows) == 0:
continue
y = experts[e](x[rows]) # one big matmul, not many small ones
out[rows] += weights[rows, slot][:, None] * y
return out分组才是 kernel 的核心
工作原理
工作原理
负载不均衡才是运维层面的问题
路由器天然并不均匀。放任不管,它会塌缩到少数几个热门专家上,MoE 训练加一个辅助负载均衡损失就是为了拦住这一点。即使训练良好的路由器也只是平均而言均衡;任何一个具体 batch 都可能偏斜。
在专家并行下这最要命,因为不同专家住在不同 GPU 上。这一步要等最忙的那张 GPU 算完才算结束,所以 3 倍的不均衡意味着你的大部分机器都在闲着。缓解手段是容量系数(限制每个专家的 token 数,溢出部分丢弃或改路由),以及在超大规模下复制热门专家。
为什么 MoE 是低并发下的收益
batch 为 1 时你读 k 个专家、跳过其余;稀疏性是真实的,decode 确实更快。batch 为 512 时,token 各自独立路由,几乎每个专家都会被某个人用到,于是你还是把整个模型读了一遍。
所以优势随并发上升而缩小,和投机解码一模一样。两者都是拿富余的显存容量去换稀缺的带宽,也都在 batch 大到足以打满机器之后停止盈利。
动手观察
动手试试
- 总参数量
- 362.9G
- 每 token 激活量
- 13.4G
- 稀疏度
- 27.0×
- 被丢弃的 token
- 131 (51.2%)
- 已处理的 token
- 超出容量 —— 丢弃或改路由
不均衡系数 17.25×。在专家并行下每个专家住在不同 GPU 上,所以一步的耗时取决于最忙的那个专家。3× 的不均衡意味着其余 GPU 有三分之二时间在空转。打开负载均衡损失,看看训练期的正则化在推理期值多少钱。
算力按稀疏度成比例下降,显存则一点没降:任何一个 token 都可能路由到任何一个专家,所以每个专家都得常驻。MoE 买到的是小模型的速度,付的是大模型的显存账单。
batch 为 1 时你只读 2 个专家,稀疏性是真实的。batch 到 512 时几乎每个专家都会被某个 token 用到,于是你还是把整个模型读了一遍,MoE 的优势随之蒸发。稀疏性是一种低并发下的收益。
在关闭均衡损失的情况下把路由偏斜推到 1.0:少数几个专家吃掉了大部分流量,容量线开始丢弃 token。丢弃是无声的,因为被丢弃的 token 只是跳过 FFN、沿残差连接直接穿过去。然后打开均衡损失,看直方图变平、丢弃消失。
亲手实现
实现
def grouped_dispatch(x, expert_ids, n_experts):
"""
Sort tokens by expert so each expert sees one contiguous slice.
This is the difference between a usable MoE kernel and a toy one.
"""
flat = expert_ids.reshape(-1) # [n_tokens * top_k]
order = np.argsort(flat, kind="stable") # group by expert
sorted_experts = flat[order]
counts = np.bincount(sorted_experts, minlength=n_experts)
offsets = np.concatenate([[0], np.cumsum(counts)])
return order, offsets # offsets[e]:offsets[e+1] is expert e's slice被丢弃的 token 不是错误,这才是危险之处
$ python code/s16_moe.py只需要 NumPy — 查看环境准备.
生产实践
在生产环境中
- vLLM / SGLang —— 融合 MoE kernel,使用分组 GEMM 和跨 GPU 的专家并行。
- DeepSeek-V3 —— 256 个路由专家加一个所有 token 都会用到的共享专家,并采用基于逐专家偏置项的、无辅助损失的均衡方案。
- quant.cpp —— CPU 上的 token 分组专家分发,配
TQ_NO_MLOCK让操作系统把冷专家的权重换出。在笔记本上,有意思的问题是哪些专家装得进内存,而不是它们住在哪张 GPU 上。 - 专家卸载 —— 热专家放 GPU、冷专家放主机内存,按需取回。只有当路由可预测到足以预取时才可行。
练习
- 1把有效稀疏度测成 batch 大小的函数:在多大的 batch 下,每步被触及的专家比例会超过 90%?
- 2实现按容量系数丢弃,并画出输出质量随容量系数的变化。找出丢弃从哪里开始变得要紧。
- 3模拟 8 张 GPU 上的专家并行,把步时按「各 GPU 的最大值」而不是平均值来测量。量化 2 倍不均衡损失掉的吞吐。
继续学习
接下来
MoE 把「模型装不进一张 GPU 该怎么办」这个问题逼到了台前。S17 会正面回答它:张量并行、流水线并行,以及决定你该选哪一种的通信成本。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
某个 MoE 每个 token 激活 64 个专家中的 2 个。什么随激活数伸缩,什么随总数伸缩?