DeepSeek V4 源码剖析

第9章 Expert 与共享专家:SwiGLU + clip + 容量平衡

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

"swiglu_limit": 10.0、"expert_dtype": "fp4" —— DeepSeek-V4-Pro config.json

Expert 类只有 20 行。但 swiglu_limit 这个 V3 时代没有的参数,和 expert_dtype: "fp4" 这个 4 月 27 日才补进 HF config.json 的字段(inference/config.json 首发即有),很容易被读成一因一果——按技术报告,它们其实各有来历:前者为稳定训练而加、贯穿整个训练,后者是后训练阶段 FP4 量化感知训练的产物。


9.1 引子:384 个 SwiGLU 同时工作的工程问题

V4 一层 MoE 包含 385 个 expert:384 个 routed + 1 个 shared。每个 expert 是一个独立的 SwiGLU FFN,参数独立,被选中的 expert 输出按路由权重加权后,与 shared expert 的输出相加。

把这套架构展开看,至少有四个工程问题:

  1. 数值稳定性:384 个 SwiGLU 同时训练,每个 expert 的"激活幅度"必须保持可控——否则反向传播的梯度会因为某些极端 expert 而不稳
  2. 容量平衡:每个 expert 接收的 token 数必须接近——否则训练梯度对某些 expert 长期不更新
  3. 共享专家的角色:shared expert 要承担"通用知识",但不能"主导"输出 —— routed 权重再乘的 route_scale=2.5 可以从这个角度理解(该值沿用 V3,官方未解释,见 §7.6)
  4. TP 多卡分布:384 个 expert 必须在 8 卡 / 16 卡之间切分——通信成本要可控

Expert 类本身只管第一个问题(float32 计算 + clamp),容量平衡交给 Gate,共享专家与 TP 切分由 MoE 类组织。本章拆这一切。


9.2 Expert 类的源码全景

# inference/model.py:587
class Expert(nn.Module):
    """Single MoE expert: SwiGLU FFN (w1, w2, w3). Computation in float32 for stability."""
    def __init__(self, dim: int, inter_dim: int, dtype=None, swiglu_limit=0):
        super().__init__()
        self.w1 = Linear(dim, inter_dim, dtype=dtype)
        self.w2 = Linear(inter_dim, dim, dtype=dtype)
        self.w3 = Linear(dim, inter_dim, dtype=dtype)
        self.swiglu_limit = swiglu_limit

    def forward(self, x: torch.Tensor, weights: Optional[torch.Tensor] = None) -> torch.Tensor:
        dtype = x.dtype
        gate = self.w1(x).float()
        up = self.w3(x).float()
        if self.swiglu_limit > 0:
            up = torch.clamp(up, min=-self.swiglu_limit, max=self.swiglu_limit)
            gate = torch.clamp(gate, max=self.swiglu_limit)
        x = F.silu(gate) * up
        if weights is not None:
            x = weights * x
        return self.w2(x.to(dtype))

20 行。但每一行都有讲究:

  • w1 / w3:投影到 inter_dim,分别是 SwiGLU 的"门控"和"上投影"
  • w2:投回 dim
  • dtype:可以是 BF16 / FP8 / FP4 e2m1(V4 的 config 设 expert_dtype: "fp4",routed expert 用 FP4;ModelArgs 里该字段默认是 None)
  • swiglu_limit:clip 阈值(up 截到 ±10,gate 只截上界),V4 设 10
  • forward 内部把激活提升到 float32 计算,最后再降回原 dtype

9.3 384 个 expert 在 MoE 层中的协同结构

把每层 MoE 中 routed + shared expert 的协同关系画成数据流图:

flowchart LR
  X["token hidden<br/>x: [B*S, 7168]"] --> Gate{Gate}
  Gate -->|sqrtsoftplus + bias + topk| Indices["6 expert indices"]
  Gate -.原始 score 归一.-> Weights["6 routing weights"]
  X --> Shared["shared expert<br/>FP8 e4m3, SwiGLU"]
  X -->|按 indices 散发| E1["routed expert 0<br/>FP4 e2m1"]
  X --> E2["routed expert 1<br/>FP4"]
  X --> Edot["..."]
  X --> EN["routed expert 383<br/>FP4"]
  E1 -->|× w0| Sum((+))
  E2 -->|× w1| Sum
  Edot --> Sum
  EN -->|× w5| Sum
  Shared -->|永远激活| Sum
  Sum --> Y["MoE 输出 y: [B*S, 7168]"]
  Indices -.决定哪些 expert 被激活.-> E1

注意三件事:

  • 每 token 选 6 个 routed expert(不是全部 384 个),每 rank 仅算自己持有的部分
  • shared expert 永远激活——给每 token 提供"通用知识"基线
  • routed expert 输出乘上 routing weight,shared 输出不乘 weight——直接相加

这种"稀疏 routed + dense shared" 的组合是 DeepSeekMoE 论文的核心设计。


9.4 SwiGLU 的代数与几何

SwiGLU 是 V4 / V3 / Llama / Mistral 等现代模型的标准 FFN:

SwiGLU(x)=w2(SiLU(w1x)⊙w3x)\text{SwiGLU}(x) = w_2(\text{SiLU}(w_1 x) \odot w_3 x)

SiLU(g) = g · sigmoid(g)——也叫 Swish。⊙ 是逐元素乘。

把这个公式拆成三步:

  1. gate = w_1 x:门控通道
  2. up = w_3 x:上投影通道
  3. output = w_2 (SiLU(gate) · up):门控后逐元素乘,再投回原维度

为什么 SwiGLU 比传统 ReLU FFN 表达力强?因为它有两条独立路径——gate 决定"开关"、up 决定"内容",两者乘起来比单条 ReLU 路径有更丰富的表达。

V4 的代数特殊处理:

gate = self.w1(x).float()
up = self.w3(x).float()

无论权重是 BF16、FP8 还是 FP4,GEMM 输出的 gate / up 都是 BF16,随即被提升到 float32(docstring 的说法是 "Computation in float32 for stability")。要注意,这里买的是精度而不是范围:

  • BF16 与 FP32 同为 8 位指数、最大值都约 3.4×10³⁸,exp(15) ≈ 3.3×10⁶ 远没到溢出线;BF16 缺的是尾数——只有 7 位,机器精度 2⁻⁷ ≈ 0.0078,10.03 存进 BF16 就是 10.0
  • SiLU 与逐元素乘在 FP32 里做,舍入误差不会在 BF16 的粗格点上叠加
  • 最后投回 w2 时再降回 dtype

float32 中间计算的代价是显存占用约翻倍——但中间张量只在每个 token 短暂存在,不写到 KV cache,对总显存影响小。


9.5 SwiGLU forward 路径的 dtype 流转

V4 Expert.forward 内部的 dtype 切换非常频繁——把它画成 dtype 流转图:

flowchart TB
  Input["x: BF16 [B,S,7168]"] --> AQ["act_quant(x, 128)<br/>→ FP8 e4m3 + ue8m0 scale"]
  AQ --> W1["w1: fp4_gemm(FP8 act × FP4 weight)"]
  AQ --> W3["w3: fp4_gemm(FP8 act × FP4 weight)"]
  W1 --> Gate["gate: BF16 → .float() → FP32"]
  W3 --> Up["up: BF16 → .float() → FP32"]
  Gate --> Clamp1{swiglu_limit?}
  Up --> Clamp2{swiglu_limit?}
  Clamp1 -->|clamp max=10| GateClipped["gate ≤ 10<br/>FP32"]
  Clamp2 -->|clamp -10..10| UpClipped["up ∈ -10..10<br/>FP32"]
  GateClipped --> SiLU["F.silu(gate)<br/>FP32"]
  SiLU --> Mul((×))
  UpClipped --> Mul
  Mul --> Inter["intermediate: FP32 [B,S,3072]"]
  Inter --> Wmul{有 weights?}
  Wmul -->|是| WeightedMul["× routing weight"]
  Wmul -->|否,如 shared expert| Skip[直接走]
  WeightedMul --> Cast["x.to(BF16)"]
  Skip --> Cast
  Cast --> W2["w2: act_quant → FP8,再 fp4_gemm"]
  W2 --> Output["output: BF16 [B,S,7168]"]

精度链的 5 个关键决策:

  1. 激活先被量化:Linear.forward → linear() 里那一句 x, s = act_quant(x, block_size, scale_fmt, scale_dtype) 把 BF16 激活按 128 一块压成 FP8 e4m3 + ue8m0 scale,然后才进 fp4_gemm。所以真正的 GEMM 是 FP8 激活 × FP4 权重(inference/kernel.py 里 fp4_gemm_kernel 的 docstring 写得很明确:"Act: 1x128 quant on K (reduce dim), FP8 with configurable scale dtype"、"Weight: 1x32 quant on K (reduce dim), FP4 with E8M0 scale"),FP32 累加,BF16 输出
  2. gate / up 升 FP32:docstring 明言 "Computation in float32 for stability"
  3. clamp 在 FP32 上做:它排在 .float() 之后,截断、SiLU、相乘都在同一精度里完成
  4. intermediate 保 FP32:乘 routing weight 也在 FP32 里做,直到 w2 之前
  5. w2 输入先降回 BF16(x.to(dtype)),进 Linear 后再被 act_quant 压成 FP8——中间这一步 BF16 不是多余的,它是"FP32 计算区"和"FP8 量化区"之间的交接口

这条精度链的效果是把 FP4 / FP8 的低精度限制在 GEMM 内部,SwiGLU 的非线性部分始终在 FP32 里算。


9.6 swiglu_limit=10:一个看似多余的 clip

V4 的 config.json 里:

"swiglu_limit": 10.0

对应源码:

if self.swiglu_limit > 0:
    up = torch.clamp(up, min=-self.swiglu_limit, max=self.swiglu_limit)
    gate = torch.clamp(gate, max=self.swiglu_limit)

把 gate clamp 到 max=10(不限下界),把 up clamp 到 [-10, 10]。为什么需要这个 clip?

官方给出的原因:压制离群值、稳住训练

技术报告第 4.2.3 节(Mitigating Training Instability)讲得很直接:训练中出现的 loss spike 总与 MoE 层里的离群值(outlier)相伴,而路由机制本身似乎会放大这些离群值;他们实测发现 SwiGLU clamping(报告注明借鉴自 OpenAI 2025,即 gpt-oss 的模型卡)能有效消除离群值、显著帮助训练稳定,且不损性能。Flash 与 Pro 整个训练过程都开着这组 clamp——线性分量(即代码里的 up)截到 [−10, 10],门控分量(gate)只截上界 10。报告也承认,它为什么有效「尚未被充分理解」。

从函数形状看:SiLU 正半轴无界

SiLU(g) = g · sigmoid(g)。在 g = 10 时 SiLU(10) ≈ 10;在 g = 100 时 SiLU(100) ≈ 100——正半轴近似线性、没有上界。gate 一旦出现离群大值,乘上同样可能很大的 up、再经 w2 投影,离群值会一路传下去。

把 gate clamp 到 10 限制了 SiLU 的最大输出,up 也被夹在 ±10 内,乘积的幅度因此有了硬上界,离群值在 w2 之前就被截断。

附带作用:给 FP4 部署兜底

注意时间顺序:FP4 量化感知训练只在后训练阶段引入(技术报告第 3.4 节),而 clamp 从预训练起就开着——所以 clamp 不是为 FP4 加的。但它对 FP4 部署同样有用。V4 的 routed expert 权重是 FP4 e2m1,每 32 个元素共享一个 E8M0(纯 2 的幂)scale。inference/kernel.py 里 fp4_max = 6.0——一个 FP4 数只能表示 0、±0.5、±1、±1.5、±2、±3、±4、±6 这十几个值。整块的动态范围靠那个 2 的幂 scale 平移。

这意味着 block 内只要有一个离群大值,它就会顶高整块的 scale,块内其余小值全部被压到最近的量化格点上——极端情况直接归零。量化误差若在个别 channel 上被放大,w1 x 与 w3 x 的输出也可能超出训练时见过的幅度,clamp 同样会把它截在进入 SiLU 与相乘之前。

注意 clamp 是不对称的,这一点比阈值本身更有信息量:

up   = torch.clamp(up,   min=-self.swiglu_limit, max=self.swiglu_limit)   # 双边
gate = torch.clamp(gate,                          max=self.swiglu_limit)   # 只截上界

gate 只截上界,因为它要过 SiLU——SiLU(g)=gσ(g)\text{SiLU}(g) = g\sigma(g) 在 g→−∞g \to -\infty 时自己趋向 0,下界天然有界,不需要人管;而 g→+∞g \to +\infty 时 SiLU 近似线性发散,必须截。up 是直接相乘的那一路,两边都得管。这个不对称说明 clamp 不是随手加的保险丝,而是照着 SiLU 的函数形状设计的。

至于为什么是 10 而不是 5 或 100——官方没有给出依据,本专栏也不编。能确定的是:技术报告说明它贯穿整个训练,推理代码照样执行,不是部署时加的补丁;换一个值就等于换了一个模型。


9.7 共享专家:永远激活的"通用知识库"

V4 一层 MoE 有 1 个 shared expert(n_shared_experts=1),它永远参与每 token 的计算:

# MoE.forward 末尾
y += self.shared_experts(x)

shared expert 与 routed expert 的差别:

维度 Shared expert Routed expert
是否激活 永远 每 token 选 6 / 384
输入 token 全部 token 选定的 token
dtype 不传 dtype,走 default_dtype(本配置下是 FP8 e4m3) FP4 e2m1(expert_dtype="fp4")
route weight 无 weight × 2.5
角色 通用知识 专门知识

shared expert 的存在是 DeepSeekMoE 论文(arXiv:2401.06066)的核心创新之一。它解决了"细粒度专家容量太小、放不下通用知识"的问题——把通用知识独立抽出来,放到一个永远激活的 expert 里,让 384 个 routed expert 专注于"差异化的专门知识"。

V4 的源码里 shared expert 的实例化:

# MoE.__init__
self.shared_experts = Expert(args.dim, args.moe_inter_dim, swiglu_limit=args.swiglu_limit)

注意两件事。第一,没有传 dtype——走 Linear.__init__ 里的 dtype = dtype or default_dtype,而 Transformer.__init__ 把 default_dtype 设成 torch.float8_e4m3fn if args.dtype == "fp8" else torch.bfloat16;V4 的 inference/config.json 写的是 "dtype": "fp8",所以 shared expert 是 FP8 e4m3,不是 BF16。第二,swiglu_limit=args.swiglu_limit 这个参数是 2026-04-27 那次 Update inference/model.py 才补上的——4 月 24 日首发版里 shared expert 没有传它,等于不做 clamp。这是本专栏统一按 04-27 版取值的原因之一。

shared expert 为什么用更高精度(FP8 而非 FP4),官方没有说明;一种说得通的解释是——它对每个 token 都贡献,精度损失会被所有 token 累积;而且它只有 1 个,占的显存微不足道(61 层 × 3 × 3072 × 7168 ≈ 4.0B 参数,对照 routed 的 61 × 384 × 3 × 3072 × 7168 ≈ 1.55T)。省它的存储没有意义,赔上精度反而亏。


9.8 MoE.forward:384 expert 怎么并行执行

MoE.forward 是把 Gate + 384 routed + 1 shared 串起来的核心:

# inference/model.py:629
def forward(self, x: torch.Tensor, input_ids: torch.Tensor) -> torch.Tensor:
    shape = x.size()
    x = x.view(-1, self.dim)
    weights, indices = self.gate(x, input_ids.flatten())
    y = torch.zeros_like(x, dtype=torch.float32)
    counts = torch.bincount(indices.flatten(), minlength=self.n_routed_experts).tolist()
    for i in range(self.experts_start_idx, self.experts_end_idx):
        if counts[i] == 0:
            continue
        expert = self.experts[i]
        idx, top = torch.where(indices == i)
        y[idx] += expert(x[idx], weights[idx, top, None])
    if world_size > 1:
        dist.all_reduce(y)
    y += self.shared_experts(x)
    return y.type_as(x).view(shape)

关键 6 步:

步骤 1:扁平化 token 维度

x = x.view(-1, self.dim)

把 [B, S, dim] 压成 [B × S, dim]——便于按 token 索引。

步骤 2:调用 Gate 拿 weights / indices

weights, indices = self.gate(x, input_ids.flatten())

weights: [B*S, 6],indices: [B*S, 6]。

步骤 3:bincount 算每个 expert 的接收数

counts = torch.bincount(indices.flatten(), minlength=self.n_routed_experts).tolist()

如果某个 expert 的 count=0,跳过它的 forward 节省计算。

步骤 4:逐 expert 执行(仅本 rank 持有的 expert)

for i in range(self.experts_start_idx, self.experts_end_idx):
    if counts[i] == 0:
        continue
    expert = self.experts[i]
    idx, top = torch.where(indices == i)
    y[idx] += expert(x[idx], weights[idx, top, None])

experts_start_idx ~ experts_end_idx 是本 TP rank 持有的 expert 范围(n_routed_experts // world_size)。每个 rank 只处理自己的 expert,跳过其他 rank 的。

torch.where(indices == i) 找出"哪些 token 选了 expert i"——返回 (token_idx, position_in_topk)。把这些 token 的 hidden 与对应权重传给 expert,输出 in-place 加到 y 上。

步骤 5:跨 rank all_reduce

if world_size > 1:
    dist.all_reduce(y)

每 rank 只算自己持有的 expert 的输出贡献——通过 all_reduce 把所有 rank 的部分输出相加,得到完整 y。

步骤 6:加 shared expert 输出

y += self.shared_experts(x)

shared expert 对所有 token 都跑,输出加到 FP32 的 y 上,最后 y.type_as(x) 转回输入 dtype。这一步在每个 rank 上独立计算,不需要参与 all_reduce——shared expert 在每个 rank 上都是完整副本(MoE.__init__ 里它不在那个按 rank 过滤的 ModuleList 中)。


9.9 Expert 在 Tensor Parallel 下的切分

以 inference/README.md 示例的 MP=8(8 卡)为例,384 个 routed expert 按 rank 切分:

  • rank 0:expert 0 ~ 47(48 个 expert)
  • rank 1:expert 48 ~ 95
  • ...
  • rank 7:expert 336 ~ 383

每个 rank 的 nn.ModuleList 里只有自己持有的 48 个 expert 是真实模块,其余位置是 None:

self.experts = nn.ModuleList([
    Expert(args.dim, args.moe_inter_dim, dtype=expert_dtype, swiglu_limit=args.swiglu_limit)
    if self.experts_start_idx <= i < self.experts_end_idx else None
    for i in range(self.n_routed_experts)
])

这种"稀疏 ModuleList"模式让代码看起来像所有 384 个 expert 都存在,但实际只有 48 个被实例化。其他 rank 的 expert 通过 None 占位,forward 的循环范围本来就不包含它们。

这种设计的工程取舍:

  • ✅ 代码看起来与"单 GPU 全 expert" 一致——少改逻辑
  • ✅ 下标 i 就是全局 expert 编号,forward 只遍历 experts_start_idx ~ experts_end_idx,永远碰不到 None,dispatch 简单
  • ❌ 每个 rank 都要存 336 个 None 占位——内存开销可以忽略,只是 ModuleList 长度与实际模块数不符

V4 选了"看似浪费、实则简化代码"的写法。


9.10 Expert 容量与"溢出"问题

MoE 训练时容易出现的一个问题是 expert 容量溢出——某个 expert 的接收 token 数超过它的处理能力。V4 的处理:

实际上 V4 源码里没有显式的容量限制——expert 接收多少就处理多少。均衡靠的是训练时的 noaux_tc bias 调节(外加权重 0.0001 的序列级均衡损失,技术报告第 4.2.2 节),推理代码里不做硬性截断。

但训练时如果出现极端不均衡(某个 expert 接收 batch 内 50% 的 token),有几个补救:

  • bias 在下次更新时按固定步长(技术报告给的更新速度是 0.001)压低这个 expert 的 score,一步只动一点
  • 训练 step 的耗时会被这个 expert 拖慢——其他 rank 在等它
  • 长期看会自然均衡

V4 没有公开过 expert load 分布的实测数据——README 与技术报告都没有给 expert 负载曲线,本专栏不给这个数字。能确定的只有源码层面的事实:V4 没有实现任何硬容量截断,推理时的均衡只体现为训练好的 Gate.bias(训练中的反馈回路见 §7.5、§7.15),而这个回路是"软"的——它只能让长期平均趋于均衡,不能保证任何单个 batch 内的均衡。


9.11 动手实验:跑一个 mini Expert + MoE

import torch
import torch.nn as nn
import torch.nn.functional as F

class MiniExpert(nn.Module):
    def __init__(self, dim, inter_dim, swiglu_limit=10):
        super().__init__()
        self.w1 = nn.Linear(dim, inter_dim)
        self.w2 = nn.Linear(inter_dim, dim)
        self.w3 = nn.Linear(dim, inter_dim)
        self.swiglu_limit = swiglu_limit

    def forward(self, x, weights=None):
        gate = self.w1(x).float()
        up = self.w3(x).float()
        if self.swiglu_limit > 0:
            up = up.clamp(-self.swiglu_limit, self.swiglu_limit)
            gate = gate.clamp(max=self.swiglu_limit)
        x = F.silu(gate) * up
        if weights is not None:
            x = weights * x
        return self.w2(x.to(torch.float32)).type_as(self.w2.weight)


class MiniMoE(nn.Module):
    def __init__(self, dim=128, inter_dim=512, n_experts=8, topk=2):
        super().__init__()
        self.dim = dim
        self.topk = topk
        self.gate = nn.Linear(dim, n_experts)
        self.experts = nn.ModuleList([MiniExpert(dim, inter_dim) for _ in range(n_experts)])
        self.shared = MiniExpert(dim, inter_dim)

    def forward(self, x):
        B, S, _ = x.shape
        x_flat = x.view(-1, self.dim)
        scores = F.softplus(self.gate(x_flat)).sqrt()
        weights, indices = scores.topk(self.topk, dim=-1)
        weights = weights / weights.sum(dim=-1, keepdim=True)

        y = torch.zeros_like(x_flat)
        for i, expert in enumerate(self.experts):
            mask = (indices == i).any(dim=-1)
            if not mask.any():
                continue
            sub_x = x_flat[mask]
            # 找出每个 sub_x 在哪些 topk 槽位选到 i
            sub_w = (weights * (indices == i).float()).sum(dim=-1, keepdim=True)[mask]
            y[mask] += expert(sub_x, sub_w)

        y += self.shared(x_flat)
        return y.view(B, S, -1)


# 测试
moe = MiniMoE()
x = torch.randn(2, 16, 128)
out = moe(x)
print(out.shape)   # torch.Size([2, 16, 128])

跑通这个 mini 版本后,再回看 V4 源码的 MoE.forward——会觉得"原来 384 expert 与 8 expert 的代码结构没本质差别,只是 expert 数量和 TP 切分变了"。


9.12 Expert 训练动力学:从随机到差异化

V4 的 384 个 routed expert 在训练开始时是随机初始化的,统计上同分布、功能上没有分工。怎么从"无分工"演化到"差异化",是 MoE 训练的核心动力学。

以下四个阶段是 MoE 训练的一般规律(正反馈分化这件事在 MoE 文献里有广泛讨论),不是 V4 的实测曲线——V4 没有公开训练日志,本专栏不给"第几个百分比 step 发生什么"这类数字。

初期(随机初始化之后):

Gate 路由还在 warmup,每个 expert 被分到的 token 几乎是随机的。每 expert 看到的"输入 token 分布"接近整体训练数据的分布。此时所有 expert 在做同一件事——学习"对随机 token 输出有意义 hidden"。

这个阶段的关键是 gradient 平等下降——每个 expert 收到的梯度量大致相等,没有 expert 被过度训练 / 欠训练。

中期(路由开始分化):

Gate 的 weight 开始学差异——某些 expert 接收的 token 开始偏向某类特征。每个 expert 的"输入分布"不再均匀,开始有特定特征。(下文用"代码 token"举例只为好懂;实测的分化多在语法 / token 层面,而非清晰的主题分工,见《Transformer》专栏第 12 章。)

此时互相强化的循环启动:

  • expert A 接收更多代码 token → 它的 SwiGLU weight 学到更适合代码的表达
  • 学到代码表达后,Gate 的 score 网络看到"代码 token + expert A 的 hidden 匹配度高" → 给 A 更高 score
  • 下一步代码 token 更倾向被分到 A → 进一步强化

这种正反馈循环就是 MoE "差异化"的核心动力。

后期(差异化稳定):

差异化模式已经稳定。每个 expert 的"擅长领域"基本固定。SwiGLU 的 w1/w2/w3 weight 承载了 expert 的 specialization。

精修期(学习率衰减之后):

lr 衰减让 expert 在固定差异化模式下精修内部细节。这个阶段 expert 的"擅长方向"已经写死在权重里。想验证的话,最直接的办法是拿开源权重跑不同领域的 prompt、统计 indices 的分布——这是一个任何人都能做但需要 864.7 GB 权重的实验,本专栏没有做。

V4 vs V3 的训练动力学差异:

决定单个 expert 能见到多少 token 的不是专家总数,而是激活比例:V3 是 8/256 = 3.13%,V4 是 6/384 = 1.56%——正好腰斩。要让每个 expert 的有效训练量与 V3 持平,总 token 数得翻倍(不是 +50%)——V4-Pro 预训练 33T,是 V3 14.8T 的约 2.23 倍,本专栏推测这笔账是原因之一(报告没有给归因,§1.12 列的是同一条推测)。而 384 expert 让差异化更细粒度——每个 expert 可以专攻更窄的子领域,整体模型表达力更强。

理解这条训练动力学曲线对 fine-tune 极重要:如果你在 V4 上做领域 fine-tune(比如训练 V4-Code),不要让 lr 大到破坏精修期建立的 specialization——具体取多少没有官方数字,应比预训练峰值小得多,并在自己的数据上用路由分布的变化来校准。


9.13 shared expert 的"承重墙"角色

shared expert 在 V4 的 MoE 中扮演"承重墙"——它承担"通用知识"的存储,让 384 routed expert 可以专注于"差异化知识"。把这个角色摆深入:

承重墙作用一:稳定输出基线

每个 token 都经过 shared expert——意味着无论 routing 选了哪 6 个 routed expert,输出至少有 shared expert 的"基线贡献"。这避免了"所有 routed expert 同时'失误'导致输出崩塌"的灾难情况。

承重墙作用二:减轻 routed expert 的"通用知识"负担

如果没有 shared expert,每个 routed expert 都必须既学"通用语言能力"又学"特定领域知识"——容量被分摊。有了 shared expert 兜底,routed expert 就不必各自重复存一份通用知识,容量更多地用于差异化。减少专家之间的知识冗余,正是 DeepSeekMoE 论文论证的核心收益。

承重墙作用三:训练梯度的"基础流"

shared expert 永远参与每 token 计算——它收到的是"所有 token"的梯度,是稳定的"基础流"。routed expert 只在被选中时收到梯度——梯度流是脉冲式的,随路由变化而波动。这是一种直观理解,报告与论文都没有就此给出实测。

承重墙作用四:推理时的"固定成本"

推理时每 token 必算 shared expert——这部分的计算量与延迟是确定的(不依赖 routing 选择),而且每个 rank 都有完整副本、不需要跨卡通信。但要分清:它只是给延迟加了一段固定成本,并不能消除 routed 部分的抖动——token 被分到忙的 rank 时,延迟照样会被那个 rank 拖慢。

需要澄清一点:DeepSeekMoE 论文并没有论证"1 个 shared expert 最优"——它论证的是"隔离出共享专家"这个结构本身有收益,具体数量各代不同(DeepSeekMoE-16B 与 V2 用的是 2 个,V3 与 V4 用的是 1 个)。把三代的共享容量(n_shared_experts × moe_intermediate_size)摆一起看就清楚了:V2 是 2 × 1536 = 3072,V3 是 1 × 2048 = 2048,V4 是 1 × 3072 = 3072——数量从 2 降到 1,容量并没有等比缩水。源码里还有一行硬性约束:assert args.n_shared_experts == 1——这一版实现只支持 1 个。


9.14 Expert 在 V4 vs 其他 MoE 上的实现细节差异

V4 的 Expert 实现与同期其他 MoE 模型在几个细节上有差异,了解这些差异有助于跨模型迁移代码。

差异 1:FP4 vs FP8 expert weight

V4:routed expert 是 FP4 e2m1(expert_dtype="fp4"),shared expert 走 default_dtype,即本配置下的 FP8 e4m3。 Mixtral / Qwen3:所有 expert 是 BF16 / FP8。

迁移影响:从 V4 借鉴 Expert 类时,需要正确处理 dtype 路径——简单设 dtype=BF16 会改变模型行为。

差异 2:swiglu_limit 的存在与否

V4:swiglu_limit=10,强制 clip。 Llama 4 / Mistral:通常没有 clip;技术报告引用的 gpt-oss(OpenAI 2025)则有类似的 SwiGLU clamp。

迁移影响:这个 clip 是为训练稳定性加的、贯穿整个训练(技术报告第 4.2.3 节),与权重是不是 FP4 无关——加载 V4 权重时不能去掉;自己从头训练的模型要不要加,看训练中是否出现离群值和 loss spike。

差异 3:weights 参数的位置

V4:weights 在 Expert.forward 里被乘到 SwiGLU 输出上 —— x = weights * x。 Mixtral:weights 在 MoE 层外面被加权——expert 输出乘 weights 再 sum。

差异是"weights 乘的位置"——V4 在 expert 内部、Mixtral 在 expert 外部。两种实现数学上等价,但 V4 的方式让 expert 是"自包含"的,便于单元测试。

差异 4:FP32 中间计算

V4:gate / up、二者的乘积和乘 routing weight 都在 FP32 中算,进 w2 前转回 dtype。 Mixtral / Llama 4:通常全程 BF16。

差异原因:源码 docstring 只写了 "for stability"。BF16 与 FP32 动态范围相同,谈不上溢出,FP32 换来的是尾数精度(见 §9.4);这层额外精度要多少收益,官方没有给出对照。

差异 5:.float() 还是 .to(torch.float32)

V4 用 .float() —— 这是 PyTorch 习惯写法,等价于 .to(torch.float32)。

迁移影响:无差异,但代码风格保持一致更易读。

理解这 5 个差异后,从 V4 借鉴 Expert 类到其他模型只需要"裁掉 V4 特定的部分(FP4 / clip / FP32 中间)" —— 核心 SwiGLU 结构通用。


9.15 Expert 在"专家利用率监控" 上的工程实践

部署 V4 后必须监控每层 MoE 的 expert 利用率分布——这是检测训练稳定性 + 部署健康度的核心指标。

先说清楚:下面这些阈值没有官方依据,是部署监控里常用的经验起点。正确的用法是先在自己的流量上跑一周、把基线量出来,再用基线的偏离量报警,而不是照抄这里的数字。

基线该长什么样:

每层 384 expert 的接收 token 比例,在流量分布稳定时应该是一条低方差的曲线:没有 expert 长期 0 load,也没有少数 expert 吃掉大部分流量。绝对的离散度取决于你的流量有多"窄"——只跑代码补全的服务,expert load 天然比通用聊天更集中,这不是异常。

异常 1:长尾分布(某些 expert 几乎不用)

症状:一批 expert 的接收量相对基线断崖式下滑,或长期为 0。 含义:bias 调节不充分,某些 expert"训练不足"。 应对:检查 bias 配置、必要时回滚到上一版本。

异常 2:路由塌陷(少数 expert 主导)

症状:少数 expert 的接收量相对基线显著抬升,分布迅速右偏。 含义:路由学到病态分布。 应对:紧急切换到其他实例、调查根因。

异常 3:同一输入两次 forward 的 expert 选择不一致

症状:完全相同的 batch 跑两遍,indices 不同。 含义:这是确定性问题,不是均衡问题——两个 expert 的 score 落在浮点可分辨精度之内(§7.14)。在 TP 部署下还可能来自 all_reduce 的求和次序。 应对:先确认不是硬件故障;若要求可复现,需要在引擎侧固定归约次序。

监控的实施:

在 vLLM / SGLang 中可以添加一个 hook:MoE forward 后记录 indices.flatten().bincount(),定期上报到 Prometheus,Grafana 配仪表板显示每层的 expert load 直方图——一眼看出异常。注意这个统计本身有成本,采样上报(比如每 100 次 forward 记一次)比全量记录更实际。


9.16 延伸阅读

  • DeepSeekMoE 论文(arXiv:2401.06066):shared expert 的源头
  • SwiGLU 论文(arXiv:2002.05202):SwiGLU 提出
  • GShard 论文(arXiv:2006.16668):早期 MoE 容量管理
  • 本专栏第 7 章:Gate 输出如何驱动本章的 expert 选取
  • 本专栏第 12 章:FP4 expert 权重的精度细节

9.17 本章小结

  • V4 的 Expert 是标准 SwiGLU FFN,但加了 swiglu_limit=10 的 clip——技术报告说它贯穿整个训练,用来压制 MoE 层的离群值、稳定训练
  • routed expert 走 FP4 e2m1(expert_dtype="fp4",04-27 才写进 HF config.json,inference/config.json 首发即有),shared expert 走 default_dtype 即 FP8 e4m3——精度分级
  • MoE.forward 用 bincount + 跳过空 expert + per-rank 循环 + all_reduce 调度 384 个 expert
  • TP 切分用"稀疏 ModuleList + None 占位"——代码简洁,每 rank 只实例化自己的 expert
  • V4 没有硬性容量限制——靠 noaux_tc bias(加上 0.0001 的序列级均衡损失)在训练中均衡
  • shared expert 永远参与每 token 计算——承担"通用知识",配合 384 routed expert 的"细分知识"

第 10 章我们离开 MoE 引擎,进入 V4 最具实验性的设计:Hyper-Connections——hc_mult=4 替代传统残差。