Prhub

#56825 [Bugfix][SparseMLA] Fix piecewise cudagraph capture crash in index group

原始 PR 作者 JaredforReal 合并时间 2026-09-15 14:51 文件变更 4 提交数 2 评论 6 代码增减 +100 / -6

执行摘要

修复稀疏 MLA 分片 cudagraph 下的启动崩溃

PR body 描述这是引擎启动期硬崩溃:GLM-5.3-Flash(KDA + sparse MLA 混合模型)在 4x GB300、TP4、load_format=dummy 下开启 cudagraph 即挂,栈顶为 vllm/v1/attention/backends/mla/index_group.py 第 133 行的 self.side_stream.wait_event(self.logical_topk_ready),报 torch.AcceleratorError: CUDA error: invalid argument,入口是 profile_cudagraph_memory -> cudagraph_utils.capture -> breakable_cudagraph.add_eager -> unified_mla_attention_with_output。根因是捕获语义:indexer 在捕获段内 record 的 event 是 graph-local 的,而消费方在捕获段之后的 eager break 里 wait 它,CUDA 必然拒绝。作者给出无 vLLM 依赖的最小复现(with torch.cuda.graph(g): ev.record(...) 后再 side.wait_event(ev)),并用 bisect 定位到 PR#53781——它引入了 side stream + logical_topk_ready 这套细粒度事件同步,此前 convert 直接跑在 compute stream 上、没有事件,所以不崩。同时说明为何只有部分模型中招:FULL cudagraph(DeepSeek-V3.2 decode)在图内 record 又图内 wait 合法,prefill 规模 batch 走 workspace 溢出分支根本不碰事件,只有混合模型按 piecewise 捕获 decode 才会命中。

推荐精读,尤其是正在处理 cudagraph / 流同步 / 稀疏注意力后端的工程师:

  1. 重点看 set_logical_topk_ready 与 _convert_once 成对守卫的设计——它示范了“生产者停止 record、消费者回退 wait_stream”这种成对修改模式,避免留下半废弃的同步原语。
  2. 关注 in_piecewise_cudagraph() 放在 vllm/forward_context.py 的取舍:为什么不放在 attention 目录下,以及它的捕获/回放双阶段语义(注释里明确写了 at capture or replay time)。
  3. 借鉴 PR body 的排查方法论:最小复现(脱离 vLLM 的 10 行 CUDA 复现)+ bisect 定位引入 PR + duplicate check,是排查“只在特定 cudagraph 模式出现”的崩溃的模板。
  4. 若后续要改这块代码,建议先补两条测试:FULL 模式仍走 wait_event 的断言,以及 hisparse copy_stream 在 piecewise 下的显式回归用例。
讨论亮点
  • 根因与复现的完整性来自 PR body,而不是 review 评论:作者给出了最小复现(图内 record、图外 wait 即 invalid argument)、bisect 结论(#53781 引入侧流与事件)和 duplicate check(”no open PR covers this crash; #54851 is a different piecewise-mode issue“),这三点基本替代了常规 review 的定位过程。
  • 人工 review 极简: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.”,随后 ZJY0516 直接 APPROVED、没有任何 inline 评论;review_comments_count 为 0。
  • 合入决策主要依赖 CI:ZJY0516 与 njhill 各触发一次 /ci run(Buildkite #88820、#89001),作者再 /ci retry 重试 3 个失败 job——失败 job 的具体原因未在提供材料中说明,无法判断是否与本改动相关。
  • 唯一的设计权衡只以注释和 PR 描述形式记录:”FULL/eager modes keep the fine-grained event“ 与 “identical to the existing has_indexer=False path“ 是对“为什么可以退化成 wait_stream“的论证,但没有 reviewer 追问是否需要在 piecewise 下重建事件、是否可用 wait_event 之外的替代方案。

实现拆解

  1. 新增 piecewise 判定助手(vllm/forward_context.py):新增 in_piecewise_cudagraph(),实现为 is_forward_context_available() and get_forward_context().cudagraph_runtime_mode == CUDAGraphMode.PIECEWISE,注释明确它“在捕获和回放阶段都成立”。放在 forward_context 而非 index_group,一是便于 hisparse 等模块复用,二是让测试能通过 set_forward_context(..., cudagraph_runtime_mode=PIECEWISE) 精确驱动分支。
  2. 生产者侧跳过 record(vllm/v1/attention/backends/mla/index_group.py 的 SparseMLAIndexGroup.set_logical_topk_ready):把原来的单行 if layer_index == 0 and self.has_indexer: record(...) 改写为 early-return 结构,并在 record 前插入 if in_piecewise_cudagraph(): return。理由写在注释里:段内 record 的事件对 eager 消费方不可用,而且消费方已改为 wait_stream、不会再触碰该事件,因此 record 本身也应跳过,避免留下半废弃的同步原语。
  3. 消费者侧回退到 wait_stream(同文件 _convert_once):if self.has_indexer: 改为 if self.has_indexer and not in_piecewise_cudagraph():,piecewise 下走 else 分支即 self.side_stream.wait_stream(compute_stream),与既有的 has_indexer=False 路径完全一致;compute_stream.wait_event(self.physical_topk_ready) 保持不变,侧流—主流同步闭环仍然完整。PR body 给出的正确性前提是:convert 总是在同一 compute stream 上、indexer 之后被调用,所以整流等待在语义上等价。注意 num_tokens > physical_topk_indices.shape[0] 的 workspace 溢出分支原样保留、不触碰事件,这也是为什么 prefill 形状不崩。
  4. HiSparse 复制流同步(vllm/v1/hisparse/runtime.py 的 _resolve_and_stage_group):if group.logical_topk_ready is not None: 改为 ... and not in_piecewise_cudagraph():,否则回退到 group.copy_stream.wait_stream(compute_stream)。这是同一 bug 在主机缓存 hot-cache 换入(_swap_rows / hisparse_gather_compact)路径上的对应修复,覆盖 HiSparse 的 TP 设备侧 staging。
  5. 测试与验证配套(tests/v1/attention/test_sparse_mla_backends.py):新增 test_index_group_convert_during_piecewise_capture,用 BreakableCUDAGraphCapture + torch.cuda.stream(capture_stream) + set_forward_context(..., CUDAGraphMode.PIECEWISE) 复刻“段内 record、capture.add_eager 中 convert”的调用顺序,replay 后把 group.physical_topk_indices 与 triton_convert_req_index_to_global_index 参考值逐元素比对;修复前在 index_group.py:133 复现生产报错。作者另报告 Sparse MLA 子集 47 项通过、4x GB300 上 GLM-5.3-Flash TP4 真实权重下引擎可启动并正常生成、GSM8K 5-shot 91.8%(invalid rate 0.0%)。
  6. 无配置、schema、部署脚本改动:没有新增 vLLM 配置项或 CLI 参数,仅新增一个内部 helper 函数;对使用方无 API 层面的迁移成本。
文件 模块 状态 重要度
vllm/v1/attention/backends/mla/index_group.py 稀疏索引组 modified 6.33
vllm/forward_context.py 前向上下文 modified 5.94
tests/v1/attention/test_sparse_mla_backends.py 注意力测试 modified 6.15
vllm/v1/hisparse/runtime.py 主机缓存 modified 5.43

关键符号

in_piecewise_cudagraph set_logical_topk_ready _convert_once _resolve_and_stage_group convert_logical_to_physical_topk test_index_group_convert_during_piecewise_capture

关键源码片段

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

修复的核心:`set_logical_topk_ready` 与 `_convert_once` 两处守卫决定 sparse MLA 在 piecewise 模式下是否触碰 graph-local 事件,直接对应崩溃栈顶 index_group.py:133。

# vllm/v1/attention/backends/mla/index_group.py(修复后的关键路径)
from vllm.forward_context import in_piecewise_cudagraph
​
​
@dataclass
class SparseMLAIndexGroup:
    # 其余字段与方法略:logical_topk_indices / physical_topk_indices /
    # valid_topk_counts / side_stream / logical_topk_ready /
    # physical_topk_ready / has_indexer ...
​
    def set_logical_topk_ready(self, layer_index: int) -> None:
        # 只有真正产出 topk 的第 0 层(indexer 本身)才需要发信号
        if layer_index != 0 or not self.has_indexer:
            return
        # piecewise cudagraph 模式下,indexer 在捕获段内执行,而消费方 convert
        # 落在紧随其后的 eager break(unified_mla_attention_with_output 是
        # @eager_break_during_capture 点)。捕获期间 record 的事件是 graph-local 的,
        # 在 eager 阶段 wait 它会直接抛 cudaErrorInvalidValue;消费方此时已改用
        # wait_stream、不再触碰该事件,因此这里连 record 一并跳过。
        if in_piecewise_cudagraph():
            return
        self.logical_topk_ready.record(current_stream())
​
    def _convert_once(
        self,
        layer_index: int,
        logical_topk_indices: torch.Tensor,
        req_id_per_token: torch.Tensor,
        block_table: torch.Tensor,
        block_size: int,
        *,
        block_stride_rows: int | None,
        return_valid_counts: bool,
    ):
        num_tokens = logical_topk_indices.shape[0]
        # 大 batch(prefill 规模)走 workspace 溢出分支,该分支完全不碰事件,
        # 这正是为什么只有 decode 形状的捕获会踩到这个 bug。
        if num_tokens > self.physical_topk_indices.shape[0]:
            return triton_convert_req_index_to_global_index(
                req_id_per_token,
                block_table,
                logical_topk_indices,
                BLOCK_SIZE=block_size,
                BLOCK_STRIDE_ROWS=block_stride_rows,
                NUM_TOPK_TOKENS=logical_topk_indices.shape[1],
                return_valid_counts=return_valid_counts,
            )
​
        physical_topk_indices = self.physical_topk_indices[:num_tokens]
        valid_topk_counts = self.valid_topk_counts[:num_tokens]
        compute_stream = current_stream()
        if layer_index == 0:
            # FULL cudagraph / eager 模式保留细粒度事件,维持 side stream 与
            # compute stream 的重叠;piecewise 模式退化为整流等待,与既有的
            # has_indexer=False 分支一致:convert 总是在同一 compute stream 上、
            # indexer 之后被调用,因此 wait_stream 在语义上等价且安全。
            if self.has_indexer and not in_piecewise_cudagraph():
                self.side_stream.wait_event(self.logical_topk_ready)
            else:
                self.side_stream.wait_stream(compute_stream)
            with self.side_stream:
                triton_convert_req_index_to_global_index(
                    req_id_per_token,
                    block_table,
                    logical_topk_indices,
                    BLOCK_SIZE=block_size,
                    BLOCK_STRIDE_ROWS=block_stride_rows,
                    NUM_TOPK_TOKENS=logical_topk_indices.shape[1],
                    return_valid_counts=return_valid_counts,
                    out=physical_topk_indices,
                    valid_counts_out=(
                        valid_topk_counts if return_valid_counts else None
                    ),
                )
                # physical_topk_ready 的 record 与等待都发生在 eager break 中,
                # 不涉及捕获段,因此可以继续保留事件语义。
                self.physical_topk_ready.record(self.side_stream)
        compute_stream.wait_event(self.physical_topk_ready)
​
        if return_valid_counts:
            return physical_topk_indices, valid_topk_counts
        return physical_topk_indices
vllm/forward_context.py core-logic

新增公共判定 `in_piecewise_cudagraph()`,是本修复能被 index_group 与 hisparse 两处复用的基础,也是测试驱动分支的入口。

# vllm/forward_context.py(新增判定助手,被 index_group.py 与 hisparse/runtime.py 复用)
def in_piecewise_cudagraph() -> bool:
    """当前 forward 是否运行在 piecewise cudagraph 模式下(捕获与回放阶段都算)。"""
    return (
        # 没有 forward context 时(例如部分单测)保守返回 False,走原有事件路径
        is_forward_context_available()
        and get_forward_context().cudagraph_runtime_mode == CUDAGraphMode.PIECEWISE
    )
tests/v1/attention/test_sparse_mla_backends.py test-coverage

新增的回归测试在 BreakableCUDAGraphCapture 下复刻“段内 record + eager break convert”,是本修复唯一能挡住回归的自动化防线。

# tests/v1/attention/test_sparse_mla_backends.py(新增回归测试,已精简中间构造代码)
def test_index_group_convert_during_piecewise_capture():
    """piecewise 模式下 indexer 被捕获,convert 落在紧随其后的 eager break。
​
    convert 的 side stream 此时不能 wait `logical_topk_ready`:段内 record
    的事件是 graph-local 的,eager wait 会抛 cudaErrorInvalidValue。
    """
    device = torch.device(DEVICE_TYPE)
    num_tokens, num_topk, num_requests, blocks_per_req, block_size = 8, 128, 4, 4, 16
​
    logical_topk_indices = torch.randint(
        0,
        block_size * blocks_per_req,
        (num_tokens, num_topk),
        dtype=torch.int32,
        device=device,
    )
    builder = SparseMLAIndexGroupBuilder(logical_topk_indices)
    group, layer_index = builder.register_layer(is_index_producing_layer=True)
    assert layer_index == 0 and group.has_indexer # 走 has_indexer=True 的事件分支
​
    # ... 此处构造 req_id / block_table / attn_metadata,并算出 expected 参考值 ...
​
    vllm_config = create_vllm_config(model_name="Qwen/Qwen3.5-0.8B")
    capture_stream = torch.cuda.Stream(device=device)
    # torch.cuda.stream 会走 vLLM patch 过的 set_stream,capture_stream 因此
    # 能被 current_stream() 正确跟踪,贴近生产路径。
    with (
        torch.cuda.stream(capture_stream),
        set_forward_context(
            None, vllm_config, cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE
        ),
    ):
        capture = BreakableCUDAGraphCapture()
        with capture:
            marker.add_(1)
            # 段内 record(修复前崩溃点),convert 放到 eager break 执行,
            # 复刻 mla.py -> unified_mla_attention_with_output 的调用顺序
            group.set_logical_topk_ready(layer_index)
            capture.add_eager(
                lambda: group.convert_logical_to_physical_topk(
                    layer_index,
                    logical_topk_indices,
                    attn_metadata,
                    block_stride_rows=None,
                    return_valid_counts=False,
                )
            )
            marker.add_(1)
        capture.replay()
        capture_stream.synchronize()
​
    result = group.physical_topk_indices[:num_tokens]
    torch.testing.assert_close(result, expected, rtol=0, atol=0)

评论区精华

捕获段内 record 的事件为何不能 eager wait 正确性

PR body 给出脱离 vLLM 的最小复现:`with torch.cuda.graph(g): ev.record(torch.cuda.current_stream())` 后在捕获之外 `side.wait_event(ev)` 即 invalid argument;并解释 FULL cudagraph(DeepSeek-V3.2 decode)图内 record + 图内 wait 合法,prefill 规模 batch 走 `num_tokens > physical_topk_indices.shape[0]` 的 workspace 分支不碰事件,所以只有混合模型按 piecewise 捕获 decode 时才崩。

结论:根因确认,采用“piecewise 下彻底跳过事件”而非重建事件的最小改动方案。 · 已解决

细粒度事件降级为整流 wait_stream 的取舍 设计

PR body 论证:piecewise 下 convert 总是同一 compute stream 上、indexer 之后被调用,所以 `side_stream.wait_stream(compute_stream)` 与既有 `has_indexer=False` 路径等价且安全;FULL/eager 模式继续保留事件重叠。reviewer 未再追问事件方案是否可保留、以及整流等待是否影响 piecewise decode 的 ITL。

结论:作为最小可合入方案被接受,但缺少 piecewise 模式下的性能量化数据。 · 已接受(性能未验证)

HiSparse 复制流是否同样受影响 正确性

作者把同一守卫推广到 `vllm/v1/hisparse/runtime.py::_resolve_and_stage_group`,让 `copy_stream` 在 piecewise 下也走 `wait_stream`,覆盖 hot-cache 换入(`_swap_rows` / `hisparse_gather_compact`)路径。

结论:源码已一并修复,但新增回归测试仅覆盖 index_group 路径,hisparse 分支只由既有 “hisparse” 关键词测试过滤间接覆盖。 · 部分覆盖

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.”;ZJY0516 直接 APPROVED 且无 inline 评论,review_comments_count 为 0,随后由 ZJY0516 与 njhill 各触发一次 `/ci run`,作者再 `/ci retry` 重试 3 个失败 job。

结论:合入主要依赖 CI 绿灯与作者自测(4x GB300 E2E + GSM8K 91.8%),缺少独立 review 视角的技术质疑。 · 已合入

重复性检查与历史 PR 关联 question

PR body 明确 “Duplicate check: no open PR covers this crash; #54851 is a different piecewise-mode issue (KV-cache writes baked into captured graphs for DSA models on multi-node TP)”,同时用 bisect 指出 #53781 是引入 side stream + logical_topk_ready 的 PR。

结论:确认非重复修复,并把回归归因到具体历史改动,便于后续审计同类 piecewise 交互。 · 已确认

风险与影响

  1. 同步语义降级带来的潜在性能影响未量化:piecewise 模式下 _convert_once 从 wait_event(logical_topk_ready) 改为 wait_stream(compute_stream),side stream 现在等待 compute stream 上当时已入队的全部工作,而不是只等到 indexer 的 topk 产出点。由于 convert 恰好紧跟在 indexer 之后调用,理论上差异很小,但 PR 未提供 piecewise decode 的吞吐/ITL 对比数据,回归风险只能靠 E2E 与 GSM8K 覆盖率来兜底。
  2. 事件被“静默禁用”的可观测性风险:set_logical_topk_ready 与 _convert_once 两处守卫必须同时成立——若未来有第三个消费方(例如新的稀疏后端)在 piecewise 下仍然 wait_event(logical_topk_ready),就会等到一个陈旧事件或得到未定义行为。当前仓库内仅发现 index_group 与 hisparse/runtime.py 两个消费点,但这个约束没有以断言或文档形式固化。
  3. HiSparse 分支缺少新增回归测试:新测试只覆盖 SparseMLAIndexGroup._convert_once + has_indexer=True 的路径;vllm/v1/hisparse/runtime.py::_resolve_and_stage_group 中 copy_stream 的同类守卫只靠既有 “hisparse” 关键词测试过滤覆盖,没有像 index_group 那样构造“段内 record + eager 消费”的显式用例。
  4. 非 piecewise 模式没有新增断言:FULL/eager 路径继续使用事件同步,但改动没有加任何“FULL 模式仍然走 wait_event”的测试,存在后续改动误把事件路径永久关闭而 CI 不报错的可能。
  5. 判定函数依赖 forward context 存在性:in_piecewise_cudagraph() 在 forward context 缺失时返回 False,会退回事件路径;若某些捕获/回放时序下 forward context 未设置或 runtime mode 不是 PIECEWISE,崩溃会重新出现。
  6. 平台与验证面:E2E 仅在 4x GB300、TP4、GLM-5.3-Flash 上做;测试文件对非 CUDA 平台整模块 skip,ROCm 稀疏 MLA 后端尚未接入(文件内 TODO),因此该 bug 在 ROCm 上的等价形态未被覆盖。
  • 用户侧:混合稀疏 MLA 模型(GLM-5.3-Flash 这类 KDA + sparse MLA)在开启 cudagraph(piecewise)时可正常启动引擎;修复前这是启动即崩的阻塞性问题,用户只能关闭 cudagraph 或改用 FULL 模式绕行。
  • 系统侧:改动落在 decode 热路径的跨流同步逻辑上,涉及 SparseMLAIndexGroup 的 side stream 与 HiSparse 的 copy stream;正确性由 physical_topk_ready 事件闭环与“convert 总在 indexer 之后、同一 compute stream 调用”的前提保证,对 FULL cudagraph 与 eager 路径零影响。
  • 团队侧:新增的 in_piecewise_cudagraph() 是一个可复用的公共判定,后续任何需要在捕获段与 eager break 之间区分行为的模块都可以复用,降低了同类 piecewise 陷阱的再次发生概率。
  • 影响范围有限但关键:仅 4 个文件、+100/-6,不改变任何对外接口、配置或数据格式,风险集中在 piecewise 路径的同步时序上。
核心解码路径变更 同步语义降级为整流等待 HiSparse 分支缺少新增回归测试 FULL/eager 事件路径无新增断言 端到端验证平台单一(GB300/TP4)

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论