Prhub

#56560 [ROCm][DSv4.1][Perf] Dequantize the MXFP8 weight once when dot_scaled cannot be used

原始 PR 作者 JohnQinAMD 合并时间 2026-09-15 09:07 文件变更 2 提交数 4 评论 9 代码增减 +76 / -5

执行摘要

MXFP8 非对齐 K 权重改为加载时一次解量化,TP4/8 解码提速约 5%

RocmDotScaledMxfp8LinearKernel 依赖 tl.dot_scaled 原生 MXFP8 GEMM,但 dot_scaled 要求 K % 128 == 0;对不满足的权重(如 DeepSeek-V4.1 共享专家 down_proj 在 TP4/TP8 下 K 被切成 576/288),旧代码在 apply_weights 内每步调用 dequant_mxfp8_to_bf16 重建同一张 BF16 权重。PR body 量化了代价:dequant_mxfp8_to_bf16 是 6 个 pointwise kernel,FP32 中间值是 FP8 源的 4 倍且两次穿过 HBM,2.95 MB 权重产生约 56 MB 流量;TP4/TP8 下 40 层全部命中,每 decode token 产生约 2.24 GB HBM 流量。这也是 EmulationMxfp8LinearKernel 在 VLLM_MXFP8_EMULATION_DEQUANT_AT_LOAD 下已有的模式。

值得精读。核心看点有三:一是把每步重复的权重预处理上提到加载期,属于典型的“常量折叠”优化;二是 apply_weights 用 element_size 而非 dtype 做分派,并保留第三分支兜底,兼顾了正确性与灵活性;三是测试直接用生产真实 shape(K=576/288)做回归,并对模块级函数做 spy 断言,避免了只测合成 shape 的盲区。对于在 ROCm 上做量化 kernel 优化或维护 MXFP8 后端的工程师,这份 PR 是一个很好的范本。

讨论亮点

核心讨论来自 ChuanLi1101 的三轮 review,均已被作者解决:

  1. 分派安全缺口(correctness):ChuanLi1101 指出“apply_weights 丢失 unaligned fallback。如果权重仍是 FP8 且 K 非对齐,你会总是调用 dot_scaled,这在 K % 128 != 0 时是非法的。加载按 K % 128 判断、应用按 element_size 判断,如果两者不一致(例如 process_weights_after_loading 未运行),就会走进非法路径。请保留每步解量化 fallback。”作者恢复第三分支,分派自恢复安全。
  2. 缺少测试(testing):ChuanLi1101 要求仿照 Emulation 已有的 test_mxfp8_linear_emulation_bf16_at_load 增加覆盖,验证“加载期 BF16 与每步解量化结果一致”且“仍为 FP8 的非对齐权重不调用 dot_scaled”。作者新增参数化回归测试。
  3. 文档过时(documentation):ChuanLi1101 指出文件头仍声称非对齐 K 会经 kernel selector fallback 到 emulation,但 can_implement 恒为 True、selector 根本看不到 K。作者更新 docstring,改为描述加载期解量化。
  4. 测试 shape 与生产不符(testing):ChuanLi1101 提出“回归测试用合成 K=2080/2048,实际 DSv4.1 问题 shape 是 K=576/288,请至少加一个真实生产 shape”。作者加入 (5120, 576) 与 (5120, 288),并移除与 288 同余的冗余 K=2080。
  5. mypy pre-commit 失败(style):mergify 多次提示 pre-commit 失败,作者将 _spy 从 calls.append(1) or real(...) 的 lambda 改为命名函数,修复 list.append 返回 None 导致的 mypy [func-returns-value] 报错。

实现拆解

实现按以下步骤推进:

  1. 定义统一对齐常量:在 vllm/model_executor/kernels/linear/mxfp8/rocm_native.py 中新增 _DOT_SCALED_K_ALIGN = 128,让加载期与推理期共用同一个 K 对齐判据,避免两个路径各自硬编码 128 造成漂移。
  2. 加载期一次性解量化:process_weights_after_loading 在裁剪 weight_scale 之后检查 K % _DOT_SCALED_K_ALIGN != 0,为真则用 dequant_mxfp8_to_bf16 把 FP8 权重转成 BF16 并写回 layer.weight;对齐权重维持 FP8 不变。这样后续每个 forward 都不再重建常量张量。
  3. 推理期按权重元素大小分派:apply_weights 的分派顺序改为:先看 layer.weight.element_size() >= 2(BF16 直接 torch.nn.functional.linear),再看激活 K 是否对齐(走 _mxfp8_dot_scaled_linear),最后保留“每步解量化”的第三分支。第三分支覆盖 process_weights_after_loading 未运行、权重仍是 FP8 且 K 非对齐的情况,避免非法进入 dot_scaled 的 128 分块逻辑。
  4. 回归测试配套:在 tests/kernels/test_minimax_m3_amd_ops.py 新增 test_mxfp8_rocm_native_unaligned_k_dequantizes_at_load,参数化真实生产 shape (5120, 576)、(5120, 288)(DSv4.1 TP4/TP8)与对齐对照 (256, 2048);用 _spy 替换模块级 _mxfp8_dot_scaled_linear,断言 unaligned FP8 权重绝不会触发 dot_scaled。
  5. 性能验证:在 4x MI355X(gfx950)、ROCm 7.2.3、TP4 上用 vllm bench serve 对比,两个未打补丁臂之间漂移仅 0.28%/0.08%,而补丁带来 4.6%/2.3% 的 TPOT 改善;profile 确认 exp2_kernel_vectorized4、CUDAFunctorOnSelf_add<float> 调用数从 2600 归零。
文件 模块 状态 重要度
vllm/model_executor/kernels/linear/mxfp8/rocm_native.py 量化内核 modified 6.6
tests/kernels/test_minimax_m3_amd_ops.py 内核测试 modified 6.13

关键符号

process_weights_after_loading apply_weights test_mxfp8_rocm_native_unaligned_k_dequantizes_at_load

关键源码片段

vllm/model_executor/kernels/linear/mxfp8/rocm_native.py core-logic

核心实现:process_weights_after_loading 将 K 非对齐的 MXFP8 权重在加载期一次性解量化为 BF16,apply_weights 改为按 weight.element_size() 分派并保留每步解量化 fallback,同时更新模块 docstring。

# vllm/model_executor/kernels/linear/mxfp8/rocm_native.py
​
# dot_scaled 的 K 维按 128 分块;K 不满足对齐的权重无法走原生 MX 路径。
_DOT_SCALED_K_ALIGN = 128
​
​
class RocmDotScaledMxfp8LinearKernel(Mxfp8LinearKernel):
    # 原生 CDNA4 (gfx950) MXFP8 线性 GEMM,通过 Triton tl.dot_scaled 实现。
​
    @classmethod
    def can_implement(cls, c: Mxfp8LinearLayerConfig) -> tuple[bool, str | None]:
        # 注意:这里不按 K 过滤,K 对齐性由加载期的解量化处理。
        return True, None
​
    def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
        # 加载阶段只执行一次:把 K 非对齐的 FP8 权重解量化为 BF16,
        # 避免 apply_weights 每次 forward 重建同一张量(6 个 pointwise kernel,
        # 且 FP32 中间值在 HBM 中往返两次)。
        weight = layer.weight.data # [N, K] fp8
        N, K = weight.shape
        scale_k = K // MXFP8_BLOCK_SIZE
        weight_scale = layer.weight_scale.data[:N, :scale_k].contiguous()
        if K % _DOT_SCALED_K_ALIGN != 0:
            weight = dequant_mxfp8_to_bf16(weight.contiguous(), weight_scale)
        layer.weight = Parameter(weight.contiguous(), requires_grad=False)
        layer.weight_scale = Parameter(weight_scale, requires_grad=False)
​
    def apply_weights(
        self,
        layer: torch.nn.Module,
        x: torch.Tensor,
        bias: torch.Tensor | None = None,
    ) -> torch.Tensor:
        if layer.weight_scale.dtype != MXFP8_SCALE_DTYPE:
            raise ValueError(
                f"Expected {MXFP8_SCALE_DTYPE} weight_scale, got "
                f"{layer.weight_scale.dtype}."
            )
        out_shape = (*x.shape[:-1], layer.weight.shape[0])
        x2d = x.reshape(-1, x.shape[-1])
        # 分派键用元素大小而不是 dtype:BF16 (>= 2 字节)直接走普通 linear;
        # 仍是 FP8 且激活 K 对齐时才允许 dot_scaled;两者都不满足时退回每步
        # 解量化,覆盖 process_weights_after_loading 未运行(权重仍为 FP8 且
        # K 非对齐)的路径,避免非法进入 dot_scaled 的分块逻辑。
        if layer.weight.element_size() >= 2:
            out = torch.nn.functional.linear(x2d, layer.weight.to(x.dtype))
        elif x2d.shape[-1] % _DOT_SCALED_K_ALIGN == 0:
            out = _mxfp8_dot_scaled_linear(x2d, layer.weight, layer.weight_scale)
        else:
            w_bf16 = dequant_mxfp8_to_bf16(layer.weight, layer.weight_scale)
            out = torch.nn.functional.linear(x2d, w_bf16).to(x.dtype)
        out = out.reshape(out_shape)
        if bias is not None:
            out = out + bias
        return out
tests/kernels/test_minimax_m3_amd_ops.py test-coverage

新增参数化回归测试,直接覆盖 DSv4.1 共享专家 down_proj 在 TP4/TP8 的真实 shape(K=576/288)与对齐对照(K=2048),并用 spy 断言 unaligned FP8 权重不会触发 dot_scaled。

# tests/kernels/test_minimax_m3_amd_ops.py
# (5120, 576)与 (5120, 288)是 DSv4.1 共享专家 down_proj 在 TP4 / TP8 的真实
# shape;K = 2048 保留为对齐对照,必须继续走 dot_scaled。
@requires_gfx950
@pytest.mark.parametrize("shape", [(5120, 576), (5120, 288), (256, 2048)])
@torch.inference_mode()
def test_mxfp8_rocm_native_unaligned_k_dequantizes_at_load(shape):
    # dot_scaled 按 K 128 分块,unaligned 权重绝不能到达它。
    N, K = shape
    aligned = K % 128 == 0
​
    w_bf16 = torch.randn(N, K, device=DEVICE, dtype=torch.bfloat16) * 0.1
    w_fp8, w_scale = _mxfp8_e4m3_quantize_torch(w_bf16, is_sf_swizzled_layout=False)
    x = torch.randn(7, K, device=DEVICE, dtype=torch.bfloat16) * 0.5
    # 参考值:先解量化再做普通 linear。
    out_ref = torch.nn.functional.linear(x, dequant_mxfp8_to_bf16(w_fp8, w_scale))
​
    def make_layer():
        # 构造“未经过 process_weights_after_loading”的层,weight 仍是 FP8。
        layer = torch.nn.Module()
        layer.weight = torch.nn.Parameter(w_fp8.clone(), requires_grad=False)
        layer.weight_scale = torch.nn.Parameter(w_scale.clone(), requires_grad=False)
        return layer
​
    kernel = rocm_native.RocmDotScaledMxfp8LinearKernel(Mxfp8LinearLayerConfig())
    layer = make_layer()
    kernel.process_weights_after_loading(layer)
    # 加载后:unaligned 权重被转成 BF16 (>= 2 字节),aligned 保持 FP8。
    assert (layer.weight.element_size() >= 2) is not aligned
​
    # 仍然 FP8 的 unaligned 权重必须回退到每步解量化,而不是调用 dot_scaled。
    unprocessed = make_layer()
    calls = []
    real = rocm_native._mxfp8_dot_scaled_linear
​
    def _spy(*a, **k):
        calls.append(1)
        return real(*a, **k)
​
    rocm_native._mxfp8_dot_scaled_linear = _spy
    try:
        out = kernel.apply_weights(unprocessed, x)
    finally:
        rocm_native._mxfp8_dot_scaled_linear = real
    # aligned 时 dot_scaled 必须被调用;unaligned 时绝不能。
    assert bool(calls) is aligned

评论区精华

apply_weights 丢失 unaligned fallback 的分派安全 正确性

ChuanLi1101 指出:加载路径按 K % 128 判断、应用路径按 element_size 判断,一旦 process_weights_after_loading 未运行,FP8 权重 + 非对齐 K 会非法进入 dot_scaled。要求保留每步解量化 fallback。

结论:作者在 apply_weights 中恢复了第三分支:仍为 FP8 且 K 非对齐时走每步 dequant,分派逻辑自恢复安全。 · 已解决

缺少非对齐 K 的回归测试 测试

ChuanLi1101 要求仿照 Emulation 的 test_mxfp8_linear_emulation_bf16_at_load 补充覆盖:加载期 BF16 与每步解量化结果一致,且仍为 FP8 的非对齐权重不调用 dot_scaled。

结论:新增参数化测试 test_mxfp8_rocm_native_unaligned_k_dequantizes_at_load,并用 _spy 断言 dot_scaled 调用情况。 · 已解决

模块 docstring 声称 selector fallback 过时 documentation

ChuanLi1101 指出文件头仍说 unaligned K falls through to emulation,但 can_implement 恒为 True、selector 看不到 K,注释与实际行为不符。

结论:docstring 改为描述“K 非对齐权重在加载期解量化一次并由普通 linear 服务”。 · 已解决

回归测试 shape 与生产不符 测试

ChuanLi1101 提出测试使用合成 K=2080/2048,实际 DSv4.1 问题 shape 是 K=576/288,建议直接覆盖真实生产 shape。

结论:加入 (5120, 576) 与 (5120, 288),移除与 288 同余的 K=2080,保留 K=2048 作为对齐对照;gfx950 上 58 passed。 · 已解决

mypy pre-commit 失败:lambda 返回 None style

mergify 多次提示 pre-commit 失败;作者将 _spy 从 calls.append(1) or real(...) 的 lambda 改为命名函数,修复 mypy [func-returns-value] 报错。

结论:改用 def _spy,行为不变且通过 ruff/mypy 四版本检查。 · 已解决

风险与影响

  1. 显存占用增加:BF16 权重体积为 FP8 的 2 倍,实测每个 rank 增加约 0.11 GiB。对显存极度敏感的部署需评估。
  2. 分派逻辑依赖加载流程:apply_weights 的正确性依赖 process_weights_after_loading 是否改写权重。虽然第三分支已兜底,但未来新增其他 dtype 或量化路径时,必须同步更新 element_size 分派,否则可能误走路径。
  3. 测试覆盖依赖硬件:新测试带 @requires_gfx950,只能在 AMD CI 的 CDNA4 上运行;非 ROCm 平台无覆盖(但该 kernel 也仅在 gfx95x 生效)。
  4. 精度差异:unaligned 解量化路径与旧行为数学等价;aligned 路径仍走 dot_scaled,其激活重量化误差与解量化路径不同,测试中 tol 分别为 5e-2 与 2e-3。
  5. 丢失 FP8 权重形态:加载期解量化后 layer.weight 不再是 FP8,若未来 dot_scaled 支持非对齐 K,需要重新加载模型才能恢复原生路径。

影响范围集中在 ROCm CDNA4(gfx950)+ MXFP8 量化 + K 非对齐线性层这一组合,典型场景是 DeepSeek-V4.1 在 TP4/TP8 下的共享专家 down_proj;TP1/TP2 因 K 对齐不受影响。对用户而言,解码 TPOT 中位数在并发 1 与 8 下分别下降 4.6% 与 2.3%,且消除了每次 forward 的 6 个 pointwise kernel 启动与约 56 MB 权重流量。对团队而言,该实现为同类 native kernel 的“加载期做一次数据变换 + 推理期按数据形态分派”提供了可复用模式,测试中的 spy 断言方法也可推广到其他 kernel 路径。

ROCm 专有路径 分派依赖加载流程 显存占用增加 测试依赖 gfx950

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论