稠密模型的算力开销与参数量严格挂钩:想让模型更聪明,就得让每一次前向都更贵。混合专家(Mixture of Experts, MoE) 打破了这个绑定——把前馈层拆成几十上百个专家,每次只激活其中一两个。于是参数量可以涨到万亿级,而单 token 的实际计算量只相当于一个中等规模模型。这是一笔用显存与通信换算力的交易。
MoE 的本质不是「更聪明的模型」,而是「更划算的模型」。它没有提升单次计算的表达能力,而是把参数容量与推理成本解耦。理解这一点,就能明白为什么 MoE 的所有工程难点都集中在通信与负载均衡上。
从稠密到稀疏
稠密 FFN 的算力瓶颈
Transformer 的参数量大头在前馈网络(FFN)。以 d_model=4096、d_ff=14336 为例,单个 FFN 层就有约 1.2 亿参数。24 层堆下来,FFN 占了整个模型七成以上的参数,而每个 token 都要走完全部 FFN——参数利用率极低,大部分权重对当前 token 毫无贡献。
稀疏激活的核心思想
MoE 用一组专家替代单个 FFN,门控网络为每个 token 挑选少数专家:
y = Σ_{i ∈ TopK(g(x))} g(x)_i · E_i(x)
关键数字对比(以 Mixtral 8x7B 为例):
| 指标 | 稠密 47B | Mixtral 8x7B(Top-2) |
|---|---|---|
| 总参数量 | 47B | 47B |
| 激活参数量 | 47B | 约 13B |
| 单 token FLOPs | 100% | 约 28% |
| 训练成本 | 高 | 低约 3 倍 |
总参数决定容量,激活参数决定成本。这就是 MoE 的全部价值主张。
专家的专业化是涌现的
一个常见的误解是「专家会自动分工成数学专家、语法专家」。实际观察表明,专家的分工往往按表层特征聚类——标点、语言、词性——而非高级语义。这并不妨碍效果,但它提醒我们:不要把 MoE 当成可解释的模块化系统来设计。
门控与路由机制
门控网络
门控本质上是一个小的线性层加 softmax:
import torch
import torch.nn as nn
import torch.nn.functional as F
class TopKGating(nn.Module):
def __init__(self, d_model, n_experts, top_k=2):
super().__init__()
self.top_k = top_k
self.n_experts = n_experts
self.gate = nn.Linear(d_model, n_experts, bias=False)
def forward(self, x):
# x: (B*T, D)
logits = self.gate(x) # (N, E)
scores = F.softmax(logits, dim=-1)
topk_val, topk_idx = scores.topk(self.top_k, dim=-1)
# 归一化,让 Top-K 权重和为 1
topk_val = topk_val / topk_val.sum(dim=-1, keepdim=True)
return topk_idx, topk_val
路由粒度的选择
| 粒度 | 说明 | 代表 |
|---|---|---|
| Token 级 | 每个 token 独立路由 | Switch Transformer、Mixtral |
| 专家级 | 整个序列共享 | 早期 MoE |
| 组级 | 专家分组,先选组再选专家 | DeepSeek-MoE |
Token 级路由最灵活,但会导致同一序列内 token 被送到不同专家,通信模式不规则。组级路由(Grouped Routing) 是折中:先把专家分成若干组,token 先选组再在组内选专家,能显著降低 All-to-All 的碎片化。
共享专家:DeepSeek 的改进
DeepSeek-MoE 提出共享专家 + 路由专家的混合结构:
y = E_shared(x) + Σ_{i ∈ TopK} g_i · E_i(x)
共享专家对所有 token 都激活,负责通用知识;路由专家负责特化。这样做的好处是减少了路由专家的知识冗余——传统 MoE 里每个专家都得学会通用语言能力,浪费容量。实测在同等激活参数下效果更好。
Softmax 的位置有讲究
- softmax 后再 Top-K:权重和为 1,但被选中的专家权重会被未选中专家的概率稀释。
- Top-K 后再 softmax:保证选中权重和为 1,梯度更干净。
绝大多数实现选后者。这个细节看起来微不足道,但用错会让训练初期路由极不稳定。
负载均衡
MoE 最大的工程噩梦:路由塌陷。门控很容易学会「总是选同一个专家」,因为那个专家训练得更充分、输出更好,形成正反馈。最终只有一两个专家被训练,其余全废——MoE 退化成了稠密模型,还白白多占了显存。
辅助负载均衡损失
经典解法是在主损失上加一项辅助损失,惩罚专家使用率的不均衡:
def load_balancing_loss(gate_logits, topk_idx, n_experts):
"""Switch Transformer 风格的辅助损失"""
# f_i: 分配给专家 i 的 token 比例
one_hot = F.one_hot(topk_idx, n_experts).float() # (N, K, E)
f = one_hot.sum(dim=(0, 1)) / (topk_idx.shape[0] * topk_idx.shape[1])
# P_i: 专家 i 的平均门控概率
P = F.softmax(gate_logits, dim=-1).mean(dim=0) # (E,)
return n_experts * (f * P).sum()
损失乘上一个系数(通常 0.01)加进总损失。f 与 P 的乘积形式保证了两者都均衡才最小,只压一个不够。
容量因子
每个专家能处理的 token 数是有上限的,上限由**容量因子(Capacity Factor)**决定:
capacity = ceil(capacity_factor × tokens_per_batch / n_experts)
| 容量因子 | 溢出率 | 效果 |
|---|---|---|
| 1.0 | 高 | 大量 token 被丢弃 |
| 1.25 | 中 | 常用默认值 |
| 2.0 | 低 | 显存浪费,但几乎不丢 token |
被丢弃的 token 直接跳过 MoE 层(残差直通)。这听起来很糟,但实测影响有限——因为丢的通常是「门控不确定」的 token。不过训练早期如果丢弃率超过 20%,就要提高容量因子或加强均衡损失。
无辅助损失的均衡:偏置调整
DeepSeek-V3 提出用动态偏置替代辅助损失:给每个专家维护一个偏置项 b_i,只用于路由决策,不参与权重计算:
def bias_update(bias, usage, gamma=0.001):
"""usage 过高则降低偏置,过低则提高,目标是均衡"""
target = usage.mean()
return bias + gamma * (target - usage)
这个方法的优势是不影响主损失——辅助损失本质上是在主任务上加了干扰项,会略微损害模型质量。偏置调整把均衡做成纯推理侧的决策,实测在同等均衡度下语言建模损失更低。
路由塌陷的早期信号
- 专家使用率方差:某专家占比超过
1.5 / n_experts就该警惕。 - 门控熵:熵持续下降说明路由在收敛到少数专家。
- 被丢弃 token 比例:持续高于 10%。
def routing_stats(topk_idx, n_experts):
counts = torch.bincount(topk_idx.flatten(), minlength=n_experts).float()
usage = counts / counts.sum()
entropy = -(usage * (usage + 1e-9).log()).sum()
return {
"usage": usage.tolist(),
"max_share": usage.max().item(),
"entropy": entropy.item(),
"ideal_share": 1.0 / n_experts,
}
训练并行策略
MoE 的并行比稠密模型复杂一个维度:除了数据并行、张量并行、流水线并行,还要加专家并行(Expert Parallelism, EP)。
专家并行
把不同的专家放到不同的 GPU 上。一次 MoE 层的前向过程:
- 路由:门控为每个 token 选出专家。
- All-to-All 分发:把 token 按目标专家发送到对应 GPU。
- 专家计算:各 GPU 用本地专家处理收到的 token。
- All-to-All 回收:把结果送回原 GPU。
import torch.distributed as dist
def expert_parallel_forward(x, topk_idx, topk_val, experts_per_rank):
rank = dist.get_rank()
world = dist.get_world_size()
# 1. 按目标 rank 分桶
target_rank = topk_idx // experts_per_rank
send_buckets = [x[target_rank == r] for r in range(world)]
# 2. All-to-All 交换 token
recv_buckets = [torch.empty_like(send_buckets[0]) for _ in range(world)]
dist.all_to_all(recv_buckets, send_buckets)
# 3. 本地专家计算
local_out = run_local_experts(recv_buckets)
# 4. 反向 All-to-All 回收
back_buckets = [torch.empty_like(local_out[0]) for _ in range(world)]
dist.all_to_all(back_buckets, local_out)
return torch.cat(back_buckets, dim=0)
通信量与拓扑
All-to-All 的通信量正比于 token 数 × d_model × 2。当 EP 跨节点时,通信会走慢速网络,成为瓶颈。优化手段:
| 手段 | 效果 |
|---|---|
| 组级路由 | 减少碎片,提高单次传输效率 |
| Token 去重/合并 | 同一专家收到多个 token 时批量发送 |
| 通信与计算重叠 | 用双缓冲隐藏延迟 |
| 高带宽互联(NVLink/RDMA) | 把 EP 限制在同节点内 |
经验法则:EP 尽量限制在单节点内(8 卡 NVLink),跨节点的部分用数据并行承担。这与 分布式训练 中张量并行的放置策略一致——都遵循「高通信量的并行维度放在快链路上」。
显存分布
MoE 的显存分布和稠密模型很不一样:
| 组件 | 分布方式 |
|---|---|
| 注意力层 | 每卡完整副本 |
| 专家 FFN | 按 EP 切分 |
| 门控 | 每卡完整副本 |
| 优化器状态 | 按 ZeRO 分片 |
这导致 MoE 的显存不均衡:EP 切分后每卡只存 n_experts / EP 个专家,但如果专家大小不一(比如 DeepSeek 的共享专家更大),需要精细的放置规划。
推理时的专家调度
显存是推理的主要约束
训练时显存可以靠 ZeRO 分摊,推理时权重必须常驻。8x7B 的 MoE 需要约 90GB 显存(BF16),单张 80GB 卡放不下,必须多卡。推理的专家调度核心问题是:每个 token 要经过跨卡通信才能找到它的专家。
批处理下的专家亲和性
推理时的优化重点是提高每个专家的批量大小。连续批处理(continuous batching)下,不同请求的 token 混在一个批次里,路由结果高度分散,导致每个专家只收到零星几个 token,GPU 利用率极低。
解法是按专家重排批次:先收集一个窗口内所有 token 的路由结果,按目标专家排序后再计算,让每个专家拿到连续的大批量:
def expert_sorted_batch(tokens, topk_idx):
flat_experts = topk_idx.flatten()
order = torch.argsort(flat_experts, stable=True)
sorted_tokens = tokens.repeat_interleave(topk_idx.shape[1], dim=0)[order]
sorted_experts = flat_experts[order]
return sorted_tokens, sorted_experts, order
专家卸载与缓存
当显存实在不够时,可以把冷门专家放到 CPU 内存或 NVMe,按需换入。代价是换入延迟,通常几十毫秒。只在超大规模 MoE(数千专家)且访问极度倾斜时才划算。
量化与 MoE 的配合
MoE 的量化有个特殊之处:共享专家对量化更敏感,因为它服务所有 token,误差会被全局放大。实践中对共享专家用较高精度(INT8),路由专家可以压到 INT4。这与 模型压缩 中「关键路径保精度」的原则一致。
架构演进与代表模型
MoE 不是新概念,但近几年的几项改进让它真正可用。
关键演进节点
| 模型 | 年份 | 关键改进 |
|---|---|---|
| Switch Transformer | 2021 | 简化到 Top-1 路由,证明稀疏可行 |
| GShard | 2020 | 引入容量因子与专家并行 |
| Mixtral 8x7B | 2023 | 开源验证 8 专家 Top-2 的实用性 |
| DeepSeek-MoE | 2024 | 共享专家 + 细粒度专家切分 |
| DeepSeek-V3 | 2024 | 无辅助损失的偏置均衡、FP8 训练 |
细粒度专家
早期 MoE 用「少而大」的专家(8 个专家,每个和完整 FFN 一样大)。DeepSeek 转向「多而小」:把 FFN 切成 m 份,专家数量变成 m × n,激活的专家数也成比例增加。
这样做的好处是组合空间爆炸:n 选 k 的组合数是 C(n,k),专家越多越细,模型能表达的组合越多,而激活参数量保持不变。代价是路由与通信的碎片化更严重,需要组级路由来压制。
MoE 与稠密模型的公平对比
比较 MoE 和稠密模型时,必须对齐激活参数量而非总参数量:
def moe_vs_dense_cost(n_experts, top_k, d_model, d_ff, n_layers):
dense_ffn = 2 * d_model * d_ff
moe_total = n_experts * dense_ffn
moe_active = top_k * dense_ffn + 2 * d_model * n_experts # 含门控
return {
"dense_equivalent_params": n_layers * dense_ffn,
"moe_total_params": n_layers * moe_total,
"moe_active_params": n_layers * moe_active,
"activation_ratio": moe_active / moe_total,
}
正确的结论是:在相同激活算力下,MoE 通常优于同等大小的稠密模型,因为它的总容量更大;但 MoE 需要更多显存,且在 batch 很小、路由极度分散的场景下,通信开销可能吃掉全部收益。
什么时候不该用 MoE
- 显存极度受限的边缘部署:MoE 的总参数必须全部驻留,稀疏激活省不了显存。
- 单请求低并发服务:没有批处理来摊薄通信,All-to-All 开销占比过高。
- 训练数据量小:专家需要足够数据才能分化,小数据下 MoE 容易塌陷。
- 需要极致低延迟:通信引入的抖动难以消除。
从零实现一个 MoE 层
把上面的部件拼起来:
class MoELayer(nn.Module):
def __init__(self, d_model, d_ff, n_experts=8, top_k=2, capacity_factor=1.25):
super().__init__()
self.n_experts = n_experts
self.top_k = top_k
self.capacity_factor = capacity_factor
self.gating = TopKGating(d_model, n_experts, top_k)
self.experts = nn.ModuleList([
nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(),
nn.Linear(d_ff, d_model))
for _ in range(n_experts)
])
def forward(self, x):
B, T, D = x.shape
flat = x.reshape(-1, D)
idx, val = self.gating(flat) # (N,K),(N,K)
out = torch.zeros_like(flat)
capacity = int(self.capacity_factor * flat.shape[0] / self.n_experts)
for e in range(self.n_experts):
# 找出路由到专家 e 的 token
mask = (idx == e)
token_pos, slot = mask.nonzero(as_tuple=True)
if token_pos.numel() == 0:
continue
if token_pos.numel() > capacity: # 容量溢出,丢弃
token_pos = token_pos[:capacity]
slot = slot[:capacity]
expert_in = flat[token_pos]
expert_out = self.experts[e](expert_in)
out.index_add_(0, token_pos, expert_out * val[token_pos, slot].unsqueeze(-1))
return out.reshape(B, T, D)
这段代码是教学版,真实实现会用 scatter/gather 与批量矩阵乘避免 Python 循环。但所有关键机制——路由、容量截断、加权求和——都在里面。
常见问题与排错
- 只有少数专家被使用:均衡损失系数太小,或初始化导致门控偏向。提高系数,检查门控权重初始化。
- 训练 loss 震荡:容量因子太小,大量 token 被丢弃。提高到 2.0 观察。
- All-to-All 超时:EP 跨节点且网络拥塞。检查是否把 EP 限制在单节点内。
- 推理吞吐远低于预期:批次路由分散,专家批量过小。启用专家重排。
- 显存 OOM 但参数量算得下:专家放置不均或激活值峰值过高。检查放置规划与重计算策略。
- 微调后路由漂移:微调数据分布与预训练差异大,路由被重塑。冻结门控或降低门控学习率。
- 量化后质量骤降:共享专家被过度量化。对共享专家单独保精度。
小结
MoE 用稀疏激活把参数量与算力解耦,让「更大」不再等于「更贵」。它的代价集中在三处:路由的稳定性(负载均衡与塌陷防治)、通信的开销(All-to-All 与专家并行拓扑)、调度的效率(推理时的专家批量与显存布局)。把这三处做好,MoE 就是当下性价比最高的大模型扩展路径。它与 大模型推理优化 的显存与批处理策略、分布式训练 的并行拓扑,以及 Transformer 架构 的基础组件层层嵌套,构成了一条完整的技术栈。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。