跳到正文
LLM 推理
S16解码加速·152

专家混合(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 的权重把它们的输出组合起来。

一个 MoE 层python
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 的核心

朴素实现会遍历 token、每个都跑一次极小的 matmul:几百次启动却几乎什么都不算,这在 GPU 上是灾难性的。真正的工作是按专家给 token 排序,让每个专家拿到一整批连续数据,跑分组 matmul,再把结果散射回去。quant.cpp 把这称为「路由 → 逆索引 → gather/计算/scatter」的三阶段流水线。

工作原理

工作原理

示意图路由、分发,以及不均衡问题
把 FFN 换成一个 ROUTER 加 N 个专家token[d_model]router线性层 → N 个 logitstop-k = 2softmax 权重专家 0专家 1专家 2专家 3专家 4专家 5专家 6专家 78 个里激活 2 个 · 另外 6 个常驻显存,本 token 用不到每个 MOE KERNEL 都要做的三阶段分发1. 路由token → 专家 id2. 按专家分组排序 token,建立偏移3. 每个专家一次矩阵乘然后再散回原位分组把 N 个小矩阵乘变成少数几个大矩阵乘。这笔交易每 token 计算量:↓ 约 3.6×(只跑 k 个专家)显存占用:不变(所有专家都得常驻)Mixtral 8×7B:存 47B,每 token 激活 13B。470 亿参数的显存,130 亿参数的速度;高稀疏 MoE 走得更远(DeepSeek-V3:671B 中激活 37B ≈ 18×)。负载不均才是真正的运维风险均值在专家并行下,一步耗时取决于最忙的那张 GPU。训练时加入负载均衡损失,正是为了把这张直方图压平。
左:MoE 买到了什么、又付出了什么。右:运维风险,一个热门专家就决定了所有 GPU 的节奏。

负载不均衡才是运维层面的问题

路由器天然并不均匀。放任不管,它会塌缩到少数几个热门专家上,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%)
专家负载 · 每个容量 5 个 token
容量上限
完全均衡
  • 已处理的 token
  • 超出容量 —— 丢弃或改路由

不均衡系数 17.25×。在专家并行下每个专家住在不同 GPU 上,所以一步的耗时取决于最忙的那个专家。3× 的不均衡意味着其余 GPU 有三分之二时间在空转。打开负载均衡损失,看看训练期的正则化在推理期值多少钱。

MoE 的这笔交易
常驻显存的参数362.9G
每 token 实际读取的参数13.4G

算力按稀疏度成比例下降,显存则一点没降:任何一个 token 都可能路由到任何一个专家,所以每个专家都得常驻。MoE 买到的是小模型的速度,付的是大模型的显存账单。

batch 大小改变一切
batch 1
batch 8
batch 64
batch 512

batch 为 1 时你只读 2 个专家,稀疏性是真实的。batch 到 512 时几乎每个专家都会被某个 token 用到,于是你还是把整个模型读了一遍,MoE 的优势随之蒸发。稀疏性是一种低并发下的收益。

在关闭均衡损失的情况下把路由偏斜推到 1.0:少数几个专家吃掉了大部分流量,容量线开始丢弃 token。丢弃是无声的,因为被丢弃的 token 只是跳过 FFN、沿残差连接直接穿过去。然后打开均衡损失,看直方图变平、丢弃消失。

亲手实现

实现

code/s16_moe.py(节选)python
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 不是错误,这才是危险之处

当某个专家超出容量时,溢出的 token 会完全跳过这个专家,沿残差连接直接穿过去。没有异常、没有日志,只是输出略差。如果你的 MoE 质量莫名其妙地低于参考实现,先给丢弃率加上监控。容量系数设得太低通常就是元凶。
在本地运行
为一个小型 MoE 实现路由、分组分发和结果组合,断言分组路径与朴素的逐 token 循环完全一致,并在不同 batch 大小和容量系数下测量负载不均衡、丢弃率和有效稀疏度。
$ python code/s16_moe.py
预期输出: 分组与朴素分发完全一致的断言(256 次极小 matmul 变成 16 次大的)、一张不均衡直方图,以及一次 batch 大小扫描:有效稀疏度到 batch 64 时已从 8× 塌到 1×。

只需要 NumPy — 查看环境准备.

生产实践

在生产环境中

  • vLLM / SGLang —— 融合 MoE kernel,使用分组 GEMM 和跨 GPU 的专家并行。
  • DeepSeek-V3 —— 256 个路由专家加一个所有 token 都会用到的共享专家,并采用基于逐专家偏置项的、无辅助损失的均衡方案。
  • quant.cpp —— CPU 上的 token 分组专家分发,配 TQ_NO_MLOCK 让操作系统把冷专家的权重换出。在笔记本上,有意思的问题是哪些专家装得进内存,而不是它们住在哪张 GPU 上。
  • 专家卸载 —— 热专家放 GPU、冷专家放主机内存,按需取回。只有当路由可预测到足以预取时才可行。

练习

  1. 1
    把有效稀疏度测成 batch 大小的函数:在多大的 batch 下,每步被触及的专家比例会超过 90%?
  2. 2
    实现按容量系数丢弃,并画出输出质量随容量系数的变化。找出丢弃从哪里开始变得要紧。
  3. 3
    模拟 8 张 GPU 上的专家并行,把步时按「各 GPU 的最大值」而不是平均值来测量。量化 2 倍不均衡损失掉的吞吐。

继续学习

接下来

MoE 把「模型装不进一张 GPU 该怎么办」这个问题逼到了台前。S17 会正面回答它:张量并行、流水线并行,以及决定你该选哪一种的通信成本。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

某个 MoE 每个 token 激活 64 个专家中的 2 个。什么随激活数伸缩,什么随总数伸缩?

得分 0/6