DeepSeek V4 源码剖析

第7章 Gate 之变:sqrtsoftplus、noaux_tc 与 routed_scaling

作者 杨艺韬 · 6,534 字 · 发布于 · 更新于

"scoring_func": "sqrtsoftplus"、"topk_method": "noaux_tc"、"routed_scaling_factor": 2.5 —— DeepSeek-V4-Pro config.json

Gate 决定每个 token 去哪 6 个专家。整个类不到 40 行,但每一行都对应 MoE 训练里一个具体的坑。


7.1 引子:384 个专家面前 Gate 的工程问题

V4 一层 MoE 有 384 个 routed expert + 1 个 shared expert。每个 token 必须选 6 个 routed expert 激活——选哪 6 个?这就是 Gate 要解决的核心问题。

把这个问题展开成一组工程约束:

  1. 正确性:选的 6 个专家应该是"对当前 token 最相关的 6 个"——否则模型表达力损失
  2. 均衡性:所有 384 个专家的"使用频率"必须接近——否则极少数专家承担大部分计算,多数专家闲置
  3. 稳定性:训练不能让"被选少的专家"陷入"越选越少"的死循环(路由坍塌)
  4. 效率:Gate 本身的计算成本必须小——否则 384 倍的"打分"就把 MoE 节省的 FLOPs 吃回去
  5. 灵活性: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 步:

  1. linear(x, weight) 算每个 expert 的原始打分
  2. 根据 score_func 套激活函数(V4 用 sqrtsoftplus)
  3. 加 bias(如果不是 hash 层)
  4. topk 选索引(hash 层走 tid2eid 表)
  5. gather 出对应位置的"原始分数"作为路由权重
  6. 归一化 + 乘 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()——

sqrtsoftplus(x)=ln⁡(1+ex)\text{sqrtsoftplus}(x) = \sqrt{\ln(1 + e^x)}

为什么用这个看起来奇怪的函数?把它与 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 输出在 [0,∞)[0, \infty),强相关 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 得到 σ(x)2ln⁡(1+ex)\frac{\sigma(x)}{2\sqrt{\ln(1+e^x)}}——峰值在 x≈0.9x \approx 0.9 处(约 0.32),两侧都衰减,但比 sigmoid 慢得多:x=10x=10 时 sqrtsoftplus 的梯度约 0.16、sigmoid 已不到 1e-4,x=−5x=-5 时两者约为 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 的三个设计恰好都在往"稳"的方向使劲,这一点可以从代码结构直接读出来,不需要训练日志:

  1. bias 只改判、不改分(§7.5)——负载均衡的压力被限制在"名次相近的候补之间",不会把一个已经学到明确分工的 expert 从它的 token 上踢走。这是 noaux_tc 相对 aux loss 最本质的稳定性优势:aux loss 是把均衡写进梯度,会直接改写 Gate.weight 学到的相关性判断。
  2. sqrtsoftplus 的无上界(§7.4)——强相关的 expert 分数可以远远拉开,bias 的固定增量撼动不了它。
  3. 前 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 项):误差的符号被持续累加进 beb_e,只要还有误差,beb_e 就会继续移动;误差归零时 beb_e 停在当前值。所以稳态的充要条件就是"每个 expert 恰好接到 target 个 token",而稳态的 be∗b_e^* 值本身没有闭式解——它取决于 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 表是怎么生成的。