DeepSeek V4 源码剖析
第9章 Expert 与共享专家:SwiGLU + clip + 容量平衡
"swiglu_limit": 10.0、"expert_dtype": "fp4"—— DeepSeek-V4-Proconfig.json
Expert类只有 20 行。但swiglu_limit这个 V3 时代没有的参数,和expert_dtype: "fp4"这个 4 月 27 日才补进 HFconfig.json的字段(inference/config.json首发即有),很容易被读成一因一果——按技术报告,它们其实各有来历:前者为稳定训练而加、贯穿整个训练,后者是后训练阶段 FP4 量化感知训练的产物。
9.1 引子:384 个 SwiGLU 同时工作的工程问题
V4 一层 MoE 包含 385 个 expert:384 个 routed + 1 个 shared。每个 expert 是一个独立的 SwiGLU FFN,参数独立,被选中的 expert 输出按路由权重加权后,与 shared expert 的输出相加。
把这套架构展开看,至少有四个工程问题:
- 数值稳定性:384 个 SwiGLU 同时训练,每个 expert 的"激活幅度"必须保持可控——否则反向传播的梯度会因为某些极端 expert 而不稳
- 容量平衡:每个 expert 接收的 token 数必须接近——否则训练梯度对某些 expert 长期不更新
- 共享专家的角色:shared expert 要承担"通用知识",但不能"主导"输出 —— routed 权重再乘的 route_scale=2.5 可以从这个角度理解(该值沿用 V3,官方未解释,见 §7.6)
- 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:投回dimdtype:可以是 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:
SiLU(g) = g · sigmoid(g)——也叫 Swish。⊙ 是逐元素乘。
把这个公式拆成三步:
gate = w_1 x:门控通道up = w_3 x:上投影通道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 个关键决策:
- 激活先被量化:
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 输出 - gate / up 升 FP32:docstring 明言 "Computation in float32 for stability"
- clamp 在 FP32 上做:它排在
.float()之后,截断、SiLU、相乘都在同一精度里完成 - intermediate 保 FP32:乘 routing weight 也在 FP32 里做,直到 w2 之前
- 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—— 在 时自己趋向 0,下界天然有界,不需要人管;而 时 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 才写进 HFconfig.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 替代传统残差。