Prhub

#57112 [ROCm][CI] Fix MLA RoPE fused-kernel tests for TheRock image

原始 PR 作者 mawong-amd 合并时间 2026-09-16 13:57 文件变更 1 提交数 2 评论 4 代码增减 +121 / -16

执行摘要

ROCm MLA RoPE 测试改为按 ULP 分级容差

PR body 指出:Core Operations Kernels 测试组在 TheRock 镜像上失败,根因是 Triton 3.8 升级——RoPE 中 x * cos - y * sin 这类计算,Triton 3.7 的 LLVM 后端降为 FMA_F16(x, cos, MUL_F16(-y, sin)),与 HIP 参考融合内核一致,而 Triton 3.8 改为 FMA_F16(-y, sin, MUL_F16(x, cos)),两种预舍入选择最多相差 2 个乘积 ULP。作者明确写道:“The current tolerances do not adequately capture the subtleties in this regime, which is partly why the tolerances for this test have been adjusted at least 3 separate times”,因此目标是 rework and unify 该测试在 ROCm 上半精度 dtype 的容差,而不是再调一次常数。

值得精读,尤其是从事 ROCm 内核、数值测试或 CI 稳定性的工程师:这是“不要靠调 atol/rtol 掩盖数值差异”的一个正面范例,用 FMA 预舍入选择推导出 2 product-ULP 的物理上界,再用失败比例上限约束整体行为。阅读顺序建议:先看 PR body 中 Triton 3.7 vs 3.8 的 lowering 对比理解动机,再看 assert_rope_close_rocm 的 Tier 1/Tier 2 双段断言与 NeoX/交错两套乘积推导,最后看 test_concat_and_cache_mla_rope_fused 如何用 slot_mapping 把 KV cache 的 rope 区段收集回 token 顺序、并把 nope 区段保持严格比对。需要留意的设计决策是 bf16 的 max_fail_fraction=1e-2 取值依据(仅由 eps 更粗解释)以及 fp8 分支未被纳入统一模型;若后续要推广该断言,应先确认这两个边界。

讨论亮点

本 PR 的 review 侧几乎没有技术交锋:claude[bot] 仅留言说明“This pull request is from a fork — automated review is disabled”,需要维护者手动 @claude review 才会跑一次性审查;随后 AndreasKaratzas 直接 APPROVED 并执行合并。issue 评论也只有 CI 触发流程:AndreasKaratzas 连续两次 /ci run,第一次被 bot 拒绝(“This PR is 5 commits behind upstream main”),要求先 merge/rebase 到最新 main,随后才触发 Buildkite CI #89262。真正的“讨论”全部体现在 PR body 的数值论证里:作者解释了 Triton 3.7 → 3.8 的 FMA 预舍入翻转,并链接了历史上三次容差调整(#51083、#46409、#32408)来说明旧做法的不可持续性。

实现拆解

  1. 变更入口:在 tests/kernels/core/test_rotary_embedding_mla_cache_fused.py 模块级新增 assert_rope_close_rocm(约 +82 行),它是整份 PR 的唯一新增逻辑,替代此前散落在测试主体里的 ROCm 专用 atol/rtol 常量。

  2. 容差核心逻辑(两段式):Tier 1 用 get_default_atol/get_default_rtol 做常规比对;若有元素失败,Tier 2 要求每个失败元素满足 |actual - ref| <= 2 * eps * |product|,其中 product 取该位置两个乘积(如 x*cos 与 -y*sin)中较大者,并限制失败元素占比不超过 max_fail_fraction(bf16 默认 1e-2,其余 1e-4)。为此函数会用 rope_input 与 cos_sin_cache[positions] 在 FP32 下重算逐元素乘积幅值,分别处理 NeoX 的 [x*cos - y*sin, y*cos + x*sin] 布局与交错布局(inp[..., 0::2] / inp[..., 1::2])。这样放宽只作用于乘积量级所允许的 ULP 范围,而不是对整张张量放宽常数。

  3. 测试主体改造:test_concat_and_cache_mla_rope_fused 中新增 k_pe_orig = k_pe.clone() 与 query_orig = query.clone(),因为融合内核与参考实现都会原地改写输入,必须保留原始 RoPE 输入用于 Tier 2 的乘积计算。KV cache 比对分支被重写:fp8 路径保持原有校准容差(atol=0.004 if rocm_bf16 else 0.001、rtol=0.15 if rocm_neox or rocm_bf16 else 0.1);ROCm 且 dtype 为 half/bfloat16 时,先用 block_idx = slot_mapping // block_size、block_offset = slot_mapping % block_size 把 kv_cache[..., kv_lora_rank:] 的 rope 区段按 token 顺序收集回来,再交给 assert_rope_close_rocm;最后对 [..., :kv_lora_rank] 的 nope 区段单独做默认 assert_close,把放宽严格限制在 RoPE 部分。query 与 ref_q_pe 的比对同样切换到 assert_rope_close_rocm。

  4. 被移除的旧逻辑:原来的 elif rocm_bf16: assert_close(kv_cache, ref_kv_cache, atol=0.02, rtol=1e-3) 与 elif rocm_neox: assert_close(..., atol=1e-3, rtol=1e-3),以及 query 上的 atol=0.04 if rocm_bf16,全部由 ULP 模型取代;CUDA 与 fp32 路径仍走默认 torch.testing.assert_close,行为不变。

  5. 测试/CI 配套:无源码、配置或部署改动;PR body 给出测试计划为“ROCm 10 上 with fix(Buildkite #13002)vs without fix(#13000)跑 Core Operations Kernels 测试组,ROCm 7.2 走本 PR CI”,结论是 ROCm 10 修复且 7.2/10 无回归。

文件 模块 状态 重要度
tests/kernels/core/test_rotary_embedding_mla_cache_fused.py 旋转位置编码 modified 6.11

关键符号

assert_rope_close_rocm test_concat_and_cache_mla_rope_fused

关键源码片段

tests/kernels/core/test_rotary_embedding_mla_cache_fused.py test-coverage

本 PR 唯一变更文件:新增 `assert_rope_close_rocm` 两段式容差断言,并重写 `test_concat_and_cache_mla_rope_fused` 的 ROCm 半精度比对路径,是修复 TheRock/Triton 3.8 CI 失败的全部实现。

# tests/kernels/core/test_rotary_embedding_mla_cache_fused.py
def assert_rope_close_rocm(
    actual: torch.Tensor,
    ref: torch.Tensor,
    rope_input: torch.Tensor, # 融合前的原始 RoPE 输入(query 或 k_pe),用于重算乘积量级
    cos_sin_cache: torch.Tensor,
    positions: torch.Tensor,
    is_neox: bool,
    *,
    max_fail_fraction: float | None = None,
    atol: float | None = None,
    rtol: float | None = None,
) -> None:
    """两段式断言:主体走默认容差,少量“灾难性抵消”元素按乘积 ULP 放行。
​
    Tier 1:|actual - ref| <= atol + rtol * |ref|,沿用默认容差。
    Tier 2:Tier 1 失败的每个元素必须满足
            |actual_e - ref_e| <= 2 * eps * |product_e|,
            且失败元素占比不得超过 max_fail_fraction。
    之所以是 2 个乘积 ULP:融合内核与参考实现可能把乘加收缩成
    预舍入不同乘积的 FMA,两种合法顺序最多相差 2 个 ULP。
    """
    if atol is None:
        atol = get_default_atol(ref)
    if rtol is None:
        rtol = get_default_rtol(ref)
    if max_fail_fraction is None:
        # bf16 的 eps 更粗,1 ULP 偏差更容易越过默认容差,故放宽到 1%
        max_fail_fraction = 1e-2 if ref.dtype == torch.bfloat16 else 1e-4
    eps = torch.finfo(ref.dtype).eps
​
    # 逐元素乘积幅值,按 (seq, heads, rot_dim) 布局还原
    seq_len, rot_dim = rope_input.shape[0], rope_input.shape[-1]
    half = rot_dim // 2
    inp = rope_input.reshape(seq_len, -1, rot_dim).to(torch.float32)
    cs = cos_sin_cache[positions].to(torch.float32) # (seq_len, 2*half)
    cos = cs[:, :half].unsqueeze(1) # 沿 head 维广播
    sin = cs[:, half:].unsqueeze(1)
    x, y = inp[..., :half], inp[..., half:]
    if is_neox:
        # out = [x*cos - y*sin, y*cos + x*sin]
        prod = torch.cat(
            [
                torch.maximum((x * cos).abs(), (y * sin).abs()),
                torch.maximum((y * cos).abs(), (x * sin).abs()),
            ],
            dim=-1,
        )
    else:
        # out[2i] = x[2i]*cos[i] - x[2i+1]*sin[i],交错布局
        xe, xo = inp[..., 0::2], inp[..., 1::2]
        prod = torch.stack(
            [
                torch.maximum((xe * cos).abs(), (xo * sin).abs()),
                torch.maximum((xo * cos).abs(), (xe * sin).abs()),
            ],
            dim=-1,
        ).flatten(-2)
    prod = prod.reshape(rope_input.shape)
​
    diff = (actual.to(torch.float32) - ref.to(torch.float32)).abs()
    tol = atol + rtol * ref.to(torch.float32).abs()
    fail = diff > tol
    n_fail = int(fail.sum())
    if n_fail == 0:
        return
    # Tier 2 前置门槛:失败元素必须足够稀少,避免整体性数值回归被放过
    assert n_fail <= max_fail_fraction * diff.numel(), (
        f"{n_fail} elements beyond default tolerance "
        f"({n_fail / diff.numel():.2%} > {max_fail_fraction:.2%}); "
        f"max diff {diff.max().item()}"
    )
    excess = diff[fail] - 2 * eps * prod[fail]
    assert (excess <= 0).all(), (
        f"{int((excess > 0).sum())} cancellation elements exceed 2 product-ULPs; "
        f"worst excess {excess.max().item()}"
    )
# tests/kernels/core/test_rotary_embedding_mla_cache_fused.py
def test_concat_and_cache_mla_rope_fused(...):
    ...
    # 保留融合前输入:融合算子与参考实现都会原地改写张量,
    # Tier 2 需要用它重算逐元素乘积幅值
    k_pe_orig = k_pe.clone()
    query_orig = query.clone()
​
    ... # 执行 opcheck 与 ops.concat_and_cache_mla_rope_fused
​
    if kv_cache_dtype == "fp8":
        ... # fp8 保持原有经验校准容差,未纳入 ULP 模型
        torch.testing.assert_close(
            result_temp,
            expected_temp,
            atol=0.004 if rocm_bf16 else 0.001,
            rtol=0.15 if rocm_neox or rocm_bf16 else 0.1,
        )
    elif current_platform.is_rocm() and dtype in (torch.half, torch.bfloat16):
        # 按 slot_mapping 把 KV cache 的 rope 区段收集回 token 顺序
        block_idx = slot_mapping // block_size
        block_offset = slot_mapping % block_size
        fused_k_rope = kv_cache[block_idx, block_offset, kv_lora_rank:]
        ref_k_rope_gathered = ref_kv_cache[block_idx, block_offset, kv_lora_rank:]
        assert_rope_close_rocm(
            fused_k_rope,
            ref_k_rope_gathered,
            k_pe_orig,
            rope.cos_sin_cache,
            positions,
            is_neox_style,
        )
        # nope 区段是逐字节拷贝,仍按默认容差严格比对,放行只限 RoPE 部分
        torch.testing.assert_close(
            kv_cache[..., :kv_lora_rank], ref_kv_cache[..., :kv_lora_rank]
        )
    else:
        torch.testing.assert_close(kv_cache, ref_kv_cache)
​
    if current_platform.is_rocm() and dtype in (torch.half, torch.bfloat16):
        assert_rope_close_rocm(
            query,
            ref_q_pe,
            query_orig,
            rope.cos_sin_cache,
            positions,
            is_neox_style,
        )
    else:
        torch.testing.assert_close(
            query,
            ref_q_pe,
            atol=get_default_atol(query),
            rtol=get_default_rtol(query),
        )

评论区精华

不再手动调 atol/rtol,改用按乘积 ULP 的定量容差模型 设计

作者在 PR body 中论证:Triton 3.8 的 LLVM 后端为收缩 FMA 选择预舍入的乘积与 3.7 不同,两种合法顺序最多相差 2 个乘积 ULP,集中在 `x*cos ~= -y*sin` 的灾难性抵消元素上;并指出该测试的容差此前已被调整至少 3 次(#51083、#46409、#32408)。

结论:结论是引入 `assert_rope_close_rocm`:主体走默认容差,失败元素逐个受 2 product-ULP 约束,且失败占比受限(fp16 1e-4、bf16 1e-2),作者称其为 rework and unify 容差。 · 已解决

fork PR 的自动 review 被禁用,缺少第三方技术复核 other

`claude[bot]` 评论:“This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review.”

结论:未有人触发一次性 review,最终仅 AndreasKaratzas 一人 APPROVED 并合并;数值推导细节未获得独立复核。 · unresolved

CI 触发流程:分支落后 main 导致首次 /ci run 被拒 other

AndreasKaratzas 第一次 `/ci run` 后,github-actions[bot] 回复该 PR 落后 upstream main 5 个 commit 并拒绝启动 CI;随后 merge main(commit 6825e26)并重新触发,才得到 Buildkite CI #89262。

结论:以一次 merge main 提交解决,CI 正常触发;不影响测试逻辑本身。 · 已解决

风险与影响

  1. 检测强度变化:bf16 允许最多 1% 的元素超出默认容差(此前 atol=0.02 是对全量元素生效),若内核出现“低频但系统性”的偏差,只要单元素幅度在 2 个乘积 ULP 内就可能被放行;Tier 2 的逐元素 ULP 上限是目前唯一的幅度护栏,缺少对该放行比例的独立校验。
  2. 覆盖路径扩大:base 版本中 ROCm + fp16 + 非 NeoX 的 kv_cache 走默认 assert_close,现在转入两段式断言,等于对 fp16 也引入 1e-4 的失败比例余量,属于轻微放松。
  3. 测试内重实现内核语义:assert_rope_close_rocm 用 Python/FP32 复刻了 NeoX 与交错两种 RoPE 布局的乘积关系;若将来内核布局或 cos_sin_cache 排布变化而断言未同步,可能产生“过宽”的假通过或误报,二者都不易被察觉。
  4. 根因未消除:Triton 3.8 / TheRock 镜像上 AITER Triton rope 与融合内核的 1–2 ULP 数值差异依然存在,本 PR 只是让它被定量容忍,属于测试侧缓解。
  5. 策略并存:fp8 分支仍使用经验校准的 atol/rtol(含 rtol=0.15),与新 ULP 模型并存,未来维护者需要理解两套容差语言的边界。
  6. 覆盖面:只改了一个文件,其他 ROCm 内核测试(如 MLA decode、MoE)若遇同类 Triton 3.8 数值漂移,仍需各自处理。

对用户与线上服务无运行时影响——变更完全位于 tests 目录,不触碰 vllm/model_executor/layers/rotary_embedding 或 concat_and_cache_mla_rope_fused 算子本身。对 CI 与团队影响明确:TheRock/ROCm 10 镜像上失败多日的 Core Operations Kernels 测试组恢复绿色,ROCM 7.2 亦无回归,解除了 ROCm 侧持续调容差的维护负担。对后续测试作者,assert_rope_close_rocm 提供了一种可复用的“默认容差 + 乘积 ULP 定向放行”范式,可迁移到其他存在 FMA 收缩差异的融合算子回归测试中;同时它把 nope 区段恢复为严格比对,反而提升了该区域的检测严格度。

仅测试改动未修根因 容差按 ULP 定向放宽 bf16 允许 1% 元素超差 测试内重实现 RoPE 布局语义 fp8 与 ULP 两套容差并存

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论