Prhub

#51705 [ROCm][MLA][DCP] Support causal multi-token verification

原始 PR 作者 YukioZzz 合并时间 2026-09-01 01:42 文件变更 7 提交数 6 评论 93 代码增减 +1105 / -111

执行摘要

ROCm AITER MLA 支持 DCP 下因果多 token 投机验证

在 DCP 多卡推理下,投机解码的多 token 验证阶段需要为每个验证 token 构造正确的因果注意窗口,而 DCP 将 KV 按 round-robin 切分到多个 rank,rank-local 长度无法直接表达全局因果尾。PR body 明确说明目标是:"Each verification token gets its correct per-rank causal KV window, and rank-local partial outputs are merged through the existing DCP attention merge"。review 中 GirasoleY 还指出早期实现"omits the current verification block, resulting in incorrect attention output",推动作者将行视图计算完全收敛到 rocm_aiter_mla.py 中。

值得精读,尤其关注三点设计决策:一是 metadata builder 作为 decode 路由唯一所有者的模式,AiterMLADCPVerifyMetadata 的存在即路由结果,避免 builder 与 impl 对同一条件推导两次产生分叉;二是 DCP 下因果窗口的推导——"每个行 drop 的 token 落在不同的 rank 上,不能从本地长度减偏移",这是教科书式容易写错的地方,配套的负向对照实验非常有说服力;三是 segmented MLA 的 split-K 部分和如何以 natural-log LSE 桥接进现有 DCP 跨 rank 合并。建议结合 #53598(前缀缓存 CoW 修复)一起阅读,理解 DCP 在 KV 缓存管理层的完整影响面。

讨论亮点

review 中最有价值的几轮交锋:

GirasoleY(发现正确性缺口):"It looks like the ROCm K3 DCP verification path omits the current verification block... _build_dcp_verify_row_view() excludes the entire current verification block and relies on forward_mqa_with_dcp_verify_window() to add it back. The ROCm K3 target uses amd/linear.py and the generic MLAAttention.forward_impl(), which calls only forward_mqa(), so k_window is always None and the current keys are omitted from target attention."

作者回复:将全部改动收敛到 rocm_aiter_mla.py,用"每个验证行构造从 committed prefix 到自身 token 的因果本地长度"的方式让 segmented 内核直接读取当前 tokens,避免改动 NVIDIA 模型路径。

jamesETsmith(分组语义):合并 draft 与 target 的 KV cache group 会让 non_causal_multi_token_decode=True 导致整个 group 被复制、失去 DCP 优势;作者确认 draft/target 因该 flag 不同而保持独立分组,且 draft 也做 DCP 分片。

okorzh-amd(路由回归风险):use_gluon_verify 放宽到 96 head 会把非 DCP 的 16-head TP8 MTP 从 asm 验证改到 Gluon;作者回应宽路由仅以 dcp_world_size > 1 生效,非 DCP 保持原样。

billishyahao(工程约束):反对用 regex/inspect 探测 AITER 支持矩阵("Please dont use regex here for detecting aiter gluon support matrix from source code"),作者移除 regex,改用显式 96-head DCP 能力 + Triton 版本检查;同时要求恢复 Gluon 为首选路径,作者最终以"Gluon 首选、segmented MLA 兜底"收敛。

GirasoleY(DCP+FULL graphs):质疑 VLLM_ALLOW_DCP_FULL_CUDAGRAPH flag 必要性,作者同意删除冗余 flag 直接放开;对 _DCPA2ABufferPool 要求给出 RCCL 在 cudagraph 下的最小复现,作者因无法复现而移除了该缓冲池。

实现拆解

  1. 新增 segmented DCP 验证元数据与路由判定:在 vllm/v1/attention/backends/mla/rocm_aiter_mla.py 中新增 AiterMLADCPVerifyMetadata dataclass(row_lens、block_table、qo_indptr、page_size、max_kv_seq_len),并把它挂在 AiterMLADecodeMetadata.dcp_verify 上。其存在本身就是路由决策,impl 不再重复推导。新增 _segmented_dcp_verify_supported() 判定:dcp_world_size > 1 且 cp_interleave == 1 且 AITER 导出 segmented MLA decode。

  2. 构造每行因果窗口的 rank-local 视图:_build_dcp_verify_row_view() 对每个验证行 t 计算全局因果上界 [0, seq_len - qlen + t](含自身 token),再按 round-robin 求该窗口在本 rank 上的本地 KV 长度。测试 test_dcp_verify_row_view_is_causal_per_row 和 test_dcp_verify_rows_sum_to_the_global_window_across_ranks 分别守护单行因果性和跨 rank 总窗口覆盖。旧的"只给 committed prefix"计算会确定性失败(2,671/147,456 元素超容差,最大差 0.1311),负向对照已写入验证说明。

  3. 物理块到子页的展开与静态图边界:_segmented_mla_page_size() 取物理块的最大 2 的幂因子、上限 128,作为 segmented kernel 的 TILE_SIZE;_fill_dcp_verify_page_table() 将物理块表展开为子页级 block table。FULL graph capture 期间使用配置最大 DCP-local KV bound、分配稳定地址的 _dcp_verify_buffers,replay 时按实际行长度屏蔽 inactive segments。

  4. 分段部分和合并为 BF16 + natural-log LSE:新增 vllm/v1/attention/ops/rocm_aiter_mla_merge.py,用 Triton kernel _merge_mla_segments_kernel 复刻 AITER 的 split-K 段规约(含 tiles_per_segment = cdiv(seq_len, NUM_SEGMENTS * TILE_SIZE) 划分),将每段 max/expsum/output 合并为 BF16 输出和 float32 自然对数 LSE,供跨 rank DCP 合并使用;单 token DCP decode 也改为直接调用 AITER 并保留未填充 LSE(test_single_token_dcp_decode_returns_unpadded_lse 守护)。

  5. 路由归属与图模式调整:Gluon 仍是小头验证首选,DCP 多 token 验证在 Gluon 可用时走 Gluon flatten,否则回退 segmented MLA;非 DCP 路径保持原有 Gluon/ASM 选择(use_gluon_verify 增加 dcp_world_size 参数)。vllm/platforms/rocm.py 的 check_and_update_config() 移除"DCP 强制 PIECEWISE"的降级,仅保留 PCP 的 PIECEWISE 限制,使 ROCm DCP 可配合 FULL CUDA graphs。

  6. 测试配套:tests/v1/attention/test_rocm_aiter_mla_mtp_split.py 新增 DCP 行视图、静态图边界、FP8 segmented 构建、单 token 未填充 LSE、部分注意合并等用例;test_rocm_aiter_mla_fp8_decode_routing.py 新增 DCP 多 token 验证永不走 Gluon/对 Gluon 无依赖的断言;test_rocm_aiter_mla_causal_verify_mask.py 与 test_rocm_aiter_mla_head_padding.py 适配了 builder 路由时代后的测试夹具改变。

文件 模块 状态 重要度
vllm/v1/attention/backends/mla/rocm_aiter_mla.py MLA 后端 modified 8.84
vllm/v1/attention/ops/rocm_aiter_mla_merge.py 合并内核 added 6.8
vllm/platforms/rocm.py 平台配置 modified 6.22
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py 验证测试 modified 7.52
tests/v1/attention/test_rocm_aiter_mla_fp8_decode_routing.py 路由测试 modified 5.94
tests/kernels/attention/test_rocm_aiter_mla_causal_verify_mask.py 验证掩码 modified 4.57
tests/kernels/attention/test_rocm_aiter_mla_head_padding.py 头填充 modified 3.42

关键符号

_segmented_mla_page_size _segmented_dcp_verify_supported _build_dcp_verify_row_view _fill_dcp_verify_page_table merge_mla_segments_triton _merge_mla_segments_kernel use_gluon_verify forward_mqa check_and_update_config

关键源码片段

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

核心实现文件:新增 segmented DCP 验证元数据与路由判定、每行因果窗口视图构建、子页块表展开、FULL graphs 静态边界缓冲,是全部正确性逻辑的载体。

# vllm/v1/attention/backends/mla/rocm_aiter_mla.py(整理后的核心实现片段)
​
def _segmented_mla_page_size(block_size: int) -> int:
    """取物理 KV block 的最大 2 的幂因子作为子页大小,上限 128。
​
    子页大小会成为 segmented 内核的 TILE_SIZE:调用前缓存被重解释为
    (-1, page_size, 1, head_dim)。内核用 tl.arange(0, TILE_SIZE) 遍历 tile,
    因此必须能被物理块整除且为 2 的幂;128 是 tiling 能支持的"""最大"""tile,
    更大的管理块会被拆成多个子页而不是加宽 tile。
    """
    assert block_size > 0
    return min(128, largest_power_of_2_divisor(block_size))
​
​
def _segmented_dcp_verify_supported(dcp_world_size: int, cp_interleave: int) -> bool:
    """配置层面判定该组合能否走 segmented DCP 验证。
​
    只判配置:某个 batch 是否真的切入该路由还取决于 query 长度。
    round-robin interleave 非 1 被排除,因为该布局下的逐行因果窗口尚未验证。
    """
    return (
        dcp_world_size > 1
        and cp_interleave == 1
        and _segmented_mla_decode_supported()
    )
​
​
@dataclass
class AiterMLADCPVerifyMetadata:
    """每个验证 token 一行 paged-KV,供 segmented DCP 验证使用。
​
    这些张量要么一起产生、要么都不产生,所以作为一个整体值传递:
    它出现在 decode metadata 上这一事实本身就等于 builder 的路由决策,
    impl 不再重新推导。
    """
    # 每个验证行的本地 KV 长度,已经过因果边界裁剪。
    row_lens: torch.Tensor
    # 每行读取的子页 ID,形状 [num_rows, max_local_pages]。
    block_table: torch.Tensor
    # 每行恰好一个 query,因此这里是 arange。
    qo_indptr: torch.Tensor
    # block table 使用的子页大小,也是 segmented 内核的 TILE_SIZE;
    # 由 builder 显式携带,避免 impl 从缓存形状重新推导产生不一致。
    page_size: int
    # 内核可见的 KV 上界。启用 FULL graphs 时对每个 batch 都是配置最大值,
    # 保证 page table 在多次 replay 间保持单一形状。
    max_kv_seq_len: int
​
​
def _build_dcp_verify_row_view(self, qlen, block_table, dcp_tot_seq_lens):
    """为每个验证 token 构造一条因果的 rank-local 行视图。
​
    行 t 的全局可见范围是 [0, seq_len - qlen + t],包含自身 token。
    直接从请求的本地长度减去因果偏移是错的:某一行丢弃的 token 分散在
    qlen - 1 - t 个不同的 rank 上,而减法会从每个 rank 各扣一个 token,
    只有按全局边界重新数 round-robin 命中数才是正确长度。
    """
    assert self.dcp_world_size > 1
    num_reqs = dcp_tot_seq_lens.numel()
    # 每个验证行的全局窗口上界(含),对应 [0, seq_len - qlen + t]。
    global_window_ends = (
        dcp_tot_seq_lens.unsqueeze(1) - (qlen - 1) + torch.arange(1, qlen + 1)
    )
    # 本地长度 = 该全局窗口内本 rank(round-robin interleave=1)持有的位置数。
    # 对窗口长度 w 与 rank r:命中数为 floor((w - 1 - r) / c) + 1(当非负)。
    local_lens = ... # 经由 get_dcp_local_seq_lens 等工具按 rank 计算
    return _fill_dcp_verify_page_table(local_lens, block_table, qlen)
vllm/v1/attention/ops/rocm_aiter_mla_merge.py core-logic

新增文件:提供 Triton 段合并内核,把 AITER segmented MLA 的 split-K 部分和(base-2 max/expsum)合并为 BF16 输出和 natural-log LSE,是跨 rank DCP 合并的前置条件。

# vllm/v1/attention/ops/rocm_aiter_mla_merge.py(新增文件,整理后的核心实现)
​
def merge_mla_segments_triton(
    segm_output: torch.Tensor, # [num_tokens, num_heads, num_segments, kv_lora_rank]
    segm_max: torch.Tensor, # [num_tokens, num_heads, num_segments] 各段局部最大值
    segm_expsum: torch.Tensor, # 各段 exp2 和
    seq_lens: torch.Tensor, # 每行的真实长度,用于计算 active segments
    tile_size: int, # 与 segmented 内核一致的 TILE_SIZE
    out_dtype: torch.dtype,
) -> tuple[torch.Tensor, torch.Tensor]:
    """把 AITER base-2 段部分和合并成输出和 natural-log LSE。
​
    这是 rank-local 的 split-K 归约,与任何集合通信无关;返回的自然对数 LSE
    正是跨 rank DCP 合并要消费的统计量。分区方式必须与 AITER 自身的
    tiles_per_segment = cdiv(seq_len, NUM_SEGMENTS * TILE_SIZE) 保持一致。
    """
    num_tokens, num_heads, num_segments, kv_lora_rank = segm_output.shape
    output = torch.empty((num_tokens, num_heads, kv_lora_rank), dtype=out_dtype)
    lse = torch.empty((num_tokens, num_heads), dtype=torch.float32)
    _merge_mla_segments_kernel[(num_tokens, num_heads)](
        output, lse, segm_output, segm_max, segm_expsum, seq_lens,
        num_query_heads=num_heads,
        out_stride0=output.stride(0), out_stride1=output.stride(1),
        lse_stride0=lse.stride(0),
        TILE_SIZE=tile_size, KV_LORA_RANK=kv_lora_rank,
        NUM_SEGMENTS_PER_SEQ=num_segments, LOGE2=LOGE2,
    )
    return output, lse
vllm/platforms/rocm.py configuration

行为变更:移除 "DCP 与 FULL CUDA graphs 不兼容 " 的强制 PIECEWISE 降级,仅保留 PCP 限制,是本 PR 能跑通 FULL graph capture 的关键配置改动。

# vllm/platforms/rocm.py 的 check_and_update_config() 片段(整理后)
​
@classmethod
def check_and_update_config(cls, vllm_config: "VllmConfig") -> None:
    from vllm.config.compilation import CUDAGraphMode
​
    compilation_config = vllm_config.compilation_config
    parallel_config = vllm_config.parallel_config
​
    # PCP(prefill context parallel)仍与 FULL graphs 不兼容,保持强制降级。
    if (
        compilation_config.cudagraph_mode.has_full_cudagraphs()
        and parallel_config.prefill_context_parallel_size > 1
    ):
        logger.warning_once(
            "Prefill context parallel (PCP) is enabled, which is "
            "incompatible with full CUDA graphs. "
            "Overriding cudagraph_mode to PIECEWISE."
        )
        compilation_config.cudagraph_mode = CUDAGraphMode.PIECEWISE
​
    # DCP 不再强制 PIECEWISE:本 PR 的验证元数据在 capture 阶段使用
    # 配置最大 DCP-local KV 上界,replay 时按实际行长度屏蔽,
    # 因此 ROCm DCP 组合可以直接使用 FULL CUDA graphs。
​
    if parallel_config.worker_cls == "auto":
        parallel_config.worker_cls = "vllm.v1.worker.gpu_worker.Worker"

评论区精华

DCP 验证路径遗漏当前验证块 正确性

GirasoleY 指出 _build_dcp_verify_row_view 排除整个验证块,依赖不存在的 forward_mqa_with_dcp_verify_window 加回,ROCm K3 目标路径会丢当前 token 的 KV。

结论:作者将全部逻辑收敛到 rocm_aiter_mla.py,行 t 使用全局范围 [0, seq_len - qlen + t] 的 rank-local round-robin 长度,segmented 内核直接从缓存读取当前 tokens。 · 已解决

Draft/target 共享 KV 组导致 DCP 失效 设计

jamesETsmith 担心合并 draft 与 target 的 group 会让 non_causal_multi_token_decode=True 使整个组被复制、失去 DCP 分片优势。

结论:作者确认 draft 与 target 因 flag 不同保持独立 KV 组,draft MLA 同样做 DCP 分片。 · 已解决

放宽 Gluon verify 到 96 head 是否影响非 DCP 正确性

okorzh-amd 询问把 16-96 head 验证全部移到 Gluon flatten 是否有意,担心 TP8 MTP(16 本地头、bf16)非 DCP 路径被改道。

结论:宽路由仅以 dcp_world_size > 1 生效,非 DCP 的 16-head MTP 保持 ASM 路径。 · 已解决

kv_cache_utils 中 draft-only bucket 谓词不一致 正确性

okorzh-amd 发现 bucket 判定用 all() 而 MLAAttentionSpec.merge 用 any(),混合 draft/target 的 bucket 会被 mainline 计数为 primary,导致 target 静默丢失 DCP。

结论:作者放弃 drafter-invariant primary_sizes 路径,group_size 恢复为对所有 bucket 的 min/max,PD 分组不变性留作后续工作。 · 已解决

禁止用 regex/ 源码探测 AITER 支持矩阵 设计

billishyahao 反对从 aiter 源码 regex 推断 Gluon max heads("Please dont use regex here"),要求显式可靠的能力探测。

结论:作者移除 regex 与 inspect,改用显式 96-head DCP 能力加 Triton 版本检查。 · 已解决

恢复 Gluon 作为 DCP verify 首选路径 设计

billishyahao 要求保留 Gluon 路径("Can we add gluon path back?"),作者一度只留 segmented 与 asm。

结论:最终 Gluon 为 DCP 多 token 验证首选(K3 原生 96-head flatten),segmented MLA 作为 Gluon 不可用时的兜底。 · 已解决

DCP8+DSpark+prefix caching 触发 CoW 断言崩溃 正确性

jamesETsmith 在 issue 中报告 AgentX warmup 期间 partial-hit 元数据指向已变更的 KV block,触发 _apply_cow 断言并杀死引擎,只能通过关闭前缀缓存绕过。

结论:作者确认属既有 CoW 缺陷被新组合暴露,建议 cherry-pick #53598 修复,并认可补端到端 DCP8+DSpark+prefix-cache 回归测试的提议。 · 已解决

风险与影响

  1. 核心注意力路径变更:rocm_aiter_mla.py 改动 612 行,decode/verify 路由从 impl 移到 builder,且 use_gluon_verify 签名增加 dcp_world_size,任何遗漏调用点都可能让 DCP verify 走到错误内核;测试虽覆盖路由谓词,但仍缺少自动化端到端 DCP8 目标验证测试(依赖人工 GSM8K 全模型回归)。
  2. CUDA graph 行为放开:vllm/platforms/rocm.py 允许 DCP 走 FULL graphs,capture 阶段的静态 max_kv_seq_len bound 与 replay 时的实际行长度屏蔽逻辑直接决定正确性;Kimi-K3 1M context 组合首次被支持,若未来出现更长 context 或混合 interleave,需重新验证。
  3. 强依赖 AITER 版本:segmented MLA decode(aiter.ops.triton.attention.mla.mla_decode_fwd)依赖 ROCm/aiter#4412 的 96-head 网格分解;andyluo7 明确说明 stock Dockerfile 使用 AITER v0.1.19、不包含该能力,需依赖 #52826 升级到 v0.1.20 才能开箱即用。
  4. DCP 与前缀缓存组合:jamesETsmith 报告 DCP8+DSpark+prefix caching 下 _apply_cow 断言崩溃(single_type_kv_cache_manager.py:416-420),本 PR 的 DCP 支持暴露了既有 CoW 缺陷,作者建议 cherry-pick #53598 修复;该组合在 CI 中仍缺乏端到端回归。
  5. 平台隔离风险:本 PR 限定 ROCm AITER 后端,但 AiterMLADCPVerifyMetadata 位于公共 MLA 目录,后续演进若被其他平台误用,可能引入共享代码回归。

用户影响:ROCm(gfx942/gfx950)上使用 MLA 架构 + DCP 的投机解码用户(Kimi-K3 DSpark、DeepSeek MTP 等)首次获得正确的多 token 因果验证,DCP8 + FP8 KV + FULL graphs 组合可用;GSM8K 5-shot exact_match 0.9613(target-only)与 0.9628(stacked Triton draft)均表明精度无退化。
系统影响:解码路径新增 segmented MLA + Triton merge 两条新执行路径,DCP 与 FULL cudagraph 的组合从"强制降级"变为"允许",对 ROCm 推理系统的内存布局与图捕获稳定性有直接影响。
团队影响:这是 AMD 与上游协作的大型跨模块 PR,经历了 6 轮 commit、49 条 review 评论和 AI 辅助修复(andyluo7 多轮 rebase/修复),沉淀了"builder 拥有路由决策""因果窗口必须按全局位置计算"等可复用的设计原则,也为后续 Triton MLA 非因果 draft(#54546)铺路。

核心注意力路径变更 CUDA graph 行为放开 强依赖 AITER 版本 平台限定 ROCm 端到端回归覆盖不足

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论