DeepSeek V4 源码剖析
第7章 Gate 之变:sqrtsoftplus、noaux_tc 与 routed_scaling
"scoring_func": "sqrtsoftplus"、"topk_method": "noaux_tc"、"routed_scaling_factor": 2.5—— DeepSeek-V4-Proconfig.jsonGate 决定每个 token 去哪 6 个专家。整个类不到 40 行,但每一行都对应 MoE 训练里一个具体的坑。
7.1 引子:384 个专家面前 Gate 的工程问题
V4 一层 MoE 有 384 个 routed expert + 1 个 shared expert。每个 token 必须选 6 个 routed expert 激活——选哪 6 个?这就是 Gate 要解决的核心问题。
把这个问题展开成一组工程约束:
- 正确性:选的 6 个专家应该是"对当前 token 最相关的 6 个"——否则模型表达力损失
- 均衡性:所有 384 个专家的"使用频率"必须接近——否则极少数专家承担大部分计算,多数专家闲置
- 稳定性:训练不能让"被选少的专家"陷入"越选越少"的死循环(路由坍塌)
- 效率:Gate 本身的计算成本必须小——否则 384 倍的"打分"就把 MoE 节省的 FLOPs 吃回去
- 灵活性:Gate 不能依赖特定 batch 大小或硬件——同一份权重要能在不同部署形态下工作
V4 的 Gate 在每个约束上都做了具体设计:sqrtsoftplus(正确性 + 稳定性)、noaux_tc(均衡性 + 不污染主 loss)、bias term(动态均衡)、routed_scaling_factor(输出幅度校准)。
flowchart TB
Token["token 的 hidden state"] --> Gate{Gate}
Gate -->|sqrtsoftplus 评分| Scores["384 个专家分数"]
Scores -->|加 bias| BiasedScores["384 个 biased 分数"]
BiasedScores -->|topk=6| Top6Idx["选 6 个 expert 的索引"]
Scores -.按索引取原始分数(不含 bias).-> Weights["6 个 weight"]
Top6Idx --> Out["实际激活的 6 个 expert"]
Weights -->|归一化后缩放 routed_scaling=2.5| FinalWeights["最终 routing weights"]
本章拆 Gate 的每一个细节。
7.2 Gate 类的源码全景
V4 的 Gate 类(inference/model.py:546;docstring 只留首行、tid2eid 一行拆成多行排版,并省略了第 573 行的注释,逻辑与源码一致):
class Gate(nn.Module):
"""MoE gating: computes expert routing scores and selects top-k experts."""
def __init__(self, layer_id: int, args: ModelArgs):
super().__init__()
self.dim = args.dim
self.topk = args.n_activated_experts
self.score_func = args.score_func
self.route_scale = args.route_scale
self.hash = layer_id < args.n_hash_layers
self.weight = nn.Parameter(torch.empty(args.n_routed_experts, args.dim))
if self.hash:
self.tid2eid = nn.Parameter(
torch.empty(args.vocab_size, args.n_activated_experts, dtype=torch.int32),
requires_grad=False
)
self.bias = None
else:
self.bias = nn.Parameter(torch.empty(args.n_routed_experts, dtype=torch.float32))
def forward(self, x: torch.Tensor, input_ids: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor]:
scores = linear(x.float(), self.weight.float())
if self.score_func == "softmax":
scores = scores.softmax(dim=-1)
elif self.score_func == "sigmoid":
scores = scores.sigmoid()
else:
scores = F.softplus(scores).sqrt()
original_scores = scores
if self.bias is not None:
scores = scores + self.bias
if self.hash:
indices = self.tid2eid[input_ids]
else:
indices = scores.topk(self.topk, dim=-1)[1]
weights = original_scores.gather(1, indices)
if self.score_func != "softmax":
weights /= weights.sum(dim=-1, keepdim=True)
weights *= self.route_scale
return weights, indices
把这个类拆成 4 块:
- 静态参数:
weight: [n_routed_experts, dim]—— 路由打分的投影矩阵 - 路由策略:
hash标志决定走 hash 路由还是学习路由 - bias term:仅
hash=False的层才有,给 noaux_tc 用 - score function:通过
score_func字符串切换(softmax / sigmoid / sqrtsoftplus)
forward 的 6 步:
linear(x, weight)算每个 expert 的原始打分- 根据 score_func 套激活函数(V4 用 sqrtsoftplus)
- 加 bias(如果不是 hash 层)
- topk 选索引(hash 层走 tid2eid 表)
- gather 出对应位置的"原始分数"作为路由权重
- 归一化 + 乘 route_scale
7.3 Gate forward 的 6 步骤数据流
把 §7.2 中 Gate.forward 的 6 步骤画成数据流图:
flowchart TB
X["x: [B*S, 7168] BF16"] --> Step1["1. linear(x.float, weight.float)<br/>→ scores: [B*S, 384] FP32"]
Step1 --> Step2{"2. score_func"}
Step2 -->|sqrtsoftplus| SqSp["scores = softplus(x).sqrt()"]
Step2 -.softmax / sigmoid.-> SqSp
SqSp --> Cache["original_scores = scores 缓存"]
Cache --> Step3["3. + bias (若非 hash 层)"]
Step3 --> Step4{"4. 是 hash 层?"}
Step4 -->|是| Lookup["indices = tid2eid[input_ids]"]
Step4 -->|否| Topk["indices = scores.topk(6)"]
Lookup --> Step5["5. weights = original_scores.gather(indices)"]
Topk --> Step5
Step5 --> Step6["6. weights /= sum(非 softmax 时)<br/>weights *= route_scale (2.5)"]
Step6 --> Out["返回 (weights, indices)"]
注意 bias 仅参与 topk 选取(步骤 3 后只影响步骤 4);weights 取自 original_scores(步骤 5 用的是步骤 3 之前 cache 的)——这是 noaux_tc"分数双轨制"的代码体现。
7.4 sqrtsoftplus:V4 的新 score function
V4 的 score function 是 F.softplus(scores).sqrt()——
为什么用这个看起来奇怪的函数?把它与 softmax / sigmoid 对比:
| 函数 | 输出范围 | 概率归一化 | 数值稳定性 | 单调性 | 训练梯度 |
|---|---|---|---|---|---|
| softmax | [0, 1] | 是 | 长尾下溢 | 单调 | 良好 |
| sigmoid | [0, 1] | 否 | 中等 | 单调 | 良好 |
| sqrtsoftplus | [0, ∞) | 否 | 极稳定 | 单调 | 更平滑 |
sqrtsoftplus 的关键优势:
先说一件容易想错的事:单看排序,换哪个函数都一样。softmax、sigmoid、sqrtsoftplus 全是单调递增的,不计 bias 时 scores.topk(6) 选出来的 6 个 expert 与直接对 logits 取 topk 完全相同。所以 score function 的意义不在"选谁",而在另外两处——
它决定 bias 加上去之后排序怎么变。
scores = scores + self.bias 是加在激活之后的。同样一个 bias[e] = -0.1:
- sigmoid 输出挤在 [0, 1],候选分数彼此贴得更近,−0.1 的偏置足以掀翻好几个名次——bias 的"权威"过大,容易压过模型真实的相关性判断
- sqrtsoftplus 输出在 ,强相关 expert 可以到 3、5 甚至更高,−0.1 只在"几个 expert 本来就旗鼓相当"时才起作用——bias 只在该起作用的地方起作用
这正是 noaux_tc 想要的行为:负载均衡只做微调,不做改判。
它决定 6 个 routing weight 的相对大小。
weights = original_scores.gather(...) 之后要除以自身之和。sigmoid 下每个值都封顶在 1,入选的 6 个分数一旦都接近饱和,归一化后就趋于均匀的 1/6,6 个专家的输出几乎等权相加;sqrtsoftplus 无上界,强相关的那个分数可以远高于其余几个(比如 logit 为 25 时分数为 5),归一化后拿到 0.5 以上的权重——模型有能力表达"这 6 个里主要靠第一个"。
梯度形状:softplus 的导数是 sigmoid(全域非零、平滑),再复合 sqrt 得到 ——峰值在 处(约 0.32),两侧都衰减,但比 sigmoid 慢得多: 时 sqrtsoftplus 的梯度约 0.16、sigmoid 已不到 1e-4, 时两者约为 0.041 对 0.0066。也就是说,分数已经很高或很低的 expert 仍能收到可观的梯度,不会像 sigmoid 那样在两端饱和。
需要说明的是,"V4 为什么从 V3 的 sigmoid 换成 sqrtsoftplus"官方没有给出解释,上面几条是从函数性质与 Gate.forward 的代码结构反推的工程推断(E 级证据)。
7.5 noaux_tc 的工业化:bias term 怎么工作
V4 的 topk_method="noaux_tc" 配合 bias term 实现"无 auxiliary loss 的负载均衡"(这个字段只出现在 HF 格式的 config.json 里,推理代码并不读它,行为由 Gate.bias 是否存在决定)。严格说 V4 并没有彻底去掉辅助损失:技术报告第 2.1 节写明它另加了一项"slight sequence-wise balance loss",第 4.2.2 节给出权重 0.0001,只为防止单条序列内的极端失衡——与 V3 的 α=0.0001 相同。这套机制的工作原理:
普通 aux loss 方案的问题:
total_loss = main_loss + λ × aux_loss
aux_loss = N × sum_e (f_e × P_e) # 示意(Switch Transformer 形式):f_e 为被选比例,P_e 为平均路由概率
aux loss 的问题是它与主 loss 竞争梯度——模型为了降低 aux_loss,会牺牲主任务的学习质量。把"专家均衡"硬性变成训练目标,反而损害模型表达力。
noaux_tc 的解法:
score_for_topk = original_score + bias[e]
score_for_weight = original_score # 不加 bias
# 每个 step 结束时按负载更新 bias(不经过反向传播):
for e in experts:
if frequency[e] > target:
bias[e] -= step
else:
bias[e] += step
精妙之处:
- bias 只影响 topk 选取——决定哪些 expert 被激活
- bias 不进入 routing weight——expert 的输出权重由原始分数决定
- bias 的更新不经过反向传播——不污染主 loss 的梯度
- 这样模型学到的"哪个 expert 最相关"是纯粹的,bias 只调整"哪个 expert 当前应该被多用"
V4 的源码里 bias 的角色非常清晰:
if self.bias is not None:
scores = scores + self.bias # bias 仅给 topk 用
if self.hash:
indices = self.tid2eid[input_ids]
else:
indices = scores.topk(self.topk, dim=-1)[1] # topk 看 biased score
weights = original_scores.gather(1, indices) # weight 用原始分数
original_scores = scores(inference/model.py:572,在加 bias 之前 cache 的),bias 后的 scores 用于 topk(:579),原始 scores 用于 gather weight(:580)。这种"分数双轨制"是 noaux_tc 的工程精髓,源码里还专门写了一行注释点明:# Bias shifts scores for expert selection (topk) but does not affect routing weights.(:573)。
7.6 routed_scaling_factor:路由权重的最终缩放
V4 的 route_scale = 2.5(推理代码读 inference/config.json 的 route_scale,对应 HF 格式 config.json 的 routed_scaling_factor)。
Gate.forward 最后一步(inference/model.py:583):
weights *= self.route_scale
把所有路由权重乘以 2.5。这看起来只是常数缩放,但对 MoE 输出幅度有重要影响。
为什么要乘这个常数,官方没有解释;下面是一种从输出幅度出发的理解。考虑 V4 的最终 MoE 输出公式:
y = sum_e (routed_weight[e] × routed_expert[e](x)) + shared_expert(x)
如果不缩放,路由权重总和约 1(归一化后)——routed 部分对最终输出贡献的"幅度"被压在 1.0。但 shared expert 输出没有归一化约束——它的幅度可以是任意值。
这导致一个不平衡:shared expert 可能"主导"输出,而 6 个 routed expert 共同只贡献"被压扁的 1.0 量级"。
route_scale = 2.5 把 routed 部分的总幅度提升到 ~2.5,让 routed expert 与 shared expert 在量级上可比。这个 2.5 的来历官方没有公开,但它是从 V3 继承下来的(V3 的 routed_scaling_factor 同样是 2.5),所以更可能是"沿用一个已验证的常数"而不是为 V4 重新标定的。
如果你训练自己的 V4-style MoE,这个常数需要根据你的 shared expert 输出幅度调整——不是普适的"魔法数字"。
7.7 hash 层与学习层的双轨架构
V4 在前 3 层用 hash routing(num_hash_layers=3),后 58 层用学习 routing。从 Gate.__init__ 看:
self.hash = layer_id < args.n_hash_layers
self.weight = nn.Parameter(torch.empty(args.n_routed_experts, args.dim))
if self.hash:
self.tid2eid = nn.Parameter(
torch.empty(args.vocab_size, args.n_activated_experts, dtype=torch.int32),
requires_grad=False
)
self.bias = None
else:
self.bias = nn.Parameter(torch.empty(args.n_routed_experts, dtype=torch.float32))
hash 层的特点:
tid2eid: [vocab_size, n_activated_experts]—— 一个直接查表的 embedding,token id → expert id 列表requires_grad=False—— 不参与梯度更新,是预计算的固定映射bias = None—— hash 层没有 bias,因为路由是固定的,不需要均衡
forward 中:
if self.hash:
indices = self.tid2eid[input_ids] # 直接查表
else:
indices = scores.topk(self.topk, dim=-1)[1] # 学习路由
为什么前 3 层要 hash?技术报告第 2.1 节只交代了做法——把 V3 前几层的稠密 FFN 换成 hash 路由的 MoE 层——没有给出理由。一个常见的工程推断是:前 3 层的 hidden state 还非常接近原始 token embedding,直接基于 token id 路由,可能比基于 hidden state 学习路由更高效、更稳定。但要注意,Hash Layers 论文(Roller et al., 2021)的实验结论是 hash 层放在较后的层效果更好,所以这条推断并没有一手证据支撑。第 8 章会专门展开 hash 路由的设计。
7.8 训练时 bias 的更新机制
V4 的训练源码不公开。下面这段是按 DeepSeek-V3 技术报告描述的 auxiliary-loss-free 策略(每个 step 结束时,按整个 batch 统计负载,过载的 expert 把 bias 减 γ、欠载的加 γ)写的示意实现,用来说明 Gate.bias 这个 requires_grad 为真却从不被主 loss 更新的参数是怎么动起来的——不是 V4 的真实训练代码:
# 训练 loop 之后(每个 step 或每 N steps)
with torch.no_grad():
# 统计每个 expert 在本 step 内的"使用次数"
expert_load = ... # [n_routed_experts]
# 计算 target load(均匀分布)
target_load = batch_size * topk / n_routed_experts
# 调整 bias
for e in range(n_routed_experts):
if expert_load[e] > target_load:
gate.bias[e] -= step_size # 这个 expert 太忙,压低
else:
gate.bias[e] += step_size # 这个 expert 太闲,抬高
# 防止 bias 失控(绝对值上限)——本专栏加的防护,V3 报告并未提到对 bias 设上下限
gate.bias.clamp_(min=-bias_max, max=bias_max)
step_size 即报告里的 bias update speed γ:V3 报告取 0.001(最后 500B tokens 降为 0),V4 技术报告第 4.2.2 节对 Flash、Pro 都取 0.001。bias_max 是示意性的兜底,报告里没有这一项,取值需自行标定。
这个更新机制的几个关键点:
- 完全在 no_grad 上下文中——不参与梯度反向传播
- 以 batch 为单位——避免单 token 决策导致 bias 抖动
- clamp 只是示意——V3 报告只写了 ±γ 的更新规则
V4 沿用 V3 这套机制,连同那项权重 0.0001 的序列级均衡损失(技术报告第 2.1、4.2.2 节)。第 17 章会基于公开技术报告展开预训练的具体细节。
7.9 Gate 与其他 MoE 模型的对比
把 V4 的 Gate 与 Mixtral / Qwen MoE / GShard 等横向对比:
非 DeepSeek 各行取自各家公开模型卡 / config,具体以官方仓库为准:
| 模型 | 专家数 | top-k | 评分函数 | 均衡机制 | 是否有 shared expert |
|---|---|---|---|---|---|
| GShard (Google) | 64-2048 | 2 | softmax | aux loss | 否 |
| Switch Transformer | 64-2048 | 1 | softmax | aux loss | 否 |
| Mixtral 8x7B | 8 | 2 | softmax | aux loss | 否 |
| Qwen2-57B-A14B | 64 | 8 | softmax | aux loss | 是(8 个) |
| Qwen3-235B-A22B | 128 | 8 | softmax | aux loss | 否(Qwen3 去掉了共享专家) |
| DeepSeek-V3 | 256 | 8 | sigmoid | noaux_tc + α=0.0001 序列级损失 | 是 |
| DeepSeek-V4 | 384 | 6 | sqrtsoftplus | noaux_tc + 0.0001 序列级损失 | 是 |
| Llama 4 Maverick | 128 | 1 | sigmoid | — | 是(1 个) |
V4 的 Gate 在三个维度上"特立独行":
- noaux_tc —— 表中只有 DeepSeek 这一系靠 bias 做主要的负载均衡,辅助损失只剩权重 0.0001 的序列级一项
- sqrtsoftplus —— 表中唯一不用 softmax / sigmoid 的评分函数
- shared expert + 384 routed —— 极致细粒度 + 共享通用知识
细粒度专家 + 共享专家的源头是 DeepSeekMoE 论文(arXiv:2401.06066),无辅助损失均衡出自同团队 Wang et al. 2024(arXiv:2408.15664)——V4 把这条路线推到了 384 专家的规模,并在 V3 的 sigmoid 基础上又向前一步换成了 sqrtsoftplus。另一处与 V3 的差异是去掉了节点受限路由:V3 限定每个 token 最多发往 4 个节点,V4 技术报告第 2.1 节说明取消了这一限制,改由重新设计的并行策略保证训练效率,所以 V4 的 config.json 与 Gate 里都没有 n_group / topk_group 这类分组路由参数。
7.10 动手实验:自己写一个 noaux_tc Gate
import torch
import torch.nn as nn
import torch.nn.functional as F
class MyGate(nn.Module):
def __init__(self, dim=512, n_experts=64, topk=6, route_scale=1.0):
super().__init__()
self.topk = topk
self.route_scale = route_scale
self.weight = nn.Parameter(torch.randn(n_experts, dim))
self.bias = nn.Parameter(torch.zeros(n_experts), requires_grad=False)
def forward(self, x):
# x: [B, dim]
scores = F.linear(x.float(), self.weight.float())
scores = F.softplus(scores).sqrt() # sqrtsoftplus
original = scores
biased = scores + self.bias
idx = biased.topk(self.topk, dim=-1)[1] # [B, topk]
weights = original.gather(1, idx) # [B, topk]
weights = weights / weights.sum(dim=-1, keepdim=True)
weights = weights * self.route_scale
return weights, idx
# 测试:模拟 noaux_tc 的 bias 更新
gate = MyGate()
x = torch.randn(64, 512)
weights, idx = gate(x)
# 统计每个 expert 的使用次数
expert_load = torch.bincount(idx.flatten(), minlength=64)
target = 64 * 6 / 64 # = 6
# 更新 bias
with torch.no_grad():
step = 1e-3
for e in range(64):
if expert_load[e] > target:
gate.bias[e] -= step
else:
gate.bias[e] += step
print("bias after update:", gate.bias[:10])
跑几个 step 后能观察到:"被选过多的 expert 的 bias 被压低,下个 step 它被选的概率下降"。这就是 noaux_tc 的全部魔法。
7.11 路由稳定性:为什么它是 MoE 训练的隐性成败线
MoE 训练里有一个不写在 loss 里、但决定成败的量:路由稳定性——同一个 token 在训练前期和后期被分配到的 expert 集合,是否剧烈变化。
如果不稳定,expert 训练就成了打地鼠:每个 expert 接收的 token 类型不断改变,永远学不出差异化,最后 384 个专家退化成 384 个功能重复的 FFN——总参 1.6T 却只有 dense 模型的表达力。
V4 的三个设计恰好都在往"稳"的方向使劲,这一点可以从代码结构直接读出来,不需要训练日志:
- bias 只改判、不改分(§7.5)——负载均衡的压力被限制在"名次相近的候补之间",不会把一个已经学到明确分工的 expert 从它的 token 上踢走。这是 noaux_tc 相对 aux loss 最本质的稳定性优势:aux loss 是把均衡写进梯度,会直接改写
Gate.weight学到的相关性判断。 - sqrtsoftplus 的无上界(§7.4)——强相关的 expert 分数可以远远拉开,bias 的固定增量撼动不了它。
- 前 3 层 hash 路由(§7.7)——最靠近 token embedding 的三层,路由干脆不学,
requires_grad=False写死("这三层 hidden state 波动最大、最适合 hash"是工程推断,官方未说明理由)。
本专栏不给这条曲线上的任何数字——V4 没有公开训练曲线,任何"第几万 step 达到均衡"的说法都是编的。但上面三条机制本身是源码里能核实的(第 3 条选在前 3 层的理由除外)。
对 fine-tune V4 的直接含义:Gate.weight 与 Gate.bias 承载的是预训练全程积累的路由分工,lr 给大了会先破坏路由、再破坏 expert——这类损伤在 loss 上表现为"先降后升",比普通过拟合更难恢复。稳妥做法是把 Gate 的 lr 单独设得比 attention / expert 更小,或者干脆冻结。
7.12 Gate 与稀疏 attention 的隐性耦合
V4 的 Gate 看似与 attention 完全独立——但实际上有一个隐性的耦合点:前 3 层 Hash routing 让稀疏 attention 的 KV 内容更稳定。
具体机制:前 3 层 hash 路由让每个 expert 接收的 token 类别固定。这让前 3 层的 hidden state 输出有较高的稳定性——同一个 token id 在训练全程经过的 expert 集合是固定的,所以它的 hidden state 表达也是稳定的。
第 4 层及之后的 attention 看到的就是这种"被 Hash 稳定化"的 hidden state——稀疏 attention 的 Indexer 学到的"哪些 KV 重要"的模式因此更稳定。如果前 3 层用学习路由,hidden state 在训练初期会大幅波动——稀疏 attention 的 Indexer 就要不断"追"这种波动,训练困难。
这条因果链是工程推断(E 级证据),不是官方说法——源码只能证明"前 3 层用 hash 路由"和"Indexer 依赖 hidden state 打分"这两个事实,两者之间的因果是推出来的。但它至少提供了一个可证伪的角度:如果把 num_hash_layers 改成 0 重训一遍,Indexer 的收敛速度应该会变慢。
7.13 Gate 调试的 5 个常见问题
实现自己的 V4-style Gate 时容易遇到几个典型问题——把它们的症状与定位方法列出来:
问题 1:Gate 输出全部相近,topk 退化为随机
症状:所有 expert 的 score 差异在 1e-3 以下,topk 选出的 6 个 expert 几乎按 expert id 顺序选——表现是某些 expert 长期不被激活。
原因:Gate 的 weight 初始化太小,或者 input hidden state 太均匀。
解法:把 Gate weight 用 truncated normal 初始化(std = 1 / sqrt(dim));检查上游 RMSNorm 是否正确归一化。
问题 2:bias 失控,绝对值越来越大
症状:训练几千 step 后某些 expert 的 bias 跑到 ±10 以上。
原因:bias 更新步长设大了;或者训练数据分布严重不均,某些 expert 长期空闲。
解法:把 bias clamp 到 ±0.5;定期检查 bias 分布,发现长期不更新的 expert 用 hash routing 强制路由几个 step 给它"喂数据"。
问题 3:sqrtsoftplus 数值不稳
症状:训练初期 loss 突然 NaN。
原因:F.softplus(x).sqrt() 在 x 为大负数时会 underflow(FP32 下 x 约 −104 以下 softplus 即为 0)——sqrt(0) 的导数是 inf,乘上同样下溢为 0 的 softplus 导数,反传得到 NaN。
解法:在 sqrt 之前加 clamp(min=1e-6) 保证不为 0。
问题 4:route_scale 与 shared expert 输出量级不匹配
症状:模型输出 norm 异常大或异常小。
原因:route_scale=2.5 是沿用自 V3 的常数,来历未公开——不同模型可能需要不同值。
解法:训练时监控 routed 输出与 shared 输出的 norm 比例,调 route_scale 让两者接近 1:1。
问题 5:hash 层与学习层的边界训练不稳
症状:第 3 层(hash 与学习的边界)的 loss 抖动比其他层大。
原因:hash 层固定 expert 输出与学习层动态 expert 输出在边界处的"信号特征"差异大。
解法:让前 3 层的 weight decay 更大,加快它们的"稳定化"。
这 5 条是实现 V4-style Gate 时会撞到的坑,不是"V4 团队踩过的坑"——V4 的训练日志没有公开,本专栏不替它编故事。其中问题 3(sqrt 在 0 处导数为 inf)是纯数学事实,任何人复现都会遇到;问题 4 的 route_scale 需要按自己的 shared expert 幅度重标(见 §7.6)。
7.14 Gate 的"前向计算" 浮点精度链路
V4 的 Gate forward 在精度上有几个细节值得专门拆出来。
第 1 步 score 计算:
scores = linear(x.float(), self.weight.float())
注意两个 .float()——把 input x 和 weight 都升到 float32 算。这与其他大部分 Linear 不同(其他大多走 BF16/FP8)。原因:score 的差异会被 topk 放大——即便差几个百分位的 epsilon,topk 选出来的可能完全不同。所以 Gate 必须用 FP32 算。
第 2 步 sqrtsoftplus:
scores = F.softplus(scores).sqrt()
softplus 与 sqrt 都在 float32 上算——避免 BF16 在大值或小值下的精度漂移。
第 3 步 bias 加法:
scores = scores + self.bias
self.bias 是 float32,与 scores 同精度——不会有精度损失。
第 4 步 topk:
topk 操作本身是 deterministic 的,但浮点精度对 ties 的处理敏感:如果两个 expert 的 score 极接近(差 1e-6),不同硬件 / 不同 kernel 可能选出不同的 expert,于是同一份权重在两台机器上给出不同输出。换 score function 救不了这件事——softplus 和 sqrt 都是单调的,不改变任何一对分数的先后。真正把 tie 概率压下去的是第 1 步那个 .float():FP32 的 24 位尾数让两个分数恰好相等的概率远低于 BF16 的 8 位尾数。
第 5 步 weights 归一化:
weights = original_scores.gather(1, indices)
if self.score_func != "softmax":
weights /= weights.sum(dim=-1, keepdim=True)
weights *= self.route_scale
也在 float32 上算——除法对精度敏感。
第 6 步返回:
weights / indices 出 Gate 后才被下游 expert 用——routed expert 权重是 FP4(Pro 的 expert_dtype),但 Expert.forward 把 SwiGLU 激活升到 float32 后才乘 weights(inference/model.py:598-605),乘完再转回 BF16 送入 w2。所以 weights 本身不单独降精度,降精度发生在乘积上——weights 是个标量乘子,精度损失可控。
整体来看 V4 的 Gate 是"全程 float32"——没有任何 BF16/FP8 中间步骤。这是为了 routing 决策的稳定性付的小代价。
7.15 noaux_tc 的训练数学:bias 怎么收敛
V4 的 noaux_tc 用 bias 替代 aux loss——但 bias 怎么具体收敛到一个稳定的分布,背后有可推导的数学。
收敛方程:
设 expert e 在某 step 接收到的 token 数为 n_e,目标接收数为 target = batch_size × topk / n_routed。按 V3 报告的规则,bias 只看误差的符号:
b_e[t+1] = b_e[t] + step × sign(target - n_e[t])
这是离散版的"目标-反馈"控制——sign(target - n_e) 是误差信号,step 是控制增益。
稳态分析:
这相当于一个只看误差符号的积分控制器(PI 控制里的 I 项):误差的符号被持续累加进 ,只要还有误差, 就会继续移动;误差归零时 停在当前值。所以稳态的充要条件就是"每个 expert 恰好接到 target 个 token",而稳态的 值本身没有闭式解——它取决于 Gate.weight 学到的分数分布形状,而后者在训练中一直在变。
直观上:天生不受欢迎的 expert 稳态 bias 更高,天生受欢迎的更低,高低差大致反映"自然激活概率"的差距。但这只是定性的,不要当成公式用。
收敛速度:
step size 决定收敛速度。step 太大:bias 抖动剧烈,topk 选取不稳;step 太小:收敛慢,训练前期不均衡。V4 技术报告第 4.2.2 节给出的 step(bias update speed)是 0.001;收敛要多少 step 报告没有公开。
与 weight 训练的同时性:
bias 在 no_grad 上下文中更新,weight 在反向传播中更新。两者交替进行——bias 调路由分布,weight 学每个 expert 的具体表达。这种"路由分布 vs 表达学习"的解耦是 noaux_tc 比 aux loss 高级的根本原因。
与 sqrtsoftplus 的协同:
sqrtsoftplus 让 score 输出无上限,于是同一个 bias 增量在不同分数区间的"改判力"是不同的:在分数低、彼此接近的区域它能改变名次,在某个 expert 分数远高于其他的区域它改不动。这恰好是负载均衡应该有的行为——把闲置 expert 从"并列候补"里提上来,而不是把明显最相关的那个挤下去。
(顺带澄清一个常见误解:源码里 bias 是加在激活之后的,即便 score function 是 softmax,bias 也不会被归一化"吃掉"——它只是作用在一个被压进 [0,1] 的量上,相对改判力更大而已。)
7.16 延伸阅读
- DeepSeekMoE 论文(arXiv:2401.06066):细粒度专家 + 共享专家
- Auxiliary-Loss-Free Load Balancing 论文(arXiv:2408.15664):bias 均衡方法的出处
- DeepSeek-V3 报告(arXiv:2412.19437):noaux_tc 首次用于大规模训练
- Switch Transformer 论文(arXiv:2101.03961):MoE 的早期工业化
- Mixtral 论文(arXiv:2401.04088):粗粒度(8 专家、top-2)MoE 路线
- 本专栏第 8 章:hash routing 的细节展开
- 本专栏第 9 章:Expert 类与 SwiGLU 实现
7.17 本章小结
- V4 的 Gate 是不到 40 行代码、4 个工程要素:sqrtsoftplus、noaux_tc、bias term、route_scale
- sqrtsoftplus 取代 sigmoid——给 Gate 更大的"分数动态范围" + 更平滑的训练梯度
- noaux_tc + bias term 取代 aux loss 承担主要均衡(另留权重 0.0001 的序列级损失)——专家均衡基本不污染主 loss
- route_scale=2.5 校准 routed 与 shared 路径的输出幅度
- 前 3 层用 hash 路由——官方未说明理由;"早期层 hidden 太接近 token embedding"是工程推断,Hash Layers 论文的结论反而是 hash 层放后层更好
- 与 GShard / Mixtral / Qwen 等同类对比,V4 在三个维度独特:noaux_tc、sqrtsoftplus、384 + shared expert
第 8 章我们专门展开 hash routing——为什么前 3 层不学路由,以及 tid2eid 表是怎么生成的。