Prhub

#55879 [Bugfix][Attention] Stabilize sparse-MLA DCP for GLM PCP evals

原始 PR 作者 khluu 合并时间 2026-09-16 00:58 文件变更 10 提交数 13 评论 9 代码增减 +411 / -13

执行摘要

修复稀疏 MLA 在 DCP/PCP 组合下的崩溃与卡死

PR body 明确目标为 "Make the B200 GLM-5.2 PCP evaluation complete reliably across its TP/PCP/DCP configurations"。主构建 #89033 先后暴露两个独立的 memory-budget 失败:TP2/PCP2 在 FlashInfer CUTLASS autotuning 需要 12.22 GiB 而仅剩约 7.6 GiB;TP1/PCP4/DCP4 在 CUDA graph profiling 后得到负的 3.23 GiB KV cache 预算。而关联 issue #50791 指出 #50781 的根因:FlashInfer 的 trtllm-gen launcher 在请求 LSE 时会从固定 workspace 中划出 softmax-stats slab,"a single request with a moderately long prompt is enough to bring down every DCP worker and the API server",且该 workspace 对 DCP、head 数、batch 数完全无感知。

值得精读。该 PR 是稀疏 MLA 在 DCP/PCP 组合下稳定性修复的标本案例:建议重点学习 supports_combination 的能力契约设计(显式拒绝不支持的组合并给出可执行的回退建议)、Triton JIT warmup key 的覆盖策略(归一化 key + 对齐笛卡尔积)、以及用 monkeypatch 把 torch.tensor 替换为抛错函数来证明热路径无设备标量构造的回归测试手法。注意 PR 依赖 open 的 #50791 且携带其原始作者提交(thegoldenflow),后续 main 上 #50791 的合并方式需要跟进,避免重复提交或署名冲突。

讨论亮点

该 PR 的 review 正文为空(fork 的自动 review 被禁用,tlrmchlsmth 直接 APPROVED),核心讨论都发生在 issue 评论与 Buildkite gate 里:

tlrmchlsmth:"merged main to pick up https://github.com/vllm-project/vllm/pull/55499, which I think was breaking the CI for this PR"

khluu 的多轮 gate 归因:明确把 #89050 归为 main 分支运行而非 PR 分支证据,"I am not treating it as PR-branch gate evidence";每次新增 commit 后提醒旧 approval 视为 stale,直到 final exact gate #89097 三配置全部通过。

NCCL 定位的根因交锋:"rank 0 entering the LSE all-gather with count 9,792 while ranks 1-3 entered with 10,240——the exact 153 vs 160 local query rows x 64 heads mismatch",由此确认 FLASHINFER_MLA_SPARSE 不做跨 DCP KV gather,设计结论是让 supports_combination 拒绝组合并回退 FLASHMLA_SPARSE。

decode 热路径的 sync guard:"get_dcp_local_seq_lens called torch.tensor(dcp_rank, device=seq_lens.device) from metadata build(), and the runtime guard rejected that per-batch host-to-device scalar construction",最终采用初始化时物化 + monkeypatch 回归测试的方案。

实现拆解

变更分六步推进,每步由一次 exact Buildkite gate 暴露并验证:

  1. 移植并适配 DCP 感知的 workspace 精确 sizing(vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py):携带 open PR #50791 中 thegoldenflow 的原始提交,新增纯 Python 函数 compute_trtllm_sparse_mla_workspace_bytes,按 trtllm-gen 内核公式 sizeof(float2) * allgather_heads * batch * 256 + 1 MiB 字节级复现软max slab;_required_workspace_bytes 解析最终大小并尊重 VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE override(低于计算值时告警);_get_workspace_buffer 增加 min_bytes 参数;FlashInferMLASparseTRTLLMMetadataBuilder.__init__ 在 DCP 下、首次 forward/capture 前完成预分配。适配点是只作用于 SM100 TRTLLM builder,SM120 独立后端保持原行为。
  2. 调整评测内存预算与 scheduler cap(两个 tests/evals/gsm8k/configs/*.yaml):TP2/PCP2 设 --gpu-memory-utilization 0.85 给 CUTLASS autotuning 留约 12.5 GiB;TP1/PCP4/DCP4 设 0.96(高于 runtime 建议的 0.9545)保证 KV 预算为正,并把 --max-num-batched-tokens 从 32768 降到 4096,使 64 heads/rank、DCP4 下的 workspace 恰好锁定为 2.39 GiB 而非 16.39 GiB。
  3. 修复空 DCP 温启 rank(vllm/v1/attention/ops/dcp.py):mask_dcp_empty_shards_ 在 seq_lens 为空时把 padded LSE 所有行填 -inf 并提前返回,避免旧逻辑把序列索引钳到 -1 后索引空张量。
  4. 扩展 indexer 的 Triton warmup key(vllm/v1/attention/backends/mla/indexer.py):BuildPrefillChunkMetadataKernel.get_warmup_keys 在 PCP+DCP 时用 zip_inputs 额外预热归一化 key (DCP_RANK=0, DCP_WORLD=1);input_variant 由 2 种对齐组合扩展为 uncompressed_seq_lens 与 cu_compressed_seq_lens 各自对齐/不对齐的 4 种笛卡尔积,覆盖 PCP 打包分配导致的非 16 字节对齐指针。
  5. 拒绝不支持的 FlashInfer 后端组合(flashinfer_mla_sparse.py 的 supports_combination):当 prefill_context_parallel_size > 1 且 decode_context_parallel_size > 1 时返回明确错误,自动选择回退到能先 gather DCP KV shard 的 FLASHMLA_SPARSE;PCP-only 与 DCP-only 组合继续放行。
  6. 消除 decode 热路径 GPU 同步(indexer.py + vllm/v1/attention/backends/utils.py):metadata builder 初始化时物化一次 dcp_rank_tensor(int32 设备标量),_dcp_localize_decode_seq_lens 改传该张量;get_dcp_local_seq_lens 的 dcp_rank 参数扩展为 int | torch.Tensor | None,Tensor 分支断言 dtype、device、numel 后直接复用,避免 build() 中每次构造 host-to-device 标量触发 runtime 的 GPU<->CPU sync guard。

测试配套:新增 tests/v1/attention/test_flashinfer_sparse_mla_workspace.py(166 行,字节精确复现 #50781 报告值、env override 语义、后端组合允许/拒绝);test_indexer_dcp_localize.py 用 monkeypatch 让 torch.tensor 抛错以证明热路径复用 rank 张量;test_indexer_deepseek_v4_slot_mapping.py 验证归一化 key 与 4 种对齐组合;test_dcp_a2a_pack_mask.py 覆盖空 rank 并把 CUDA 用例标记 requires_accelerator。

文件 模块 状态 重要度
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py MLA 后端 modified 8.42
vllm/v1/attention/backends/mla/indexer.py 稀疏索引器 modified 6.87
vllm/v1/attention/backends/utils.py DCP 工具 modified 5.84
vllm/v1/attention/ops/dcp.py DCP 算子 modified 4.09
tests/v1/attention/test_flashinfer_sparse_mla_workspace.py workspace 测试 added 7.34
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py 索引器测试 modified 5.45
tests/v1/attention/test_indexer_dcp_localize.py DCP 定位测试 modified 5.47
tests/v1/attention/test_dcp_a2a_pack_mask.py mask 测试 modified 4.82
tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP1-PCP4-DCP4-EP.yaml 评测配置 modified 3.89
tests/evals/gsm8k/configs/GLM-5.2-NVFP4-TP2-PCP2-EP.yaml 评测配置 modified 3.25

关键符号

compute_trtllm_sparse_mla_workspace_bytes _required_workspace_bytes _get_workspace_buffer FlashInferMLASparseTRTLLMBackend.supports_combination FlashInferMLASparseTRTLLMMetadataBuilder.__init__ BuildPrefillChunkMetadataKernel.get_warmup_keys BuildPrefillChunkMetadataKernel.warmup_inputs get_dcp_local_seq_lens mask_dcp_empty_shards_ FlashInferMLASparseIndexerMetadataBuilder._dcp_localize_decode_seq_lens

关键源码片段

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

PCP+DCP 归一化 warmup key、4 种指针对齐组合、dcp_rank_tensor 物化,解决首请求 JIT 不同步与 decode 热路径 GPU sync。

def get_warmup_keys(self, vllm_config: VllmConfig) -> list[CompileKey]:
    max_tokens = max(1, min(vllm_config.scheduler_config.max_num_batched_tokens, 8))
    hf_text_config = vllm_config.model_config.hf_text_config
    parallel_config = vllm_config.parallel_config
    dcp_world = parallel_config.decode_context_parallel_size
    dcp_interleave = parallel_config.cp_kv_cache_interleave_size
    dcp_rank = get_dcp_group().rank_in_group if dcp_world > 1 else 0
​
    # PCP+DCP 的 prefill 会构造全局 PCP chunk plan,本内核收到的行起点
    # 已是本地化结果,运行时 key 被归一化为 rank 0/world 1;
    # 必须显式预热该 key,否则某个 rank 会在首次真实 batch 时触发 JIT,
    # 而其它 rank 已进入 collective,导致 all-gather 挂起超时。
    dcp_cases = [dict(DCP_RANK=dcp_rank, DCP_WORLD=dcp_world)]
    if parallel_config.prefill_context_parallel_size > 1 and dcp_world > 1:
        dcp_cases.append(dict(DCP_RANK=0, DCP_WORLD=1))
​
    compress_ratios = tuple(
        dict.fromkeys(
            max(1, int(ratio))
            for ratio in (
                *(getattr(hf_text_config, "compress_ratios", None) or (1,)),
                getattr(hf_text_config, "index_kpool", 1) or 1,
            )
        )
    )
    index_kpool = getattr(hf_text_config, "index_kpool", None)
    if index_kpool and index_kpool > 1 and index_kpool not in compress_ratios:
        compress_ratios = compress_ratios + (index_kpool,)
​
    return self._trace_dispatch(self.dispatch)(
        zip_inputs(*dcp_cases), # 同时覆盖配置的 rank 与归一化的 (0, 1) 两种 key 族
        # 覆盖 Triton 的 divisible、exact-one 与 generic i32 三类标量。
        query_slice_start=(0, 1, 2),
        query_slice_stop=(1, 2 * max_tokens - 1, 2 * max_tokens),
        DCP_INTERLEAVE=dcp_interleave,
        BLOCK_SIZE=self.BLOCK_SIZE,
        COMPRESS_RATIO=list(compress_ratios),
        # PCP 的全局累计长度是 packed 多行分配里的第二行,指针不一定
        # 16 字节对齐;prefill chunking 也能独立地错位 uncompressed 长度,
        # 因此对两个指针分别预热对齐 / 不对齐共 4 种 input_variant。
        input_variant=(
            TritonPointerInputVariant.from_alignment(
                uncompressed_seq_lens=True, cu_compressed_seq_lens=True,
            ),
            TritonPointerInputVariant.from_alignment(
                uncompressed_seq_lens=True, cu_compressed_seq_lens=False,
            ),
            TritonPointerInputVariant.from_alignment(
                uncompressed_seq_lens=False, cu_compressed_seq_lens=True,
            ),
            TritonPointerInputVariant.from_alignment(
                uncompressed_seq_lens=False, cu_compressed_seq_lens=False,
            ),
        ),
    )
vllm/v1/attention/backends/utils.py core-logic

get_dcp_local_seq_lens 扩展为接受设备张量 rank,是消除 decode 热路径 GPU<->CPU 同步的关键。

def get_dcp_local_seq_lens(
    seq_lens: torch.Tensor,
    dcp_size: int = 1,
    dcp_rank: int | torch.Tensor | None = None,
    cp_kv_cache_interleave_size: int = 1,
) -> torch.Tensor:
    """在 DCP 下把全局 decode seq_lens 切分为本 rank 的本地长度。
​
    各 DCP rank 上的 KV cache 大小可能不同,需要按 rank 偏移计算本地
    seq_lens。dcp_rank 既可以传普通 int(会构造设备标量,仅用于非热路径),
    也可以传已经物化的 int32 设备标量,直接复用避免 GPU<->CPU 同步。
    """
    seq_lens_i32 = seq_lens.to(torch.int32)
​
    if dcp_rank is None:
        # 不指定 rank 时返回全部 rank 的展开结果,供统一计算使用。
        rank_offsets = torch.arange(
            dcp_size, dtype=torch.int32, device=seq_lens.device,
        ).view(*((1,) * seq_lens_i32.dim()), dcp_size)
        seq_lens_tiled = seq_lens_i32.unsqueeze(-1)
    elif isinstance(dcp_rank, torch.Tensor):
        # 热路径:直接复用 builder 初始化时物化的标量,必须与 seq_lens
        # 同 dtype、同设备且为单元素,否则说明调用方传错对象。
        assert dcp_rank.dtype == torch.int32
        assert dcp_rank.device == seq_lens.device
        assert dcp_rank.numel() == 1
        rank_offsets = dcp_rank
        seq_lens_tiled = seq_lens_i32
    else:
        # 非热路径:int 会被包成设备标量(会引入一次 host-to-device 同步)。
        rank_offsets = torch.tensor(
            dcp_rank, dtype=torch.int32, device=seq_lens.device,
        )
        seq_lens_tiled = seq_lens_i32
​
    base = (
        seq_lens_tiled
        // cp_kv_cache_interleave_size
        // dcp_size
        * cp_kv_cache_interleave_size
    )
    remainder = seq_lens_tiled - base * dcp_size
    remainder = torch.clip(
        remainder - rank_offsets * cp_kv_cache_interleave_size,
        0,
        cp_kv_cache_interleave_size,
    )
    return base + remainder

评论区精华

exact gate 归因与 stale approval 管理 测试

khluu 在 issue 评论中逐轮记录 #89033/#89040/#89050/#89065/#89083/#89087/#89089/#89093/#89097 的 Buildkite 门禁结果;明确把 #89050 归因为 main 分支运行而非 PR 分支证据,并在每次新增 commit 后提醒旧 approval 视为 stale。

结论:以最终 exact gate #89097 为准,三组配置全部通过;验证流程严格区分 literal-head、actual-branch 与诊断运行。 · 已解决

零本地序列 DCP warmup rank 的掩码行为 正确性

CUDA graph warmup 下某 DCP rank 无本地序列但 padded LSE 仍有行,旧逻辑把序列索引钳到 -1 并索引空 seq_lens 张量导致崩溃。

结论:mask_dcp_empty_shards_ 在 seq_lens 为空时直接把所有 padded LSE 行填 -inf 并提前返回。 · 已解决

PCP+DCP 下 FlashInfer sparse MLA 的不等 all-gather 挂起 设计

NCCL 追踪显示第一个 PCP+DCP 请求中 rank 0 进入 LSE all-gather 的 count 为 9792,ranks 1-3 为 10240(153 vs 160 本地 query rows x 64 heads);根因是 FLASHINFER_MLA_SPARSE 不做跨 DCP KV gather。

结论:supports_combination 拒绝 PCP+DCP 组合并给出可执行错误,自动选择回退到实现所需 gather 的 FLASHMLA_SPARSE;CPU 回归覆盖组合拒绝与单模式放行。 · 已解决

decode 热路径的 GPU<->CPU 同步 guard 性能

get_dcp_local_seq_lens 在 metadata build() 中执行 torch.tensor(dcp_rank, device=seq_lens.device),被 runtime guard 判为每 batch 的 host-to-device 标量构造并拒绝。

结论:在 builder 初始化时物化 dcp_rank_tensor,热路径直接复用;回归测试用 monkeypatch 让 torch.tensor 抛错来证明无替换构造。 · 已解决

合并 main 引入 #55499 修复 CI other

tlrmchlsmth 表示合并 main 以引入 PR #55499,否则会破坏本 PR 的 CI。

结论:已合并 main,后续 gate 正常执行。 · 已解决

风险与影响

  1. workspace 预分配与显存占用:compute_trtllm_sparse_mla_workspace_bytes 按 max_num_batched_tokens 计算,普通用户在 DCP 下若 scheduler cap 较大(如 32768 tokens),workspace 会膨胀到 16.39 GiB 量级,可能挤占 KV cache 或直接 OOM;本次用评测配置降 cap 规避,但生产部署需要意识到该行为变化。
  2. CUDA graph 地址烘焙:workspace 大小在首次 capture 前必须一次到位(代码注释明确 "no lazy regrow"),若未来有路径在 capture 后扩大 batch 上限,会触发溢出。
  3. 后端自动回退:PCP+DCP 下 FLASHINFER_MLA_SPARSE 被拒绝后自动选 FLASHMLA_SPARSE;若运行环境未安装 FlashMLA 依赖,可能导致启动失败。
  4. warmup 集合扩大:indexer 的编译 key 从 2 变 4 种对齐组合并新增一组 DCP key,首次启动编译时间与显存占用略增;对极端 kernel 数量可能拉长启动。
  5. 验证覆盖:新增回归均为 CPU 单测,GPU 行为只由一次性 Buildkite 手工 gate 验证(最终 #89097 通过),常规 CI 对 DCP/PCP 组合无持续覆盖。
  6. 评测配置贴近显存上限:TP1/PCP4/DCP4 用 0.96 利用率,若 B200 空闲显存有波动仍可能 OOM。

用户影响:所有在 B200/SM100 上使用 FLASHINFER_MLA_SPARSE + DCP(或 PCP+DCP)的用户。此前单条约 3K token 以上的 prompt 就能让整个 DCP worker 组崩溃(#50781),本 PR 从 workspace 层面根治;同时消除了首个真实 batch 因 JIT 编译不同步导致的 all-gather 挂起,以及 decode 步的 GPU 同步报错。系统影响:启动时 DCP 下会增加 workspace 预分配(与 batch 上限挂钩);PCP+DCP 组合的后端选择从 FlashInfer 稀疏 MLA 自动切换到 FlashMLA 稀疏,注意力计算路径发生变化。团队影响:GLM-5.2 的 TP/PCP/DCP 评测从不可复现的失败变为可复现的通过(TP2/PCP2 0.9121、TP1/PCP4 0.9454、TP1/PCP4/DCP4 0.8832,全部 1319 题、invalid 0),并为后续并行注意力调试沉淀了一套"字节级公式 + exact gate 逐层归因 + monkeypatch 防回归"的方法论。

核心路径变更 workspace 预分配增大显存占用 PCP+DCP 后端自动回退行为变化 依赖 open PR #50791 的移植 GPU 验证仅靠一次性 Buildkite gate 评测配置接近显存上限

关联 Issue

#50791 [Bugfix][Attention] Size FlashInfer sparse MLA workspace for decode-context-parallel

完整报告

参与讨论