ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MoE原理与负载均衡:稀疏大模型训练、推理成本优化实践

MoE原理与负载均衡:稀疏大模型训练、推理成本优化实践

说实话,第一次听说某个模型用上“混合专家”(MoE)时,我的第一反应是:这不就是集成学习换了个名字吗?多个专家凑在一起投票,怎么可能反而省算力?后来把相关论文和实现逐行看完,才意识到这个直觉错得离谱。MoE的核心不是让多个模型合作,而是给同一个模型装上大量可替换的子模块,每次输入只激活其中一小部分——它属于条件计算,跟集成是两套完全不同的逻辑。

这篇文章我打算沿着“专家本质 → 路由机制 → 负载均衡”这条主线来写。前面讲清楚专家和门控网络到底在做什么,中间重点讲负载均衡为什么会成为MoE工程落地最绕不开的问题,最后给一个可以放进训练脚本跑起来的最小实现,以及我个人在训练时用来判断负载健康状况的几个指标。

适合两类人读:一类是刚接触大模型架构、想彻底搞懂MoE原理的开发者;另一类是已经在调MoE模型、正被负载不均衡或训练不稳定折磨的工程师。文章里没有堆术语,但也不会为了通俗牺牲准确性,该给公式给公式,该贴代码贴代码。

1. MoE到底解决了什么:稠密模型的“全量激活”困境

1.1 稠密模型为什么这么贵

注意力稍微拉回到Transformer结构上,你会发现一个套在头上的紧箍咒:标准的Transformer里,每一个token都必须完整经过Attention层和前馈层,没有例外。前馈层(FFN)通常在参数量上占大头,而且它对每个token做的是无差别计算,这是整个推理链路里最大的算力黑洞。

模型想要更强的表达能力,最直接的办法就是把FFN的隐层维度拉大、把层数加深。参数一多,每个进来的token都要同样多算一遍。这就是我们常说的“稠密激活”:所有参数对每个token一视同仁地付出计算量。换句话说,参数量和推理成本被死死绑定在一起,模型想记住更多知识就得喂更多参数,参数越多,每次推理的计算量也跟着线性涨。

对大模型部署来说,这几乎是预算上的噩梦。你辛辛苦苦训出一个70B模型,每个token前向都要从第一个参数算到最后一个参数,没有一点点偷懒的空间。

1.2 MoE不是集成,而是条件计算

很多人一开始把MoE理解成集成学习,这是最大的认知坑。集成学习的核心是多个模型各自对输入算一遍,然后把结果投票或者加权,总成本就是单个模型成本的N倍。你有8个模型做集成,推理成本就是8倍,没有任何技巧能省下来。

MoE的玩法完全不同。它在结构上仍然是一个模型,只不过这个模型内部准备了大量可替换的“计算单元”。输入进来后,门控网络先从这些单元里挑出少数几个让它们干活,其余单元原地不动、不参与计算。这叫做条件计算:不同输入走不同的计算路径,而整条路径只是完整参数空间中的一个子集。

用一个不太严谨但很好懂的类比:集成像遇到难题时找8个顾问各出一份方案再综合;MoE像公司内部设了很多项目组,任务进来后只叫两个最对口的组去处理,其他组不用动弹。前者算力是加法,后者是动态分配。

1.3 MoE收益的本质:参数量和计算量解绑

所以MoE被大规模采用的根本原因,是把“参数量”和“单次推理计算量”之间的绑定关系解开了。一个MoE模型可以有非常大的总参数量,但某个token真正激活的只是其中一小块。

假设一个模型把FFN替换成了8专家的MoE,top_k取2,那么每个token实际产生的计算量大约只相当于2个专家的工作量,却拥有8倍于普通前馈网络的容量储备。从部署角度看有两个明显收益:一是模型容量更大、能塞进更多知识;二是单token的推理算力成本没有跟随参数总量线性增长。

当然天下没有免费的午餐,代价就落在路由机制、通信开销、显存占用和负载均衡上。这也是为什么标题把“负载均衡”单独拎出来——它才是决定MoE能不能用得起来的工程命门。

2. “专家”到底是什么:MoE的核心部件拆解

2.1 专家不是学科分类,而是参数子空间

很多人对MoE的第一个直觉是:专家应该像语文老师、数学老师那样各管一摊吧?实际你去把训练好的MoE模型拆开看,会发现专家的分工根本不是按文本主题划分的。拿不同话题去测每个专家的激活程度,最后往往是高度重叠、分布散乱,同一个专家可能在数学题和情感对话里都被高概率激活。

那专家到底是什么?用我的理解来说,专家就是一组相对独立的参数子空间,擅长某一种神经表示变换,而不是某一个学科。Transformer里的FFN层本质上是对token的隐藏表示做非线性变换;不同的FFN参数矩阵,是在尝试建模不同类型的变换模式。MoE把原本属于一个大FFN的参数空间切分成多个独立子空间,让路由按输入特征动态挑选最合适的那个。

一个比较粗的类比:FFN是要求一个人掌握所有语境下的翻译方式,MoE则准备了多个翻译处理模块,有的擅长短句、有的擅长技术文档。但这个分工不是刻意指定的,而是在训练中自发涌现的默契——它是路有出来的自然结果,不是人设计出来的先验规则。

2.2 门控网络到底在学什么

门控网络(Router/Gating)在MoE里负责分发。它通常就是一个线性层,输入是token的隐藏表示,输出是一个与专家数量等长的logits向量,再经过softmax变成每个专家的选中概率。

这里要澄清一个常见的困惑:门控网络不是在学习“这个token属于哪个专家”的分类问题,它不产生one-hot标签,而是输出一个概率分布。以最常用的top-k路由为例,模型从概率最高的K个专家里挑出来,把其余专家置零,接着再把K个选中专家的概率重新归一化,最后用归一化权重去做专家输出的加权求和。

为什么是加权求和而不是硬选一个?如果每个token只走一个专家,路由错一次就完全没有拯救机会,梯度通过的路径也会变得非常局限。加权求和让多个专家同时参与输出,本质上是给路由决策留了一条软后路:第一专家选错了,第二专家还能补位。这也是top_k=2成为默认选择的重要原因。

2.3 为什么通常只替换FFN层

另一个值得注意的细节是:MoE在设计时不是把所有层都换成专家,通常只替换FFN层,Attention层保持稠密。原因是FFN占用了Transformer里绝大部分参数和单token计算量,对Attention做同样的替换性价比很低。

而且Attention是token之间交换信息的公共通道,每个token都需要和其他token交互,这一层很难被稀疏化处理。你无法只让一部分token做Attention而另一部分不做,那会把上下文依赖关系打断。所以MoE的“稀疏”是刻意、局部的:把计算开销最大的FFN部分做成条件分支,而公共交互层继续全量计算。这种“局部稀疏、整体稠密”的设计,才是MoE工程落地的真实面貌。

3. 路由最容易被忽略的三个细节:K值、噪声注入与容量因子

3.1 K值怎么选:稀疏度与表达力的权衡

top_k到底取几,直接决定稀疏程度和模型容量利用率的平衡。整理一个简单的对照:

K值计算成本容错性典型场景
K=1最低差,路由错一次就全错极端追求推理速度
K=2低较强,两个专家互补大多数MoE模型的默认值
K=全部等于稠密模型最强,但没有稀疏收益soft MoE,几乎只在特殊场景用

K=1是最极端的稀疏,每个token只用一个专家,计算成本最小,但专家间的协作完全不存在,模型容量利用率很容易受制于路由早期偏差。K=2是大多数方案的默认选择,计算量只多一倍,却给两个专家互补的容错空间。如果K等于专家总数,就退化成软路由,所有专家每个token都参与,MoE的算力优势基本消失。

还有一个容易被忽视的点:K值要和专家数量放在一起看。专家少的时候可以用大K,专家多的时候用小K,真正重要的是保证被激活的“专家组合”数量足够大,模型才能拥有条件组合的表达能力。

3.2 训练时为什么要往logits里注入噪声

很多人理解路由就是softmax选top_k,但真训练时直接这么干很容易出问题。训练早期的门控网络非常脆弱,一旦对某个专家产生早期偏好,分配就会被快速锁死在一条歪路上。经典的解决方案是在训练阶段往logits里注入噪声,让选择过程带一点随机探索。

这个做法本质上是给路由参数加温度,逼着门控在前几个epoch里不要过早收敛,多去尝试不同的专家组合。推理阶段噪声关闭,让选择变成确定性过程。你可能觉得这不就是dropout吗?其实不是。dropout随机丢弃的是神经元,而这里解决的是专家选择的探索问题,两者作用对象完全不同。路由噪声调整的是门控的决策熵,防止它在信息不足的情况下做太自信的决定。

3.3 容量因子:给每个专家设一道工位上限

容量因子(capacity factor)是MoE里最容易被新手忽略的参数,它决定了一个专家在单次批次中最多能处理多少个token。计算公式通常是:先算出如果路由完全均匀时每个专家该分到多少token,再乘上容量因子得到上限。

因子取1,表示完全没有冗余,路由稍微偏一点就会挤爆;实际训练通常设在1.0到1.25之间。超过容量上限的token怎么办?两种常见做法:直接丢弃,或者走残差旁路绕过MoE层。训练时丢弃token是信息损失,所以容量因子不能设太小;推理时宁可多算也不丢token,通常会临时提高容量。

容量因子是负载均衡的“硬约束”,它和辅助均衡损失的区别在于:辅助损失是劝门控别偏心,容量因子是直接物理限制每个专家最多能接多少活。两者配合,才能把负载真正按住。

4. 负载均衡问题是怎么发生的:从正反馈到专家崩溃

4.1 完整链条:偶然优势如何滚成雪球

要理解负载均衡,得先弄明白不均衡是怎么来的。它不是某一次初始化的偶发bug,而是MoE自带的内在倾向。刚开始训练时,门控接近均匀,但由于随机波动,总会有一两个专家在某些batch里收到稍多一点的数据。

这一丝偶然优势会滚雪球:专家收到的token多,对应梯度更新就多,它的参数就更适配当前训练分布;参数一适配,门控分配给它的token就更多。于是“赢家通吃”的循环形成了。这个过程在文献里通常被称为路由崩溃或专家崩溃。

它不像程序报错那么明显——训练loss可能还在降,但你把专家激活分布拉出来看,会发现一群人快累死了,另一群人每天在摸鱼。还有一种更隐蔽的情况:训练初期有均衡损失压着,直方图看起来挺平坦,一旦辅助损失权重设小,或者训练后期噪声衰减,崩溃会在某个阶段突然冒出来。所以不均衡问题不是配好参数就一劳永逸,它需要全程监控。

4.2 专家崩溃的实际代价

很多人觉得专家崩溃无非是浪费几个专家,顶多训练慢一点。实际上代价比想象中大得多。

第一是参数量虚增。设计目标是每个专家贡献一些容量,如果大部分专家长期闲置,模型的有效容量就远低于设计值,相当于你显存里白养了一堆人。第二是训练不稳定。热门专家承接过量梯度和样本方差,优化器要照顾这些大梯度,冷门专家的小更新又被压在下面,梯度分布严重失衡。第三是分布式热点。专家并行时,token会大量路由到某几个设备上,闲置的设备空转,忙碌的设备在等通信,整个集群利用率往下掉。

另外还有一个隐性影响:如果长期只有少数专家活跃,那么即使你设了top_k=2,最后两个专家常常是同一批幸存者,MoE组合能力实际退化成单专家表达能力,整个设计初衷就失效了。

4.3 为什么不能指望模型自己学会均衡

“门控网络也是可学习的,它难道不会学到让loss最小的分配吗?”这个问题我被人问过很多次。答案是:它会学,但学到的往往是集中分配而不是均匀分配。

均匀分配本就不在主loss的优化目标里。对主loss而言,把大部分token塞给少数几个强专家,往往能更快压到不错的效果,这正是梯度下降喜欢的路径。也就是说,如果不加约束,模型自然涌现的均衡状态是“能者多劳”,而不是“人人有活干”。

工程上绕不开的结论就是:必须人为干预。要么用辅助损失,要么用容量硬约束,要么在路由信号里加负载反馈。把均衡这件事从隐含项变成显式指标,才是MoE能用得稳的前提。

5. 解决负载均衡的核心手段:辅助损失、硬约束与新路由策略

5.1 辅助平衡损失:最经典的软引导

最常见的软办法是加辅助均衡损失。思想很朴素:统计一个batch内每个专家实际接收的token比例f_i,再统计门控对每个专家输出的平均概率P_i,然后把每个专家的f_i乘P_i累加起来,乘上专家数量作为惩罚项。

这里有两个细节要理解。为什么把f_i和P_i乘在一起?因为单独约束任何一个都不够——你可能嘴上(概率)说得很均匀,但实际分配歪到一边;也可能实际分得匀,但门控对专家毫无区分度。两者相乘,同时惩罚了分配不均和评分不均。为什么乘专家数量?为了让这一项的量级和token数、专家数解耦,落到一个稳定的尺度。

实现时有个特别容易踩的坑:f_i在反向传播时要用detach截断,让梯度只通过P_i回流到门控,不要顺着token统计量再绕回门控一次。权重系数通常取0.001到0.01这个量级,太小约束不住,太大会抢主loss的位置,导致模型整体效果下降。

5.2 容量因子与溢出token的处理

辅助损失是软约束,真到了约束不住的时候需要硬约束兜底,这就是容量因子。它的作用非常直接:专家接单超过上限后,后续token要么被丢弃,要么走残差旁路。

丢token对模型质量伤害明显,尤其在训练集分布比较歪的场景。走残差旁路至少让信息还能继续流下去,代价是该token这一层没得到专家精细处理,表达能力会打折扣。还有第三种做法是把溢出token重新分配给次优专家,多花一点路由和通信开销,但保住了信息。

实操上我强烈建议把溢出token当成健康指标看待:训练时被drop或旁路的token比例如果持续高过5%~10%,说明容量太小或者负载已经歪得很严重。这时候与其硬调容量因子,不如先回头看看辅助均衡损失是不是没在工作。

5.3 新一代思路:绕开辅助损失做均衡

辅助损失好用,但它本质上是和主loss在抢优化空间。所以近一两年有不少工作想绕开它,其中两类最值得关注。

第一类是负载反馈偏置。给路由logits里额外加一个可学习的偏置,训练过程中定期统计每个专家负载,负载高的降低偏置、负载低的抬高偏置。这样均衡信息直接被写进路由信号,不需要额外的辅助损失项。好处是主loss和均衡信号互不干扰,坏处是它多了一层反馈机制,实现起来和调参更讲究。

第二类是设备限制路由和路由分组。token不能任意选专家,只能从某个设备组或某个分组里选,再由组内路由决定具体专家。这种结构性约束天然把路由压力分散到多组,还顺带减少了跨设备通信。

如果你刚开始做MoE,我建议先用“辅助损失+容量因子”这个经典组合,验证稳定后再去探索无辅助损失的方案,别第一个版本就把难度拉满。

6. 从原理到代码:手写一个最小MoE层会遇到哪些坑

6.1 一个可直接实验的PyTorch最小实现

下面这个版本我把MoE层压缩到只剩核心逻辑,可以直接塞进Transformer风格模型的FFN位置做实验。它包含门控、top_k选择、专家前馈、简单容量限制和辅助均衡损失,能跑通、能观察到负载变化。

import torch import torch.nn as nn import torch.nn.functional as F class MoEFFN(nn.Module): def __init__(self, d_model, d_ff=512, num_experts=8, top_k=2, capacity_factor=1.25, aux_loss_weight=0.01): super().__init__() self.num_experts = num_experts self.top_k = top_k self.capacity_factor = capacity_factor self.aux_loss_weight = aux_loss_weight self.gate = nn.Linear(d_model, num_experts, bias=False) nn.init.normal_(self.gate.weight, mean=0.0, std=0.02) self.experts = nn.ModuleList([ nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(0.1), nn.Linear(d_ff, d_model), ) for _ in range(num_experts) ]) def forward(self, x): # x: [batch, seq, d_model] bsz, seq_len, d_model = x.shape x = x.reshape(-1, d_model) n_tokens = x.size(0) logits = self.gate(x) # 在float32下算softmax,避免混合精度带来的路由误差 probs = F.softmax(logits.float(), dim=-1) topk_probs, topk_inds = torch.topk(probs, self.top_k, dim=-1) topk_probs = topk_probs / topk_probs.sum(dim=-1, keepdim=True) capacity = max(1, int(round(n_tokens / self.num_experts * self.capacity_factor))) out = torch.zeros_like(x) aux = 0.0 for eid in range(self.num_experts): mask = (topk_inds == eid) # [N, top_k] # 每个token分给该专家的权重:选中则取归一化概率,未选中则为0 expert_weights = topk_probs.masked_fill(~mask, 0.0).sum(dim=-1) # 辅助损失:f_i要detach,梯度只通过P_i回流到门控 aux += expert_weights.mean().detach() * probs[:, eid].mean() if expert_weights.sum() > 0: # 简单容量限制:保留权重最高的前capacity个token sorted_idx = torch.argsort(expert_weights, descending=True) kept_idx = sorted_idx[:capacity] assigned = torch.zeros_like(expert_weights) assigned[kept_idx] = expert_weights[kept_idx] out += self.experts[eid](x) * assigned.unsqueeze(-1) aux_loss = self.aux_loss_weight * self.num_experts * aux return out.view(bsz, seq_len, d_model), aux_loss

需要强调一句:真实训练框架不会让每个专家对全部token做前向,而是先做路由,再把每个专家接到的token聚合成batch后统一矩阵乘。上面这个写法是为了可读性,只能当逻辑验证用,不要拿它的复杂度去和框架版本对比。另外,完全被容量丢弃的token,在真实模型里要走残差旁路兜底,这个最小实现没做那一层,接入正式Transformer时要自己补上。

6.2 实现中容易翻车的四个细节

我每次检查别人的MoE实现,都会格外留意四个点。

第一,门控线性层不要加bias,或者至少保证初始化时bias为0。加一个随机初始化的bias很容易在训练早期引入偏心,让某个专家天然获得优势。第二,softmax务必在float32下计算,混合精度训练里半精度logits带来的路由误差会被后续加权放大,直接损害训练稳定性。第三,辅助损失里的f_i要用detach,忘记截断是新手最容易犯的bug,梯度路径一旦绕路,路由就会被带偏。第四,专家内部的dropout放在激活之后、输出层之前;不要对路由概率做dropout,否则会打乱top_k矩阵的稀疏结构。

另外,如果你的模型里有多个MoE层,每层都会算出一个辅助损失,最终loss应该是所有层辅助损失之和。注意各层的负载状况是可以独立变化的,监控时也要分层看,不能只看一个平均数字。

6.3 训练MoE时的三个核心观测指标

我现在训练MoE模型时,最在意的不是loss曲线本身,而是下面这三个东西:

观测指标计算方式健康范围异常处理
专家使用率直方图每N步统计每个专家接收token次数占总token比例无专家长期为0,分布不过分倾斜倾斜明显时先查辅助损失权重和gate初始化
辅助损失数值随训练步数记录初期快速下降后趋稳,数值相对主loss不明显偏大持续高说明软引导失效,调整权重或检查容量因子
溢出token占比每个batch溢出或旁路的token数占总token数比例训练期控制在5%~10%以内持续超标就增大容量因子,回头查均衡损失

loss曲线只告诉你整体在收敛,这三个指标才告诉你MoE的资产有没有真的被用起来。我见过不止一次loss非常漂亮、但专家利用率一塌糊涂的情况,这种模型最后测试时表现会远低于预期,因为它的有效容量比设计值小得多。

7. MoE正在往哪里走:新趋势与仍然存在的瓶颈

7.1 细粒度专家与共享专家

早期MoE的典型特征是专家数量不多、每个专家很大,靠辅助损失维持负载均衡。最近几年的演进方向反过来:专家数量做得更大,每个专家维度变小,同时专门设置几个固定激活的共享专家。

共享专家兜住通用知识,路由专家专攻细粒度差异;专家粒度变小后,同一个token会涉及更多专家组合,组合空间变大,这对容量利用和负载分散都有好处。还有一个附带优点:细粒度专家偶尔失活时影响被摊薄了,不像大专家那样一躺倒整个层就少了一大块容量。

这个方向让我觉得MoE正在从“粗放地挂一堆大模块”走向“精细化分工”,整个架构的效率和鲁棒性都在提升。

7.2 路由粒度与通信结构的优化

另一类变化集中在路由粒度上。token级路由精度高,但代价是跨设备通信的碎片化:一批token里可能每个token都跑向不同设备,通信开销爆炸。现在很多方案开始做block级路由或设备限制路由,一段连续的输入强制送到同一个设备组,先决定设备再决定专家。

这牺牲了一点路由精度,却让通信包变得整齐,吞吐明显提升。做工程的人应该能感觉到,这是典型的容量利用率和系统效率之间的trade-off。同样的趋势在推理侧也出现:许多推理框架会把发往同一专家的token攒批后再做矩阵乘,本质上都是在对抗路由带来的访存随机性。

7.3 我目前最看重的几个实践判断

接触MoE很长时间,我自己的判断可以归纳成几句。

在纯训练侧,MoE确实是低成本扩展模型能力的实用方式,但负载均衡一定要当成一等公民来设计,不能指望它自己长出来。在推理侧,显存和访存才是真正的瓶颈——MoE的稀疏性帮你省了算力,却把所有专家参数都压在显存里,部署前一定要盘一遍专家实际的利用率,毫不客气地裁掉那些常年摸鱼的专家。

最后一条建议很朴素:如果你正打算在自己的模型里试MoE,先从最朴素的版本开始,固定top_k=2,加一个经典的辅助均衡损失,容量因子设1.25。跑通之后再去试各种新的路由方法,比如负载反馈偏置、设备限制路由、细粒度专家拆分。先把基本盘守住,再谈花样。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进