Prhub

#38176 keeping router GEMM in fp32 for deterministic inference (DeepSeek V3/V4)

原始 PR 作者 xikronz 合并时间 2026-09-15 16:16 文件变更 2 提交数 5 评论 6 代码增减 +29 / -15

执行摘要

DeepSeek V3/V4 确定性推理下 router GEMM 保持 fp32,避免专家选择翻转

PR body 明确说明这是 issue #34758(确定性推理)下的 wip:"under deterministic inference the DeepSeek router GEMM falls back to F.linear, which stores buffers in bf16"。而 MoEGate.forward 的其他路径(dsv3_router_gemm/linear_bf16_fp32、prefill-CP)都已经返回 fp32;这些 logits 会进入 noaux_tc 的 top-k(256/384 专家)并叠加 fp32 修正 bias,"using bf16's 8-bit mantissas here can change which experts are selected when scores sit within one ULP"。作者同时指出根因:确定性 matmul 早已有 fp32 累加与 fp32 store 分支,但"it was unreachable because the output buffer was always allocated as the input dtype",而 batch invariance 只关心归约顺序、不关心输出 dtype。

值得精读,尤其适合关注确定性推理、MoE 路由数值稳定性和自研 kernel 接口演进的工程师。重点看三处设计决策:一是把 dtype 语义交给猴补丁后的 torch.mm 而非硬 import kernel,换取解耦但引入隐式契约;二是 matmul_persistent 中「输出缓冲决定 fp32 store 分支可达性」这一根因,以及 triton 调优参数仍按输入 dtype 选取的取舍;三是 _mm_dtype_compat 去掉 .to(out_dtype) 后对全局调用点的影响面。建议后续补一个针对 router logits dtype 与 top-k 选择一致性的单测,并显式校验 deterministic 模式与 batch-invariant 补丁的绑定关系。

讨论亮点

讨论集中在两处,且都被作者采纳:

  1. b8zhong 建议不要直接 import kernel,而是复用猴补丁入口:"Is it possible to call: return torch.mm(hidden_states, self.weight.t(), out_dtype=torch.float32) And it could be monkeypatched directly right?"(指向当时还在显式 from sglang.srt.batch_invariant_ops import matmul_persistent 的版本)。作者回复 "ouuu good point",最终 head 版本确为 torch.mm(...),去掉了模型文件对 batch_invariant_ops 的硬依赖。
  2. b8zhong 对非 CUDA 回退行提出 "Can we change it to FP32 (through casts)",作者回 "good catch",落地为 F.linear(hidden_states.float(), self.weight.float(), None),对应 commit "adding fp32 to non cuda backends"。

BBuf 最终 APPROVED;无遗留未解决的技术分歧。

实现拆解

1. 改写 MoEGate 的确定性分派入口(python/sglang/srt/models/deepseek_v2.py)

原实现只有一行 return F.linear(hidden_states, self.weight, None),输出落回 bf16。现改为两支:CUDA/HIP 走 torch.mm(hidden_states, self.weight.t(), out_dtype=torch.float32),其他后端走 F.linear(hidden_states.float(), self.weight.float(), None)。这样确定性模式下 router logits 的 dtype 契约与 tiny_gemm_bf16、linear_bf16_fp32 等分支统一为 fp32,并且在任何 token 数下都不再触发 max_router_gemm_tokens 的 kernel 切换;非确定性路径逐字未改。

2. 把 out_dtype 打进批次不变量 matmul 内核(python/sglang/srt/batch_invariant_ops/batch_invariant_ops.py)

matmul_persistent 新增 out_dtype: torch.dtype | None = None,内部 out_dtype = out_dtype or a.dtype 保证既有调用点零行为变化;_matmul_persistent_triton 与 _matmul_persistent_deepgemm 都新增 out_dtype 形参,torch.empty 改按 out_dtype 分配。关键细节是 triton 侧调优参数仍取 **configs[a.dtype],因为加载与累加的数据类型没有变,变的只有输出缓冲的存储宽度,从而让 kernel 内既有的 fp32 store 分支变得可达。

3. 修掉输出转换中的二次舍入,并约束兜底路径

_mm_dtype_compat 由 matmul_persistent(...).to(out_dtype) 改为 matmul_persistent(..., out_dtype=out_dtype),避免 fp32→bf16→fp32 的双重舍入;这同时是 torch.mm(..., out_dtype=...) 在猴补丁下可直接工作的接口前提。einsum 兜底(_ENABLE_MM_FALLBACK_VARIANT)新增 out_dtype == a.dtype 门控,防止没有 fp32 累加语义的路径冒充 fp32 输出,否则回落到 persistent triton kernel。

4. 测试与 CI 配套

本 PR 未新增测试、配置或文档:改动仅 2 个源码文件,依赖既有的 test/registered/attention/test_deepseek_v3_deterministic.py 与 test/registered/unit/batch_invariant_ops/test_batch_invariant_ops.py(issue 评论显示这两个用例 rerun 后在 1-gpu-h100 上通过)。PR Test (Extra) 与 AMD ROCm 10 曾失败,最终靠多次 /rerun-failed-ci 收尾。提交序列共 5 个 commit:初版 → 依 review 补 "adding fp32 to non cuda backends" → 两次 merge main(由 b8zhong 操作)→ 一次 MoEGate 分支的 ruff-format 修复。

文件 模块 状态 重要度
python/sglang/srt/models/deepseek_v2.py 专家路由 modified 5.82
python/sglang/srt/batch_invariant_ops/batch_invariant_ops.py 批不变算子 modified 5.51

关键符号

MoEGate.forward matmul_persistent _matmul_persistent_triton _matmul_persistent_deepgemm _mm_dtype_compat

关键源码片段

python/sglang/srt/models/deepseek_v2.py core-logic

MoEGate.forward 的确定性推理分支从 F.linear(bf16 输出 ) 改为 CUDA/HIP 上的 torch.mm(out_dtype=torch.float32) 以及非 CUDA 后端的显式 fp32 cast,是本 PR 的行为入口;router logits 的 dtype 直接决定 256/384 专家 top-k 的结果。

class MoEGate(nn.Module):
    # __init__ 中已根据 config 与 weight dtype 决定 tiny_router_gemm_max_tokens 等属性
    def forward(
        self,
        hidden_states,
        gemm_output_zero_allocator: BumpAllocator = None,
        forward_batch: ForwardBatch = None,
    ):
        # 权重本身就是 fp32(未量化 / expert_pack 场景)时,直接做 fp32 线性层
        if self.weight.dtype == torch.float32:
            return F.linear(hidden_states.float(), self.weight)
​
        if use_intel_amx_backend(self):
            return torch.ops.sgl_kernel.weight_packed_linear(
                hidden_states,
                self.weight,
                None, # bias
                True, # is_vnni
            )
​
        # 确定性推理分支:本 PR 的核心改动点,之前这里只有一行
        # `return F.linear(hidden_states, self.weight, None)`,输出会落回 bf16
        if get_exec().deterministic.enable_deterministic_inference:
            if _is_cuda or _is_hip:
                # torch.mm 已被 batch_invariant_ops 猴补丁为 matmul_persistent,
                # 因此这里的 out_dtype 会一路透传到 persistent kernel:
                # 输出缓冲直接按 fp32 分配,既没有 bf16 中间结果,也不需要
                # compute-then-cast,自然避免了 fp32 -> bf16 -> fp32 的二次舍入。
                # 同时对任意 token 数都走同一条路径,不再受 max_router_gemm_tokens 影响。
                return torch.mm(hidden_states, self.weight.t(), out_dtype=torch.float32)
            # 非 CUDA / HIP 后端(NPU、CPU 等)不支持 out_dtype,
            # 退化为把两侧输入都 cast 成 fp32 再算,代价是两份额外 fp32 张量
            return F.linear(hidden_states.float(), self.weight.float(), None)
​
        # 非确定性路径保持原样:小 batch 走 tiny_gemm_bf16,其余按后端分派;
        # 这些分支本来就已经返回 fp32 logits,与确定性分支的输出契约一致
        if hidden_states.shape[0] <= self.tiny_router_gemm_max_tokens:
            logits = tiny_gemm_bf16(
                hidden_states,
                self.weight,
                out_dtype=torch.float32,
                max_m=self.tiny_router_gemm_max_tokens,
            )
        elif _use_aiter:
            logits = aiter_dsv3_router_gemm(hidden_states, self.weight)
        elif not _is_cuda:
            logits = F.linear(hidden_states, self.weight, None)
        else:
            # cuBLAS bf16 x bf16 -> fp32 GEMM(torch.mm 的 out_dtype 仅 CUDA 可用)
            from sglang.kernels.ops.attention.dsv4 import linear_bf16_fp32
​
            logits = linear_bf16_fp32(hidden_states, self.weight)
​
        return logits

评论区精华

用猴补丁后的 torch.mm 取代直接 import matmul_persistent 设计

b8zhong 在 python/sglang/srt/models/deepseek_v2.py 上指出,可以直接写 `return torch.mm(hidden_states, self.weight.t(), out_dtype=torch.float32)`,并追问 "And it could be monkeypatched directly right?"——当时版本还在模型文件里显式 `from sglang.srt.batch_invariant_ops import matmul_persistent`。作者回复 "ouuu good point"。

结论:采纳。最终 head 版本改为调用 torch.mm,由 batch_invariant_ops 的猴补丁把它路由到 matmul_persistent,模型层不再硬依赖内核模块;代价是隐式要求补丁已安装。 · 已解决

非 CUDA/HIP 分支也要 cast 到 fp32 正确性

b8zhong 针对回退行 `return F.linear(hidden_states, self.weight, None)` 提出 "Can we change it to FP32 (through casts)",指出只改 CUDA 分支会让其他后端仍在 bf16 下产生 logits;作者回复 "good catch"。

结论:采纳,落地为 `F.linear(hidden_states.float(), self.weight.float(), None)`,对应 commit "adding fp32 to non cuda backends",使所有后端在确定性模式下都输出 fp32。 · 已解决

CI 反复 rerun 才通过,确定性用例覆盖依赖既有测试 测试

b8zhong 多次执行 "/rerun-failed-ci again",PR body 中 PR Test (Extra) 与 AMD ROCm 10 一度为失败状态;机器人在 1-gpu-h100 上重跑 `test/registered/attention/test_deepseek_v3_deterministic.py` 与 `test/registered/unit/batch_invariant_ops/test_batch_invariant_ops.py` 后 2 个用例通过。

结论:合入前依赖既有确定性 / 批次不变量集成测试兜底,未补充针对 router logits dtype 的定向用例;CI 最终转绿。 · 已解决

风险与影响

  1. 依赖猴补丁的隐含契约:最终实现调用 torch.mm(..., out_dtype=torch.float32),只有 torch.mm 被 batch_invariant_ops 替换为 _mm_dtype_compat 时才合法。若出现「开启确定性推理但未安装批次不变量补丁」的组合(新增后端、独立配置路径、单测直接构造 MoEGate),该调用会落到原生 torch.mm;代码注释本身写着 torch.mm 的 out_dtype 仅 CUDA 可用,因此在非 CUDA 上更可能直接抛 TypeError 而不是静默降级。
  2. 共享内核输出契约变更:_mm_dtype_compat 从「按输入 dtype 算完再 .to(out_dtype)」变为「直接以 out_dtype 分配输出」,凡是带 out_dtype 的 matmul 调用点(不止 router GEMM)输出位模式都会变化。虽然方向上是更精确,但此前建立的确定性基线/重放结果可能出现差异,需要全量确定性用例复核。
  3. 非 CUDA/HIP 回退的内存与拷贝成本:F.linear(hidden_states.float(), self.weight.float(), None) 会额外物化 hidden_states 与 256/384×hidden_size 的 router 权重两份 fp32 张量,在 NPU/CPU 等后端只在确定性模式下可接受。
  4. 兜底语义被静默改变:einsum 变体在 out_dtype != a.dtype 时被跳过(即使 _ENABLE_MM_FALLBACK_VARIANT 打开),行为正确但调试时容易误判。
  5. 缺少针对性回归测试:本 PR 无测试变更,ULP 级专家翻转问题没有专门的 dtype/top-k 一致性单测,风险靠既有 deterministic 集成测试兜底;CI 的 Extra 与 AMD ROCm 工作流出现过失败需 rerun。

用户侧:开启 deterministic inference 的 DeepSeek V3/V4 部署,MoE 路由的专家选择在分数接近 ULP 时不再被 bf16 尾数翻转,可复现性更接近理论承诺;代价是 router logits 缓冲区从 bf16 变 fp32(256/384 专家的 logits 显存与写带宽约翻倍,但相对 KV cache 与专家权重属小量级)。系统侧:matmul_persistent 是确定性推理下被补丁的 torch.mm / matmul 等的统一后端,本次新增的 out_dtype 参数改变了所有此类调用的输出分配语义,属于共享基础设施层的契约调整,会影响后续所有依赖 batch invariance 的模型与内核。团队侧:确立了「输出 dtype 必须与累加 dtype 对齐,禁止 compute-then-cast」的约定,也为后续把确定性覆盖扩展到更多后端/模型提供了可复用的参数通道。

依赖猴补丁语义 共享内核输出契约变更 缺少针对性测试 非 CUDA 路径额外 fp32 拷贝

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论