Prhub

#33596 [Test] Replace GEMM backend e2e matrices with layer-level unit tests

原始 PR 作者 hnyls2002 合并时间 2026-08-05 06:50 文件变更 6 提交数 7 评论 4 代码增减 +421 / -318

执行摘要

用层级单元测试替换 GEMM e2e 矩阵,CI 提速并修复 block 检查

三个 e2e 测试文件(test_nvfp4_gemm.py、test_fp8_blockwise_gemm.py、test_fp8_gemm_sm120.py)每个后端都要启动一次服务器并跑完整 GSM8K 评测,单文件耗时 146-430 秒,且覆盖存在严重漏洞:TestFP8BlockwiseGemmFlashinferDeepGemm 被 skipIf(sm != 90) 门控在 B200 专属套件中永远不运行,TestMXFP8GemmTriton 带无条件 @unittest.skip,flashinfer_cutlass 的 FP8-blockwise 后端从未被 e2e 覆盖。PR body 指出这些文件本质是 flag 接线冒烟测试(来源于 #14379、#16534、#20717),而非后端正确性测试。新测试的目标是让每个后端的权重预处理(NVFP4 padding/interleave、TRTLLM shuffle、DeepGEMM UE8M0 scale requant、MXFP8 scale packing)和 GEMM dispatch 的数值错误在秒级暴露出来。

值得精读,特别是 test_nvfp4_linear_backends.py 中 convert_swizzled_to_linear 的 K-tile crop 修复和 test_fp8_blockwise_linear_backends.py 的按 SM 自适应后端集合设计。这两个文件是量化后端测试的优质模板,展示了如何在不启动服务器的情况下覆盖 kernel 专属的 padding / swizzle / scale 布局逻辑。同时建议关注 fp8.py 的修复是否与后续 skip_block_quant_check 调用方有交互。

讨论亮点

该 PR 没有 review 评论,主要交互来自 issue 的 /rerun-test 命令。第一次 rerun 在 4-gpu-b200 上两个测试均通过,第二次 rerun 中 4-gpu-b200 出现一次失败(❌),而 1-gpu-5090 和 1-gpu-h100 均通过。由于没有关联的失败日志或后续说明,这次失败大概率是 B200 runner 的环境抖动或偶发数值问题,但也提示新单元测试在 4-gpu 环境可能存在轻微 flakiness。PR body 中特别强调的两个历史覆盖漏洞值得关注:flashinfer_deepgemm 因 skip 条件与 runner 不匹配从未运行、MXFP8 Triton 被无条件跳过——这些正是 e2e 矩阵结构性失效的典型例子。

实现拆解

实现分四步:

  1. 新增 NVFP4 层级单元测试(test/registered/unit/layers/quantization/test_nvfp4_linear_backends.py):构造 ModelOptFp4LinearMethod 并在内存中构建含 NVFP4 checkpoint 格式权重的 linear 层,通过 mock.patch 替换 fp4_utils.FP4_GEMM_RUNNER_BACKEND 来遍历 flashinfer_cutedsl / cutlass / cudnn / trtllm 四个后端。关键工具函数 convert_swizzled_to_linear 从 test_fp4_moe.py 移植并修复了 K-tile padding crop 的 bug——原来只裁剪 M 维 padding,现在同时裁剪 K 维(按 k // block_size 列裁剪 scale)。测试使用 (5, 160, 336) 非对齐 shape 命中 TRTLLM 的 N->128 shuffle pad、scale K/16->4 pad 和 CUTLASS 的 K 32 对齐 pad。

  2. 新增 FP8 / MXFP8 / per-tensor 层级单元测试(test/registered/unit/layers/quantization/test_fp8_blockwise_linear_backends.py):覆盖 Fp8LinearMethod(blockwise 和 MXFP8)以及 ModelOptFp8LinearMethod(per-tensor auto dispatch)。后端集合按 get_device_sm() 动态决定:SM100/103 用 triton/deep_gemm/flashinfer_trtllm/flashinfer_cutlass,SM120 用 triton/cutlass,SM90 用 triton/deep_gemm/flashinfer_deepgemm。三个测试类共享 _check_backend 核心逻辑,用余弦相似度 > 0.99 和宽松的 rtol/atol 做数值断言,以容忍 DeepGEMM UE8M0 单元素 scale 舍入异常。

  3. 删除三个 e2e 文件:test/registered/quant/ 下的 test_nvfp4_gemm.py、test_fp8_blockwise_gemm.py、test_fp8_gemm_sm120.py 整体删除。PR body 说明 CLI 参数接线仍由显式传参的模型 e2e(如 test_kimi_k26_nvfp4_dflash.py、test_deepseek_r1_fp8_trtllm_backend.py)覆盖,默认路径精度由常规模型套件兜底。

  4. 修复 fp8.py 的 validate_block_quant_shapes:原实现无论是否 skip 都在函数入口调用 get_parallel().tp_size,导致设置 skip_block_quant_check 的单元测试在未初始化分布式组时崩溃。修复把 tp_size 读取移入非 skip 分支,行为对正常路径完全不变。

配套的 CI 注册:FP8 文件注册在 4-gpu-b200(est 120s)、1-gpu-small / 1-gpu-large(各 60s);NVFP4 文件注册在 4-gpu-b200(est 120s),新增测试均在 base-b 阶段运行,秒级完成。

文件 模块 状态 重要度
test/registered/unit/layers/quantization/test_fp8_blockwise_linear_backends.py 量化测试 added 7.76
test/registered/unit/layers/quantization/test_nvfp4_linear_backends.py 量化测试 added 7.5
python/sglang/srt/layers/quantization/fp8.py 量化实现 modified 4.89
test/registered/quant/test_fp8_blockwise_gemm.py 量化测试 removed 7.27
test/registered/quant/test_fp8_gemm_sm120.py 量化测试 removed 6.91
test/registered/quant/test_nvfp4_gemm.py 量化测试 removed 6.91

关键符号

convert_swizzled_to_linear break_fp4_bytes dequantize_nvfp4_to_dtype _check_backend _quantize_fp8_blockwise _quantize_mxfp8 validate_block_quant_shapes

关键源码片段

test/registered/unit/layers/quantization/test_fp8_blockwise_linear_backends.py test-coverage

新增的 FP8/MXFP8/per-tensor 层级单元测试主文件,覆盖三种量化格式、按 SM 自适应后端集合,包含核心数值校验逻辑 _check_backend,并首次在 CI 中覆盖 flashinfer_deepgemm 和 flashinfer_cutlass FP8-blockwise。

# 核心校验逻辑:对每个后端、每个 shape 执行量化层全流程并断言数值
class _LinearBackendCheck(CustomTestCase):
    def _check_backend(self, backend: str, allowed, shapes, build_layer):
        # 后端不在当前 SM 的支持集合里就直接跳过,保证同一文件可跨 SM 运行
        if backend not in allowed:
            self.skipTest(f"{backend} not in SM{get_device_sm()} backend set")
        torch.manual_seed(7)
        for m, n, k in shapes:
            with self.subTest(backend=backend, shape=(m, n, k)):
                # 用 mock 替换全局 GEMM runner 选择,模拟 --fp8-gemm-backend 指定
                with mock.patch.object(
                    fp8_utils,
                    "FP8_GEMM_RUNNER_BACKEND",
                    Fp8GemmRunnerBackend(backend),
                ):
                    method, layer, w_dequant = build_layer(n, k)
                    method.process_weights_after_loading(layer)
​
                    x = torch.randn((m, k), device="cuda", dtype=torch.bfloat16) / 10
                    out = method.apply(layer, x)
​
                    # 参考 = 反量化权重矩阵乘,不经过任何 kernel 优化
                    ref = x.float() @ w_dequant.T
                    self.assertEqual(out.shape, (m, n))
                    cos = torch.nn.functional.cosine_similarity(
                        out.float().flatten(), ref.flatten(), dim=0
                    ).item()
                    self.assertGreater(cos, 0.99)
                    # atol 容忍 DeepGEMM UE8M0 单元素 scale 舍入异常;
                    # 真正的 kernel/layout 错误会差出数量级,宽松阈值仍能抓住
                    torch.testing.assert_close(out.float(), ref, rtol=5e-2, atol=1e-1)
test/registered/unit/layers/quantization/test_nvfp4_linear_backends.py test-coverage

新增的 NVFP4 层级单元测试,覆盖四个 SM100 后端,并包含 convert_swizzled_to_linear 修复(K-tile padding crop bug)及非对齐 shape 的 padding 路径测试。

# 将 flashinfer swizzled scale 布局转回 linear 布局,供参考反量化使用
def convert_swizzled_to_linear(a_sf_swizzled: torch.Tensor, m, k, block_size):
    m_tiles = (m + 128 - 1) // 128
    f = block_size * 4
    k_tiles = (k + f - 1) // f
    tmp = torch.reshape(a_sf_swizzled, (1, m_tiles, k_tiles, 32, 4, 4))
    tmp = torch.permute(tmp, (0, 1, 4, 3, 2, 5))
    out = tmp.reshape(m_tiles * 128, k_tiles * f // block_size)
    # 关键修复:除了裁剪 M 维的 128 对齐 padding,还必须裁剪 K 维 tile padding。
    # 原来从 test_fp4_moe.py 移植的实现只裁了 M 维,在非对齐 K 时会残留错误列
    return out[0:m, 0 : k // block_size]
​
​
# NVFP4 反量化参考:把 uint8 双 packed fp4 拆成高低 nibble 并映射到 float
def dequantize_nvfp4_to_dtype(
    tensor_fp4, tensor_sf, global_scale, dtype, device, block_size=16
):
    assert tensor_fp4.dtype == torch.uint8
    m, packed_k = tensor_fp4.shape
    k = packed_k * 2
    tensor_f32 = break_fp4_bytes(tensor_fp4, torch.float32)
    tensor_f32 = tensor_f32.reshape(m, k // block_size, block_size)
    tensor_sf = tensor_sf.view(torch.float8_e4m3fn)
    tensor_sf = convert_swizzled_to_linear(tensor_sf, m, k, block_size)
    tensor_sf_dtype = tensor_sf.to(torch.float32) / global_scale
    out = (tensor_f32 * tensor_sf_dtype.unsqueeze(-1)).reshape(m, k)
    return out.to(dtype=dtype)
python/sglang/srt/layers/quantization/fp8.py core-logic

核心源码修改:修复 validate_block_quant_shapes 在 skip_block_quant_check 时仍读取 tp_size、需要分布式初始化的问题,使单元测试可独立运行。

@staticmethod
def validate_block_quant_shapes(
    quant_config,
    input_size: int,
    input_size_per_partition: int,
    output_size: int,
    output_size_per_partition: int,
    output_partition_sizes: List[int],
    skip_block_quant_check: bool = False,
):
    block_n, block_k = (
        quant_config.weight_block_size[0],
        quant_config.weight_block_size[1],
    )
​
    if skip_block_quant_check:
        # 跳过校验时不再触碰分布式全局状态,让单进程单元测试也能走通
        print_warning_once(
            "Skipping block quantization checks for weight partition."
        )
    else:
        # 仅在真正需要校验时才读取 TP 世界大小,避免未初始化分布式组时崩溃
        tp_size = get_parallel().tp_size
        # row parallel 约束:输入分区需整除 block_k
        if tp_size > 1 and input_size // input_size_per_partition == tp_size:
            if input_size_per_partition % block_k != 0:
                raise ValueError(
                    f"Weight input_size_per_partition = "
                    f"{input_size_per_partition} is not divisible by "
                    f"weight quantization block_k = {block_k}."
                )
        # column parallel / merged weights 约束:每个输出分区需整除 block_n
        if (
            tp_size > 1 and output_size // output_size_per_partition == tp_size
        ) or len(output_partition_sizes) > 1:
            for output_partition_size in output_partition_sizes:
                if output_partition_size % block_n != 0:
                    raise ValueError(
                        f"Weight output_partition_size = "
                        f"{output_partition_size} is not divisible by "
                        f"weight quantization block_n = {block_n}."
                    )

评论区精华

CI rerun 结果:4-gpu-b200 偶发失败 test

PR 作者两次发起 /rerun-test。第一次在 4-gpu-b200 上两个测试均通过;第二次 4-gpu-b200 出现 ❌,而 1-gpu-5090 和 1-gpu-h100 均通过。没有附加失败日志或说明。

结论:无法确定失败原因,疑似 runner 环境抖动或数值敏感性;从最终 PR 合并状态看,重跑后通过。 · 已解决

flashinfer_deepgemm 首次获得真实 CI 覆盖 设计

PR body 指出 e2e 矩阵中 TestFP8BlockwiseGemmFlashinferDeepGemm 被 skipIf(sm != 90) 门控在 B200-only 套件内,实际从未运行;新单元测试按 SM 选择后端集合,SM90 上会真正执行 flashinfer_deepgemm。

结论:设计上通过按 SM 自适应后端集合解决该覆盖盲区。 · 已解决

风险与影响

  1. 端到端覆盖缺失:删除三个 GSM8K e2e 后,量化后端在真实模型上的端到端精度不再被这些测试守护。PR body 已声明由模型 e2e 覆盖 CLI 接线、常规套件覆盖默认路径,但 per-backend 显式指定的精度回归(如 DeepGEMM 在特定模型上的精度问题)可能漏检。
  2. 参考实现偏差:新测试使用自写的反量化参考(如 convert_swizzled_to_linear、dequantize_nvfp4_to_dtype),若参考本身有误(例如 layout 理解错误),测试会得出错误结论。测试用宽松容差(rtol=5e-2)和 cosine > 0.99 缓解,但无法覆盖所有 kernel 细节。
  3. fp8.py 核心路径变更:validate_block_quant_shapes 是权重量化校验的公共路径,改动虽小(移动 tp_size 读取位置),但若未来有人依赖 skip 分支中的副作用会受影响;当前行为与之前非 skip 时完全一致,风险低。
  4. CI 偶发失败:第二次 rerun 中 4-gpu-b200 出现过失败,需关注是否由新测试引入的资源竞争或 kernel 数值敏感导致。

对 CI 基础设施影响显著:base-c、extra-a、extra-b 三个阶段合计减少约 926 秒的单次 PR 运行时间,且将原本从未执行的 flashinfer_deepgemm 后端纳入真实覆盖,补上了 flashinfer_cutlass FP8-blockwise 的盲区。对量化后端开发者的影响是调试手段的转变——从启动服务器跑 GSM8K 变为秒级数值断言,能更快定位权重预处理或 kernel dispatch 的错误。对 SGLang 仓库的测试方法论也有示范作用(与 #33611 的 MoE 层测试形成系列),后续新增 GEMM 后端可以沿用该模板补充覆盖。

删除端到端精度覆盖 CI 新测试偶发失败 量化校验路径源码变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论