Prhub

#33764 Fix the router GEMM inaccuracy when using _front_w in Kimi-K3

原始 PR 作者 b8zhong 合并时间 2026-08-09 03:23 文件变更 8 提交数 12 评论 5 代码增减 +216 / -59

执行摘要

K3 融合 GEMM 改发 fp32 路由 logits,修复专家选择精度

PR body 明确指出:Kimi-K3 的 MoE 层在 bf16 下计算 router logits,专家选择基于被舍入的 logits 而不准确。实测 layer-1 gate 权重加 fp32 correction bias 后,5.27% 的 token 得到不同的 top-16 专家集合,平均 0.33%(p99 6.4%)的路由权重流向不同专家。vLLM 与 SGLang 自己的 EP front 都用 out_dtype=torch.float32,而融合 front 因 gate_up 与 latent 两个消费者需要 bf16,被迫把 router logits 也压成 bf16。PR body 还引用 PR#29783 先例:同样的 bf16 router 问题让 GLM-5.2 的 SWE-Bench Verified 从 79 掉到 67,且该路径按 num_tokens 分流,大 batch 评测难暴露、小 batch 才显现,诊断成本极高。

值得精读。三个设计决策很有借鉴价值:(1) 用「输出 fp32、消费者吸收」化解融合 GEMM 的单 dtype 冲突,且因 TGV 累加器本来就是 fp32,精度修复几乎零成本;(2) 用 bf16(round(fp32)) bit-identical 论证把 fp32 契约安全扩展到所有 runner,避免新增条件分支;(3) JIT 内核以 (in, out) dtype 二元组做模板与编译缓存管理。需要注意 _fi_kernel 私有字段耦合与测试缺口,若后续接手该路径,建议先补 dtype 契约单测。

讨论亮点

BBuf 在 APPROVE review 中提问 "Any end2end acc can be reported?",要求提供端到端精度数据;b8zhong 回应正在用 perf 与 acc 数据验证,但 PR 最终未附上精度数字,以端到端性能数据(均值 -0.11%)作为合入依据。b8zhong 主动披露曾考虑解耦 router 单独跑 GEMM 的备选方案,但 tinyN GEMM 才能保住性能,multi-streaming 或串行化都会造成 4–5% E2E 回退,因此保留融合、只把输出改成 fp32 是更优折中。性能验证方面,b8zhong 给出 TP-8 B300 1K/1K 各 batch size(bs 1–52)数据,最大偏差 +0.31%/-0.68%,均值 -0.11%,判定为噪声。

实现拆解

  1. GEMM 后端支持 fp32 输出python/sglang/kernels/ops/gemm/cutedsl_bf16_gemm.pyTgvGemmCuteExtKernel 新增 out_dtype 参数(默认 bf16),编译产物命名与缓存 key 都加入输出 dtype,避免 bf16/fp32 两个 epilogue 变体在编译缓存中串号;新增 _TORCH_TO_CUTLASS_OUT_DTYPE 的 fp32 映射。kimi_k3.py_k3_bf16_gemm 增加 out_dtype 参数:TGV 路径直接走 cutedsl_bf16_gemm_out,其余路径用支持 out_dtypetorch.mm,并顺手删掉非连续 out 的暂存拷贝分支。
  2. 模型入口切换 fp32 frontkimi_k3.py 新增 cached property _front_fp32(非 HIP、满足 _eligible_for_fused_front 且合并权重为 bf16 才启用),_merge_front_weights 的缓存失效列表同步加入它;_forward_fused 据此以 out_dtype=torch.float32 调用融合 GEMM,torch.split 得到的三段切片共享同一 fp32 张量。
  3. 下游消费者吸收 fp32activation.pysitu_and_mul.cuh 把激活内核模板从单一 T 拆成 TIn/TOut,JIT 缓存 key 改为 (in_dtype, out_dtype) 二元组,fp32 输入默认落回 bf16 输出;per_token_group_quant.cuhQuantTraitkVecSize 固定为 16 元素并新增 run_fp32 路径,route_quant_fused.cuh 模板化为 RouteQuantTraitT<TX>,按 score 与 activation 的 dtype 组合在 4 个特化间分派;per_token_group_quant.pymoe_route_quant_fused.py 的 dtype 白名单加入 fp32。
  4. 覆盖全部 CUDA runner 并保留 bf16 回退:新 cached property _moe_front_needs_dense_bf16 取代原 _moe_front_needs_contiguous,只有 SM100 trtllm-gen mxfp4 runner(_fi_kernel == "trtllm_sm100"precision == "default")直接消费 fp32 strided 切片;marlin、triton、SM90/SM120 cutlass mxfp4 与 precision="bf16" 走 dense bf16 修复路径,靠 bf16(tgv_fp32) == tgv_bf16 保证 routed_input 与旧输出 bit-identical;ROCm 保持 bf16 front。
  5. 测试与验证配套:本 PR 未新增自动化测试文件,验证以 CUPTI 微基准和 B300 TP8 1K/1K 端到端数据为主,并通过 /rerun-test test_kimi_k3_b300.py/rerun-failed-ci 补跑 e2e 与失败 CI;PR 说明中注明 main 上 test_v2_jit_matches_aot 已有 66 例失败,与本 PR 无关。
文件 模块 状态 重要度
python/sglang/srt/models/kimi_k3.py 模型层 modified 8.04
python/sglang/kernels/ops/kimi_k3/activation.py 激活内核 modified 4.93
python/sglang/kernels/ops/gemm/cutedsl_bf16_gemm.py GEMM 后端 modified 4.81
python/sglang/kernels/jit/csrc/gemm/per_token_group_quant.cuh 量化内核 modified 4.69
python/sglang/kernels/jit/csrc/moe/route_quant_fused.cuh 路由量化 modified 4.41
python/sglang/kernels/jit/csrc/kimi_k3/situ_and_mul.cuh 激活内核 modified 3.57
python/sglang/kernels/ops/quantization/per_token_group_quant.py 量化内核 modified 2.92
python/sglang/kernels/ops/moe/moe_route_quant_fused.py 路由量化 modified 2.64

关键符号

_front_fp32 _moe_front_needs_dense_bf16 _k3_bf16_gemm _forward_fused situ_and_mul _jit_situ_and_mul_module QuantTrait::run_fp32 situ_and_mul_kernel route_quant_fused_kernel

关键源码片段

python/sglang/srt/models/kimi_k3.py data-contract

模型入口,新增 `_front_fp32` 决策与 `_forward_fused` 的 fp32 输出切换,并用 `_moe_front_needs_dense_bf16` 取代 `_moe_front_needs_contiguous`,定义了整个 fp32 front 的数据契约。

# 融合 front 是否输出 fp32:让 router 读到精确 logits。
# situ 激活与 flashinfer_mxfp4 量化器直接消费 fp32 切片;
# 其他 runner 在 _forward_fused 里把 routed_input 舍回 bf16,
# 与 bf16 front 的输出 bit-identical
@cached_property
def _front_fp32(self) -> bool:
    # 仅在非 HIP 且融合 front 合格的 bf16 权重上启用:
    # ROCm 的 torch.mm out_dtype 与 aiter 路由路径未在 fp32 链路上验证
    return (
        not _is_hip
        and self._eligible_for_fused_front
        and self._front_w.dtype == torch.bfloat16
    )# ---- _forward_fused 中的调用点 ----
# 融合 front 用一次 GEMM 同时产出 gate_up / router_logits / routed_input
# 三段切片;输出 fp32 后 router 读到精确 logits,另两个消费者直接读 fp32,
# 省掉一次 cast 的额外 launch。TGV 累加器本就是 fp32,输出 fp32 只是去掉
# epilogue 里的 bf16 转换,几乎零成本
fused = _k3_bf16_gemm(
    hidden_states,
    self._front_w,
    out_dtype=torch.float32 if self._front_fp32 else None,
)
gate_up, router_logits, routed_input = torch.split(
    fused, self._front_sizes, dim=-1
)
if num_tokens > 1 and _is_hip and not _aiter_k3_opt:
    router_logits = router_logits.contiguous()
if self._moe_front_needs_dense_bf16:
    # fp32 front 下 cast 本身产生 dense buffer,contiguous() 是空操作;
    # bf16 front 下 cast 是空操作,contiguous() 负责拷贝。
    # 因为 bf16(tgv_fp32) == tgv_bf16(同一 fp32 累加器只多一次舍入),
    # routed_input 与改动前 bit-identical
    routed_input = routed_input.to(hidden_states.dtype).contiguous()
python/sglang/kernels/ops/kimi_k3/activation.py infrastructure

SiTU 激活的宿主封装,JIT 模块缓存 key 从单一 dtype 改为 (in, out) 二元组,fp32 输入默认落回 bf16 输出,是所有 CUDA runner 共享的消费者改造。

# 融合 SiTU(SoftCap-GLU)激活:输入 bf16 或 fp32,输出 dtype 由 out 决定。
# fp32 输入时默认落回 bf16 输出——内核内部本就按 fp32 计算,输入变宽只
# 改变加载宽度,输出保持 bf16 与下游契约一致
def situ_and_mul(
    input: torch.Tensor,
    out: Optional[torch.Tensor],
    beta: float,
    linear_beta: Optional[float],
) -> torch.Tensor:
    hidden_size = input.shape[-1] // 2
    if out is None:
        out_dtype = torch.bfloat16 if input.dtype == torch.float32 else input.dtype
        out = input.new_empty(*input.shape[:-1], hidden_size, dtype=out_dtype)
    # 2D 输入可能是行 stride(例如融合 GEMM 输出的一段切片),需要显式
    # view;更高维输入保持 dense-view 路径
    if input.dim() == 2 and input.stride(0) != 1:
        input_2d = input.view(-1, input.shape[-1])
        out_2d = out.view(-1, hidden_size)
    else:
        input_2d, out_2d = input, out
    has_linear_beta = linear_beta is not None
    # JIT 模块缓存 key 从单一 dtype 改为 (in_dtype, out_dtype) 二元组,
    # bf16 -> bf16 与 fp32 -> bf16 是两个独立编译产物
    module = _jit_situ_and_mul_module(input_2d.dtype, out_2d.dtype)
    module.run(
        input_2d,
        out_2d,
        beta,
        1.0 / beta,
        linear_beta if has_linear_beta else 0.0,
        1.0 / linear_beta if has_linear_beta else 0.0,
    )
    return out
python/sglang/kernels/jit/csrc/moe/route_quant_fused.cuh entrypoint

路由 + 组量化融合内核模板化为 `RouteQuantTraitT<TX>`,launcher 按 score 与 activation 的 dtype 组合分派 4 个特化,是 fp32 契约真正落到 runner 内核的入口。

// Activation dtype 模板化:同一份“路由 + 组量化”融合内核覆盖 bf16 / fp32
// 两种输入。路由打分(score)恒走 fp32;量化段按 TX 选择 QuantTrait
// 特化,fp32 输入走新增的 run_fp32 路径(kVecSize 固定为 16 元素)
template <typename TX>
using RouteQuantTraitT = QuantTrait<
    TX, fp8_e4m3_t,
    /*kGroupSize=*/32, /*kUe8m0=*/true,
    /*kRowMajor=*/true, /*kAligned=*/true, /*kFuseSiluAndMul=*/false>;// 启动时按 score 与 activation 的 dtype 组合在 4 个特化内核间分派;
// 只有 trtllm-gen SM100 mxfp4 runner 会以 fp32 strided 切片走到 fp32 分支
if (score_dtype.is_type<fp32_t>()) {
    if (x_dtype.is_type<fp32_t>()) {
        SGL_ROUTE_QUANT_LAUNCH(fp32_t, fp32_t);
    } else {
        SGL_ROUTE_QUANT_LAUNCH(fp32_t, bf16_t);
    }
} else {
    if (x_dtype.is_type<fp32_t>()) {
        SGL_ROUTE_QUANT_LAUNCH(bf16_t, fp32_t);
    } else {
        SGL_ROUTE_QUANT_LAUNCH(bf16_t, bf16_t);
    }
}

评论区精华

端到端精度结果请求 测试

BBuf 在 APPROVE review 中提问 "Any end2end acc can be reported?",要求提供端到端精度验证数据。

结论:b8zhong 在 issue 评论说明正在用 perf 与 acc 数据验证,并给出 1K/1K 端到端性能数据(均值 -0.11%,噪声内);PR 最终未附上精度数字即合并。 · 已解决

是否解耦 router GEMM 的备选设计 设计

b8zhong 主动提出可考虑拆开 fusion 单独跑 router GEMM,但 tinyN GEMM 才能保住性能,multi-streaming 或串行化都会造成 4–5% E2E 回退。

结论:采用「保留融合、GEMM 直接写 fp32」方案,解耦方案被否。 · 已解决

跨 batch size 性能回归验证 性能

b8zhong 公布 TP-8 B300 1K/1K 各 bs 数据:最大偏差 +0.31%/-0.68%,均值 -0.11%,判定为噪声;随后 /rerun-failed-ci 重跑失败 CI。

结论:性能无回退,合入前补跑了 K3 B300 e2e 与失败 CI。 · 已解决

风险与影响

  1. kimi_k3.py_moe_front_needs_dense_bf16 依赖 method._fi_kernel == "trtllm_sm100" 这一私有字段做契约判据,flashinfer 侧内核命名或版本升级可能让 fp32 strided 切片被错误直接消费,产生隐性数值错误。
  2. 共享专家激活数值变化:SiTU 现在读 fp32 值,相对 fp32 参考的最大相对误差从 3.9e-03 降到 2.5e-03(更准),但输出与旧版本不再 bit 一致,属于行为变更,对依赖旧输出的长尾评测可能产生微小偏差。
  3. bit-identical 论证只覆盖 TGV 路径:bf16(tgv_fp32) == tgv_bf16 依赖同一 fp32 累加器单次舍入;非 TGV 路径(cuBLAS out_dtype)的舍入一致性未在 PR 中显式论证。
  4. CUDA/ROCm 分叉:_front_fp32_is_hip 硬编码关闭,AMD 上 K3 仍保留 bf16 router,路由精度问题在 ROCm 未修复,两平台数值行为不一致。
  5. 性能拐点:非 trtllm runner 的 1-token batch 因 cast 多一次 launch(前端 GEMM + cast 从 20.45 us 到 21.95 us),虽然 e2e 平均无回退,但单 token 场景有固定小开销。
  6. 测试缺口:无新增自动化测试覆盖 fp32 front 的 dtype 契约、4 个 route_quant_fused 特化分派与 bit-identical 保证,回归主要靠手工 E2E;且 main 上 test_v2_jit_matches_aot 66 例失败使量化内核测试基线不干净。

精度上,所有 CUDA 部署(SM90 + Blackwell)的 Kimi-K3 路由选择更接近 fp32 参考,top-16 专家集合与路由权重分布误差显著收敛,参照 GLM-5.2 先例可避免 SWE-Bench 类评测在 bf16 router 下掉分。性能上,融合结构保留(单 GEMM 读一次激活),且 fp32 输出让 SiTU 延迟敏感内核线程翻倍,每 MoE 层微基准 -0.67~-1.28 us,B300 TP8 端到端 1K/1K 均值 -0.11% 无回退。代码面上,per_token_group_quantroute_quant_fusedsitu_and_mul 三个共享 JIT 内核的 dtype 组合从 1 个扩到 2–4 个编译特化,编译时间与缓存体积上升;契约从 runner backend 决定 contiguous 与否改为 runner 内核决定 dense/bf16 与否,后续新增 runner 或内核需同步更新 _moe_front_needs_dense_bf16 判定,新增维护面。

核心路径变更 缺少测试覆盖 私有字段耦合 CUDA/ROCm 分叉 数值行为变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论