执行摘要
- 一句话:用层级单元测试替换 GEMM e2e 矩阵,CI 提速并修复 block 检查
- 推荐动作:值得精读,特别是 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 调用方有交互。
功能与动机
三个 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 的数值错误在秒级暴露出来。
实现拆解
实现分四步:
-
新增 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。
-
新增 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 舍入异常。
-
删除三个 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)覆盖,默认路径精度由常规模型套件兜底。
-
修复 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(模块 量化测试;类别 test;类型 test-coverage;符号 _fp8_block_backends, _mxfp8_backends, _quantize_fp8_blockwise, _quantize_mxfp8): 新增的 FP8/MXFP8/per-tensor 层级单元测试主文件,覆盖三种量化格式、按 SM 自适应后端集合,包含核心数值校验逻辑 _check_backend,并首次在 CI 中覆盖 flashinfer_deepgemm 和 flashinfer_cutlass FP8-blockwise。
test/registered/unit/layers/quantization/test_nvfp4_linear_backends.py(模块 量化测试;类别 test;类型 test-coverage;符号 convert_swizzled_to_linear, break_fp4_bytes, dequantize_nvfp4_to_dtype, _make_quantized_layer): 新增的 NVFP4 层级单元测试,覆盖四个 SM100 后端,并包含 convert_swizzled_to_linear 修复(K-tile padding crop bug)及非对齐 shape 的 padding 路径测试。
python/sglang/srt/layers/quantization/fp8.py(模块 量化实现;类别 source;类型 core-logic;符号 validate_block_quant_shapes): 核心源码修改:修复 validate_block_quant_shapes 在 skip_block_quant_check 时仍读取 tp_size、需要分布式初始化的问题,使单元测试可独立运行。
test/registered/quant/test_fp8_blockwise_gemm.py(模块 量化测试;类别 test;类型 deletion;符号 FP8BlockwiseGemmBase, MXFP8GemmBase, TestFP8BlockwiseGemmTriton, TestFP8BlockwiseGemmDeepGemm): 被删除的 e2e 测试文件之一,原注册在 extra-b 阶段(约 430 秒),包含多个从未实际运行的测试类(flashinfer_deepgemm 被错误 gate,MXFP8 Triton 被无条件 skip)。
test/registered/quant/test_fp8_gemm_sm120.py(模块 量化测试;类别 test;类型 deletion;符号 FP8GemmSM120Base, TestFP8PerTensorGemmSM120Auto, TestFP8BlockwiseGemmSM120Auto): 被删除的 SM120 e2e 测试文件,原注册在 extra-a 阶段(约 146 秒),per-tensor 与 blockwise 各一个用例。
test/registered/quant/test_nvfp4_gemm.py(模块 量化测试;类别 test;类型 deletion;符号 FP4GemmBase, TestFP4GemmFlashinferCutlass, TestFP4GemmFlashinferCudnn, TestFP4GemmFlashinferTrtllm): 被删除的 NVFP4 e2e 测试文件,原注册在 base-c 阶段(约 350 秒),每个后端启动一个服务器跑 GSM8K。
关键符号: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
新增的 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
新增的 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
核心源码修改:修复 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}."
)
评论区精华
该 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 矩阵结构性失效的典型例子。
- CI rerun 结果:4-gpu-b200 偶发失败 (test): 无法确定失败原因,疑似 runner 环境抖动或数值敏感性;从最终 PR 合并状态看,重跑后通过。
- flashinfer_deepgemm 首次获得真实 CI 覆盖 (design): 设计上通过按 SM 自适应后端集合解决该覆盖盲区。
风险与影响
- 风险:
- 端到端覆盖缺失:删除三个 GSM8K e2e 后,量化后端在真实模型上的端到端精度不再被这些测试守护。PR body 已声明由模型 e2e 覆盖 CLI 接线、常规套件覆盖默认路径,但 per-backend 显式指定的精度回归(如 DeepGEMM 在特定模型上的精度问题)可能漏检。
- 参考实现偏差:新测试使用自写的反量化参考(如 convert_swizzled_to_linear、dequantize_nvfp4_to_dtype),若参考本身有误(例如 layout 理解错误),测试会得出错误结论。测试用宽松容差(rtol=5e-2)和 cosine > 0.99 缓解,但无法覆盖所有 kernel 细节。
- fp8.py 核心路径变更:validate_block_quant_shapes 是权重量化校验的公共路径,改动虽小(移动 tp_size 读取位置),但若未来有人依赖 skip 分支中的副作用会受影响;当前行为与之前非 skip 时完全一致,风险低。
- 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 新测试偶发失败, 量化校验路径源码变更
关联脉络
- PR #33611 [Test] Replace NVFP4 MoE runner backend e2e matrix with a layer-level unit test: 同一作者、同一模式:将 e2e 矩阵替换为层级单元测试,本 PR 的 convert_swizzled_to_linear 即从该 PR 涉及的 test_fp4_moe.py 适配而来。
- PR #33605 [CI] Make B200 base-b suites single-GPU as prep for 1-gpu B200 runners: 同期 CI 优化,调整 B200 测试套件分布,与本 PR 的 CI 时间节省目标一致。
- PR #33586 [CI] Trim redundant B200 test registrations: 同样在精简 B200 相关测试注册,与本 PR 的 e2e 矩阵移除同属 CI 减负系列。
参与讨论