Prhub

#51171 [ROCm][MLA] Reach FULL cudagraphs for AITER MLA speculative decoding

原始 PR 作者 yudigege86 合并时间 2026-08-30 10:49 文件变更 4 提交数 12 评论 30 代码增减 +181 / -127

执行摘要

ROCm MLA 投机解码实现 FULL cudagraph, 吞吐提升约 3.8 倍

PR body 明确指出:"Speculative decoding on ROCm cannot reach FULL cudagraphs, for two independent reasons. This fixes both." 第一个原因是类常量 CG 支持上报:一个服务 1 + num_speculative_tokens 块的 DSpark draft 组让引擎取最小值后降级整个引擎;第二个原因是 AiterMLAImpl.forward_mqa 小 head verify 路径每层做 4 次 device->host 同步和数据相关分配,每个都会中止 HIP graph capture。此外还发现 worker 侧一直缺失 kv_cache_size_tokens,导致 verify buffer 按 max_model_len 过度预留(295.3 MiB vs 修正后 46.6 MiB)。reger-men 在 gfx950 上独立复现同样的两个缺陷,并给出 spec-off 时强制 PIECEWISE 也要付出 5.2 倍代价的数据,证明 decode graph 本身价值极高。

值得精读。这是 ROCm MLA 投机解码性能的关键一跃,且 review 质量高:okorzh-amd 的拆分并行度分析、tjtanaa 对 4-D 入口的提示都直接改变了最终方案。建议重点学习四个设计决策:builder 与 impl 的职责边界(同步必须离开捕获区)、组级而非层级的 CG 支持上报、用 kernel 原生 4-D MTP 替代手动 flatten、以及在修复性能时同步修正正确性隐患(min_kv_seq_len 污染、assert 剥离、padding 行溢出)。

讨论亮点

评论区最有价值的交锋集中在三处:

  • min_kv_seq_len 被 padding 行污染(okorzh-amd,正确性/性能):"min_kv_seq_len is taken over all requests including cudagraph padding rows. Under pad_uniform_mtp those rows are pinned to max_qo_len... so the min is qlen, not a real KV length."他给出拆解:7 个 padding 行把 NUM_KV_SPLITS 从 17 压到 1,并指出 PR 测出的性能收益是在 split 并行被关掉的情况下测的。作者确认修复于 afd132cfd,重测 c1 为 45.33 tok/s,说明修复没有损害收益。

  • 放弃手动展开、改用原生 4-D MTP 入口(tjtanaa,设计):"Do we still need to prepare this flat_kv_indptr? The aiter v0.1.19 documents that it accepts 4D tensors now." 这一问直接促成了方案从"每层/每步展开 paged-KV"重构成"kernel 内因果掩码"的简化,删除了大量持久 buffer 与同步代码。

  • assert 在 python -O 下被剥离(depthfirst-app + claude[bot],安全):"If stripped, the subsequent Triton kernel _expand_page_indices_kernel writes to flat_kv_indices... corrupting adjacent GPU memory." 作者改为与 kv_cache_spec_registry.py 一致的 raise ValueError 模式。claude[bot] 还指出 flat buffer 预留未计入 padding 行可能溢出,该风险随 flat 路径删除而消除。

另有两条低强度评论:shen-shanshan 纠正 docstring 中 use_2d_view=True 应为 False,并质疑 gpu_worker.py 的 kv_cache_size_tokens 填充是否还有用;作者确认是旧 flat 路径遗留,已在 0abcdc05c 移除。

实现拆解

整个变更分四条线索推进,共 4 个文件、12 个 commit,期间经历了从"手动展开"到"kernel 原生 4-D 入口"的方案升级:

  1. 修复 CG 支持上报(文件: vllm/v1/attention/backends/mla/triton_mla.py)。为 TritonMLAMetadataBuilder 新增类方法 get_cudagraph_support,当 kv_cache_spec 带有 non_causal_multi_token_decode 标记时返回 UNIFORM_BATCH,否则回退原类常量。该标记是 KV cache 组级属性(MLAAttentionSpec.merge 会对组内所有层做 OR),与 init 里提升 reorder_batch_threshold 用的是同一谓词,保证两处判定一致;副作用是同组的 causal target 也会被一并提升,已在 docstring 中记录作用域。

  2. 重构小 head verify 路径(文件: vllm/v1/attention/backends/mla/rocm_aiter_mla.py)。早期 commit(242c0974)先把每层展开移到 _build_decode 一次完成并写入持久 buffer;随后 tjtanaa review 指出 aiter 已支持 4-D 张量,作者在 f3c0eb56 改为走 mla_gluon 原生 4-D MTP 入口:q/o unflatten 成 [num_reqs, qlen, nhead, dim],直接传每请求的 paged_kv_indptr 与 paged_kv_indices,因果上界由 aiter v0.1.20 在 kernel 内施加,彻底删除 flat_kv_indptr/flat_kv_indices 持久 buffer、_expand_page_indices_kernel 的 QLEN 展开逻辑和 builder 侧对应同步。

  3. 修正 min_kv_seq_len 语义并对边界加固。okorzh-amd 指出 FULL-cudagraph 的 pad_uniform_mtp 会把零 qo 行钉到 max_qo_len,导致 per_req_len.min() 返回 qlen 而非真实 KV 长度,把 Gluon KV-split 并行度从 17 路压到 1 路;修复为仅对 qo_lens_device > 0 的 active 行取 min,并新增 test_min_kv_seq_len_ignores_cudagraph_padding_rows。同时把 flat buffer 的 assert 边界检查改为 raise ValueError(python -O 会剥离 assert,越界时 Triton kernel 会写坏相邻显存)。

  4. 配套测试与清理。test_rocm_aiter_mla_causal_verify_mask.py 从"逐行因果窗口长度"断言改为钉 4-D MTP 契约(q 4-D、use_2d_view=False、1-D page_table、[num_reqs+1] seq_info);test_rocm_aiter_mla_mtp_split.py 的 _ExpandPageIndicesKernel mock 同步改为从 indptr 差分推导行数。gpu_worker.py 的 kv_cache_size_tokens 填充曾临时加入,4-D 路径不再依赖后由 shen-shanshan 指出并移除。

文件 模块 状态 重要度
vllm/v1/attention/backends/mla/rocm_aiter_mla.py MLA 后端 modified 7.46
vllm/v1/attention/backends/mla/triton_mla.py 图捕获 modified 6.95
tests/kernels/attention/test_rocm_aiter_mla_causal_verify_mask.py 回归测试 modified 6.76
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py ROCm 测试 modified 5.6

关键符号

TritonMLAMetadataBuilder.get_cudagraph_support AiterMLAMetadataBuilder._build_decode AiterMLAImpl.forward_mqa AiterMLAHelper.use_gluon_verify _expand_page_indices_kernel test_verify_mtp_uses_native_4d_gluon_entry test_min_kv_seq_len_ignores_cudagraph_padding_rows

关键源码片段

vllm/v1/attention/backends/mla/rocm_aiter_mla.py core-logic

核心变更文件 : 小 head verify 从每层 flat 展开改为 mla_gluon 原生 4-D MTP 入口 ,min_kv_seq_len 计算移入 builder 并排除 padding 行 , 边界检查从 assert 改为 raise ValueError。

# 小 head (< 16) 多 token verify 走 mla_gluon 的 4-D MTP 入口 , 直接使用
# 每请求的 paged-KV 视图 , 不再构建逐 verify token 的展开 buffer。
# mla_gluon 仍需要一个 KV 长度下界用于决定内部分割 ;verify 路径上该下界
# 必须基于真实请求 , 而不能被 FULL cudagraph 的占位行污染。
#(pad_uniform_mtp 会把零 qo 行钉到 max_qo_len, 若对全部行取 min 会得到
# qlen 而非真实 KV 长度 , 进而把 Gluon 的 KV-split 并行度从 17 路压到 1 路。)
min_kv_seq_len = 1
if AiterMLAHelper.use_gluon_verify(
    self.num_heads, int(max_qo_len), self._kv_cache_dtype_str
):
    # paged_kv_indptr 的差分即每请求 KV 行数
    per_req_len = paged_kv_indptr[1:] - paged_kv_indptr[:-1]
    if pad_uniform_mtp:
        # 只统计真实请求 (qo_len > 0), 忽略 cudagraph 占位行
        active = qo_lens_device > 0
        if active.any():
            # .item() 在 builder 内执行 , 位于捕获区之外 , 不会中止 HIP 图捕获
            min_kv_seq_len = int(per_req_len[active].min().item())
    else:
        min_kv_seq_len = int(per_req_len.min().item())
if AiterMLAHelper.use_gluon_verify(
    self.num_heads, int(decode.max_qo_len), self.kv_cache_dtype
):
    # 小 head (< 16) 多 token verify 交给 mla_gluon 的原生 4-D MTP 入口 :
    # q 从 [B, nhead, dim] 还原成 [num_reqs, qlen, nhead, dim](flat 布局
    # 是 row-major,unflatten 是零成本 view), 因果上界由 aiter v0.1.20
    # 在 kernel 内施加 :score_end = min(split_kv_end, seq_len - qlen + q_pos + 1)。
    # 相比旧实现 , 这里不再按 verify token 展开 paged-KV, 也没有每层
    # 4 个 device -> host 同步和数据相关分配 , 因此可被 HIP 图捕获。
    qlen = int(decode.max_qo_len)
    num_reqs = B // qlen
    if num_reqs * qlen != B:
        raise ValueError(f"verify block {B} rows is not a multiple of qlen {qlen}")
    assert decode.paged_kv_indptr is not None
    assert decode.paged_kv_indices is not None
    mla_gluon = _get_mla_gluon()
    mla_gluon(
        q_nope=q_nope.unflatten(0, (num_reqs, qlen)),
        q_pe=q_pe.unflatten(0, (num_reqs, qlen)),
        kv_c=kv_buffer,
        o=o.unflatten(0, (num_reqs, qlen)),
        page_table=decode.paged_kv_indices,
        seq_info=decode.paged_kv_indptr,
        sm_scale=self.scale,
        k_pe=None,
        kv_pe_offset=self.kv_lora_rank,
        use_2d_view=False,
        kv_scale=1.0,
        min_kv_seq_len=decode.min_kv_seq_len,
    )
    return o, None
tests/kernels/attention/test_rocm_aiter_mla_causal_verify_mask.py test-coverage

回归测试从 " 逐行因果窗口长度 " 改为钉 4-D MTP 契约 , 防止重蹈 verify 位置能看到其后的 draft token 的正确性缺陷。

def test_verify_mtp_uses_native_4d_gluon_entry():
    """验证 4-D MTP 契约:verify 必须以 4-D q + per-request 元数据进入 Gluon。
​
    回归保护:旧实现给每个 flatten 行发整段请求 paged-KV 范围,verify
    位置 t 能看到位于其后的 draft token(即它本应校验的 token)。原生
    MTP 路径把因果掩码委托给 aiter kernel。
    """
    metadata, captured = _run_verify_block()
    decode = metadata.decode
    assert decode is not None, "batch was not classified as a decode"
    assert decode.max_qo_len == QLEN
​
    q_nope = captured["q_nope"]
    assert q_nope.dim() == 4, f"verify 必须以 4-D q 调用 mla_gluon"
    assert q_nope.shape[:2] == (num_reqs, QLEN)
    assert captured["use_2d_view"] is False
    # per-request 元数据 :1-D page_table + [num_reqs + 1] indptr,
    # 而不是按 verify token 展开的扁平视图
    assert captured["page_table"].dim() == 1
    assert captured["seq_info"].numel() == num_reqs + 1

评论区精华

min_kv_seq_len 被 cudagraph padding 行污染导致 KV-split 并行塌缩 正确性

okorzh-amd 指出 :pad_uniform_mtp 下 padding 行被钉到 max_qo_len,per_req_len.min() 返回 qlen 而非真实 KV 长度 , 并给出完整推演 :NUM_KV_SPLITS 从 17 降到 1, 意味着 PR 报告的 c1 性能收益实际是在 split 并行被关掉时测得的 , 需要修复。

结论:作者采纳 , 改为 pad_uniform_mtp 时仅对 qo_lens_device > 0 的 active 行取 min, 新增单测 ; 重测 c1 为 45.33 tok/s, 收益未受损。 · 已解决

是否改用 aiter 原生 4-D MTP 入口替代手动 flat 展开 设计

tjtanaa 询问 :"Do we still need to prepare this flat_kv_indptr? The aiter v0.1.19 documents that it accepts 4D tensors now." 并指向 mla_gluon 源码中 4-D 入口的调用契约。

结论:作者在 f3c0eb56 采纳 :verify 直接传 per-request paged_kv_indptr/paged_kv_indices 与 4-D q,aiter v0.1.20 在 kernel 内施加因果掩码 ,flat buffer 及相关代码全部删除。 · 已解决

assert 边界检查在 python -O 下被剥离导致越界写 安全

depthfirst-app 与 claude[bot] 先后指出 :flat_kv_indices 的唯一边界检查是 assert, 被 -O 剥离后 _expand_page_indices_kernel 会越界写相邻 GPU 内存 ; 代码库已有 kv_cache_spec_registry.py:161 的 raise ValueError 模式。

结论:作者改为显式 if/raise ValueError; 后续 4-D 路径删除 flat buffer, 该风险整体消除。 · 已解决

flat_kv_indices 预留容量未计入 padding 行可能溢出 正确性

claude[bot] 指出 __init__ 按 kv_cache_size_tokens( 仅真实 KV) 预留 flat 容量 , 但 pad_uniform_mtp 把 padding 行 seq_len 从 0 改为 max_qo_len, 每个 padding 行会贡献最多 qlen*(qlen+1)/2 个未预留条目 , 紧 KV 池 + 粗捕获尺寸下会触发 assert 或越界写。

结论:该 flat 展开路径随后被 4-D MTP 入口整体替换 , 缓冲区不复存在 , 风险随代码删除而消除。 · 已解决

worker 侧 kv_cache_size_tokens 填充是否还需要 question

shen-shanshan 在 gpu_worker.py 上质疑 :"I suppose this is not used in the PR now?" 该填充最初用于给 flat verify buffer 按真实 KV 容量预留 (295.3 MiB -> 46.6 MiB)。

结论:作者确认是旧 flat 路径遗留 ,4-D 路径不再读取 worker 上的 kv_cache_size_tokens, 已在 0abcdc05c 移除填充逻辑。 · 已解决

use_gluon_verify docstring 中 use_2d_view 方向错误 documentation

shen-shanshan 指出 docstring 写的是 use_2d_view=True, 与实现相反。

结论:作者在 0abcdc05c 连同测试模块 docstring 一起修正为 use_2d_view=False。 · 已解决

风险与影响

  • 与 AITER 版本行为强耦合:4-D MTP 路径把因果掩码委托给 aiter v0.1.20 的 kernel,min_kv_seq_len 的语义也随 ROCm/aiter#4555 变化(bh16 不再用它决定拆分,仅 bh64 作下界断言)。若用户环境安装的 aiter 版本低于 v0.1.20,性能与正确性依赖将不一致,建议在文档或环境校验中明确最低版本。
  • 组级 CG 上报的连带提升:get_cudagraph_support 按 KV cache 组返回 UNIFORM_BATCH,与 draft 共享组的 causal target 也会被提升。虽然与 reorder_batch_threshold 共用同一谓词、且 forward_mqa 无同步保证,但该分支在真实 causal target 上的覆盖仍主要依赖单元测试,建议关注线上日志中 FULL 模式下 target 端的行为。
  • FULL 捕获内存显著上升:capture memory per rank 从 0.51 GiB 升到 2.11 GiB,低显存配置下可能 OOM;这是 FULL cudagraph 的固有成本,但部署文档应提示。
  • fp8 KV 显式排除:use_gluon_verify 对量化 KV 返回 False,避免触发 Gluon fp8 regime 的 batch_size==1 断言;fp8 用户不会获得本 PR 的 verify 加速,需要明确预期。
  • worker 侧 kv_cache_size_tokens 缺口仍在:该字段在 worker 上仍为 None 的既有问题只是不再影响 verify buffer(4-D 路径不依赖),其他据此做容量估算的代码仍可能受影响。
  • 用户影响(高):ROCm + AITER MLA + DSpark 类投机解码(Kimi-K3、DeepSeek 系列在 TP8/TP16 下小 head 场景)首次获得 FULL cudagraph。c1 吞吐 12.10 -> 46.30 tok/s,ITL 186.98 -> 34.26 ms;c16 吞吐 174.01 -> 326.46 tok/s。reger-men 在 gfx950 上独立复现:spec on 从 15.12 到 89.22 tok/s,且 spec-off 时 PIECEWISE 对比 FULL 也要付出 5.2 倍代价。GSM8K 精度 0.9651 -> 0.9629(误差带内),接受率基本不变,说明收益全部来自执行速度。
  • 系统影响(中):FULL 图捕获需要额外约 1.6 GiB/rank 显存;图模式从 PIECEWISE 升至 FULL,运行期行为与日志(warning 消失)发生变化。
  • 团队影响(中):该 PR 确立了三条可复用的后端设计模式:把捕获区内的同步移到 builder、用组级 KV 属性驱动 CG 支持上报、优先使用 kernel 原生批量入口而非手动展开。对后续 ROCm 其他 attention backend 走向 FULL cudagraph 有直接参考价值。
图捕获核心路径 依赖 AITER v0.1.20 行为 捕获内存增长明显 组级 CG 上报范围

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论