Prhub

#46514 [Attention][MLA] FlashMLA sparse: DCP on the fp8_ds_mla mixed-batch path + MTP

原始 PR 作者 drakosha 合并时间 2026-08-19 12:02 文件变更 3 提交数 10 评论 64 代码增减 +295 / -34

执行摘要

FlashMLA sparse 的 fp8 路径接入 DCP 与 MTP 推测解码

PR body 明确指出:fp8_ds_mla 是后端选择器在 Hopper 上为 DSA 模型(GLM-5.2 / DeepSeek-V3.2 在 TP4/DCP4 下)选中的 KV 路径,而该路径此前直接拒绝 DCP。在 decode context parallel 成为长上下文扩展 KV 容量的关键手段后,DCP + fp8_ds_mla 成为 DSA 模型服务化部署的硬需求。评论区 Leoyzen 也强调:“the DCP + fp8_ds_mla path for FLASHMLA_SPARSE is critical for GLM-5.2 on H200”。早期版本(#45426)自带 indexer 机制,但上游 #46076 已合入全局 top-k 合并与 DCP index 过滤,因此本 PR 收敛为纯后端改动,避免重复造轮子。

值得精读。这是 DCP 落地到 sparse MLA 后端的代表作品,重点关注三点设计决策:

  1. (0, -inf) 单位元中和:用一句简洁的数值语义(0 * NaN = NaN)解释了为什么空行必须显式中和,并且用单元测试把行为钉死,这种“用测试固化数值不变量”的做法值得借鉴。
  2. fail-closed 守卫的写法:把 DCP 支持限制在已验证的配置子集内(ag_rs、mixed-batch、head-envelope 一致),宁可拒绝也不静默损坏,是硬件后端特性的稳妥落地方式。
  3. 与 #46076 的分工:indexer 机制上游化后本 PR 主动砍掉自研部分,只保留 backend 侧接线,最终 diff 仅 3 个文件,收敛得非常干净。

建议阅读顺序:flashmla_sparse.py 的 builder 守卫 → _forward_fp8_kv_mixed_batchtest_fp8_mixed_batch_dcp_neutralizes_empty_rows

讨论亮点

review 与 issue 评论区的主要交锋集中在四件事:

  1. 空行中和的开销与必要性:LucasWilkinson 在 _forward_fp8_kv_mixed_batch 的 masked_fill 上质疑“are these strictly necessary? is there a cheaper way to do this?”,drakosha 解释这是正确性必需——kernel 对全 -1 行的输出未定义,0 * NaN = NaN 会让 NaN 存活过合并;并承认 mask 本可从 triton kernel 免费产出,留作 follow-up。
  2. DSpark 草稿模型不支持 DCP:Leoyzen 指出 spec_decode/dflash/speculator.py 的 slot mapping 用 ctx_pos // block_size(DCP-blind),与目标模型的 DCP-aware 映射不一致,导致 DCP>1 时 DSpark 接受率约 0%;drakosha 确认并补了两层修复,但 DSpark 的支持仍不在本 PR 范围内。
  3. 并发解码下的 block table 宽度崩溃:rikki 在 TP8/DCP2/1M 上复现 expanded_block_table_buffer[8, 8192] vs [8, 16384] 不匹配,drakosha 定位为 buffer 用 DCP 除数、而 runner 传入全局宽度,最终拆出独立修复 #48404,并获 rikki 在 8×H200 上验证通过。
  4. 注释清理:LucasWilkinson 要求精简 AI 生成的过度冗长注释,drakosha 在提交 bb340fa59 中只保留非显然不变量。

此外,多轮关于 NVFP4 vs INT4 在 Hopper 上的选择(ashgold 提问,drakosha 与 rikki 回答)以及 CPU offload 与 MTP 的配合(#46971/#46972 的 double num_blocks 问题)也构成了重要讨论背景。

实现拆解

本 PR 的最终形态是 +234/−34 的后端侧改动,分为以下 4 步:

  1. 接入共享 DCP decode 合并机制:在 vllm/v1/attention/backends/mla/flashmla_sparse.py 中给 FlashMLASparseImpl 声明 can_return_lse_for_decode = True,让 _forward_fp8_kv_mixed_batch 在 DCP 需要时返回 decode LSE;kernel 返回的 LSE 布局是 (1, H, T),需要转置为 (T, H) 交给跨 rank reducer。同时修正 _fp8_flash_mla_kernel 在裁剪 head padding 时同步裁剪 LSE,避免 H 维度不匹配。

  2. DCP index 本地化_forward_fp8_kv_mixed_batch 中当 dcp_world_size > 1 时,改用 triton_filter_and_convert_dcp_index 处理 indexer 发出的全局 token id,并保持 compact_valid_to_front=False——散落的 -1 留在原地由 fp8 kernel 原生掩码,从而省掉每次调用的 front-pack。req_id_per_token 按 topk 行数切片,与转换 kernel 的网格对齐。

  3. 空行中和(数值正确性关键):DCP 下某 rank 可能对一行 decode 的 topk 分片一个候选都不持有(全 -1),此时 kernel 输出未定义。实现中把这类行 mask 成 out=0, lse=-inf——这是跨 rank LSE 合并的单位元,能安全从 reduce 中剔除;否则 0 * NaN = NaN 会把 NaN 传播到最终结果。配套 contiguous() 保证输出可喂给 reduce_scatter

  4. fail-closed 守卫与 varlen 支持FlashMLASparseMetadataBuilder.__init__ 在 DCP 配置下依次检查:通信后端必须是 ag_rs、必须是 mixed-batch fp8 路径(separate prefill/decode 路径只对 decode token 返回 LSE)、本地 head 数与 DCP 聚合后的 head 数必须 pad 到同一 fp8 kernel envelope(64/128);同时 supports_dcp_with_varlencp_kv_cache_interleave_size == 1 时开启,因为因果性来自 indexer 的 top-k 索引而非 kernel 元数据——这正是 MTP + DCP 在完整 cudagraph 下工作的前提。

  5. dense FlashMLA 的 LSE 展平与测试配套vllm/v1/attention/backends/mla/flashmla.pyforward_mqaneed_to_return_lse_for_decode 时把 [batch, heads, seq_len] 的 LSE 展平成 [tokens, heads](spec decode 下 seq_len > 1 时必须);测试文件 tests/v1/attention/test_sparse_mla_backends.py 新增 _build_sparse_dcp_vllm_config(mock 模型配置模拟 TP/dcp)、test_fp8_dcp_head_envelope_guard(参数化接受/拒绝用例)与 test_fp8_mixed_batch_dcp_neutralizes_empty_rows(用 monkeypatch 的 kernel 钉死空行中和行为)。

文件 模块 状态 重要度
vllm/v1/attention/backends/mla/flashmla_sparse.py MLA 后端 modified 7.6
vllm/v1/attention/backends/mla/flashmla.py MLA 后端 modified 5.57
tests/v1/attention/test_sparse_mla_backends.py 单元测试 modified 7.07

关键符号

_forward_fp8_kv_mixed_batch FlashMLASparseMetadataBuilder.__init__ forward_mqa can_return_lse_for_decode test_fp8_dcp_head_envelope_guard test_fp8_mixed_batch_dcp_neutralizes_empty_rows

关键源码片段

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

本 PR 的核心改动,DCP 接线、fp8 LSE 返回与空行中和全部在此。FlashMLASparseMetadataBuilder 增加 DCP 守卫与 head-envelope 检查,FlashMLASparseImpl 声明 can_return_lse_for_decode 并改造 _forward_fp8_kv_mixed_batch。

def _forward_fp8_kv_mixed_batch(
    self,
    q: torch.Tensor,
    kv_c_and_k_pe_cache: torch.Tensor,
    topk_indices: torch.Tensor,
    attn_metadata: FlashMLASparseMetadata,
) -> tuple[torch.Tensor, torch.Tensor | None]:
    """FP8 混合批处理前向:prefill 与 decode 同批走 FP8 decode kernel。    相比 BF16 prefill kernel,这种模式避免小 head 数下的头填充开销;
    仅在 DCP 需要时返回 LSE,否则返回 None。
    """
    if self.dcp_world_size > 1:
        # DCP 下 indexer 产生的是全局 token id,先保留本 rank 分片并转成
        # 本地 fp8_ds_mla 缓存槽位。
        # compact_valid_to_front=False 让散落的 -1 留在原地:fp8 kernel
        # 原生掩码它们,也便于后续空行中和逻辑复用同一份索引。
        topk_indices = triton_filter_and_convert_dcp_index(
            attn_metadata.req_id_per_token[: topk_indices.shape[0]],
            attn_metadata.block_table,
            topk_indices,
            dcp_size=self.dcp_world_size,
            dcp_rank=self.dcp_rank,
            cp_kv_cache_interleave_size=attn_metadata.cp_kv_cache_interleave_size,
            BLOCK_SIZE=attn_metadata.block_size,
            NUM_TOPK_TOKENS=topk_indices.shape[1],
            compact_valid_to_front=False,
        )
    else:
        # 单 rank(非 DCP):把每请求索引转换为全局缓存槽位即可。
        topk_indices = triton_convert_req_index_to_global_index(
            attn_metadata.req_id_per_token[: topk_indices.shape[0]],
            attn_metadata.block_table,
            topk_indices,
            BLOCK_SIZE=attn_metadata.block_size,
            NUM_TOPK_TOKENS=topk_indices.shape[1],
        )
​
    assert attn_metadata.fp8_extra_metadata is not None
    assert isinstance(
        attn_metadata.fp8_extra_metadata,
        FlashMLASparseMetadata.FP8KernelMetadata,
    )
    fp8_metadata = attn_metadata.fp8_extra_metadata
​
    # (T, H, D) -> (1, T, H, D),(T, topk) -> (1, T, topk)
    _attn_out, _lse = self._fp8_flash_mla_kernel(
        q=q.unsqueeze(0),
        kv_c_and_k_pe_cache=kv_c_and_k_pe_cache,
        topk_indices=topk_indices.unsqueeze(0),
        kernel_metadata=fp8_metadata,
    )
    out = _attn_out.squeeze(0)
​
    if not self.need_to_return_lse_for_decode:
        return out, None
​
    # kernel 返回的 LSE 布局是 (1, H, T),跨 rank 合并器消费 (T, H)。
    lse = _lse.squeeze(0).transpose(0, 1)
    # 本 rank 未持有任何被选 token 的行(topk 全为 -1)kernel 输出未定义;
    # (0, -inf) 是跨 rank LSE 合并的单位元,能把该行从 reduce 中剔除。
    # 若不中和,NaN 会以 0 * NaN = NaN 的形式存活到合并结果里。
    empty_rows = (topk_indices == -1).all(dim=-1)
    out.masked_fill_(empty_rows.view(-1, 1, 1), 0.0)
    lse.masked_fill_(empty_rows.view(-1, 1), float("-inf"))
    # 头填充裁剪可能使 out 非连续,而合并器会把它喂给 reduce_scatter。
    return out.contiguous(), lse
vllm/v1/attention/backends/mla/flashmla.py core-logic

dense FlashMLA decode 路径在 DCP 需要时把 LSE 从 [batch, heads, seq_len] 展平成 [tokens, heads],spec decode 下 seq_len > 1 时是 DCP reducer 正确消费的关键配套。

o = reshape_attn_output_for_spec_decode(o)if self.need_to_return_lse_for_decode:
    # FlashMLA 返回的 LSE 布局是 [batch, heads, seq_len];DCP 的跨 rank
    # 合并器消费的是 [tokens, heads]。仅在 spec decode 下 seq_len > 1,
    # 所以这个展平在 MTP 场景是必须的;同时又只为 DCP 消费,普通路径
    # 跳过这次拷贝。
    num_decodes, q_num_heads, seq_len = lse.shape
    lse = (
        lse.permute(0, 2, 1)
        .reshape(num_decodes * seq_len, q_num_heads)
        .contiguous()
    )return o, lse
tests/v1/attention/test_sparse_mla_backends.py test-coverage

新增两个高价值测试:head-envelope guard 参数化验证接受 / 拒绝配置,空行中和测试用 monkeypatch 的假 kernel 钉死 (0, -inf) 单位元行为,防止 NaN 回归。

def test_fp8_mixed_batch_dcp_neutralizes_empty_rows(monkeypatch):
    """DCP 下某 decode 行的 topk 分片若不含本地候选(全 -1),
    kernel 输出未定义;必须中和为 (0, -inf)——这正是跨 rank LSE 合并
    的单位元,否则 0 * NaN = NaN 会让 NaN 存活到合并结果里。
    """
    num_tokens, num_heads, head_dim = 3, 2, 3
    q = torch.empty(num_tokens, num_heads, head_dim, device=DEVICE_TYPE)
    # token 0、2 有本地候选;token 1 完全不属于本 rank
    local_indices = torch.tensor(
        [[0, 1, -1, -1], [-1, -1, -1, -1], [2, -1, 3, -1]],
        dtype=torch.int32,
        device=DEVICE_TYPE,
    )
​
    monkeypatch.setattr(
        "vllm.v1.attention.backends.mla.flashmla_sparse."
        "triton_filter_and_convert_dcp_index",
        lambda *args, **kwargs: local_indices,
    )
​
    def run_kernel(**kwargs):
        # 模拟 kernel:未中和行输出 NaN,有本地候选的行返回真实值
        out = torch.full(
            (1, num_tokens, num_heads, 1), float("nan"), device=DEVICE_TYPE
        )
        lse = torch.full((1, num_heads, num_tokens), float("nan"), device=DEVICE_TYPE)
        for token_id in (0, 2):
            out[0, token_id] = float(token_id + 1)
            lse[0, :, token_id] = float(token_id + 1)
        return out, lse
​
    metadata = SimpleNamespace(
        fp8_extra_metadata=FlashMLASparseMetadata.FP8KernelMetadata(
            scheduler_metadata=object(),
            dummy_block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
            cache_lens=torch.empty(1, dtype=torch.int32, device=DEVICE_TYPE),
        ),
        req_id_per_token=torch.empty(num_tokens, dtype=torch.int32, device=DEVICE_TYPE),
        block_table=torch.empty(1, 1, dtype=torch.int32, device=DEVICE_TYPE),
        block_size=64,
        cp_kv_cache_interleave_size=1,
    )
    impl = SimpleNamespace(
        dcp_world_size=2,
        dcp_rank=0,
        need_to_return_lse_for_decode=True,
        _fp8_flash_mla_kernel=run_kernel,
    )
​
    out, lse = FlashMLASparseImpl._forward_fp8_kv_mixed_batch(
        impl, q, torch.empty(0, device=DEVICE_TYPE), local_indices, metadata
    )
​
    # 空行 token 1:out 全 0、lse 全 -inf,成为合并单位元
    assert torch.equal(out[1], torch.zeros_like(out[1]))
    assert torch.isneginf(lse[1]).all()
    # 非空行保持真实值,且最终结果不含任何 NaN
    for token_id in (0, 2):
        assert torch.equal(out[token_id], torch.full_like(out[token_id], token_id + 1))
        assert torch.equal(lse[token_id], torch.full_like(lse[token_id], token_id + 1))
    assert out.is_contiguous()
    assert not out.isnan().any()
    assert not lse.isnan().any()

评论区精华

空行 (0, -inf) 中和的开销与必要性 正确性

LucasWilkinson 在 _forward_fp8_kv_mixed_batch 的 masked_fill 处提问:“are these strictly necessary? is there a cheaper way to do this?”,drakosha 解释 DCP 下某 rank 可能对一行 topk 完全无候选,kernel 输出未定义,NaN 会在 0 * NaN = NaN 下存活过合并,必须中和为合并单位元。

结论:masked_fill 保留;drakosha 补充了单元测试钉死行为,并承认 mask 可从 triton_filter_and_convert_dcp_index 免费产出,留作 follow-up。 · 已解决

expanded_block_table_buffer 宽度在 DCP 下崩溃 正确性

rikki 在 TP8/DCP2/1M 上复现 indexer.py:482 的 [8, 8192] vs [8, 16384] 不匹配,drakosha 定位为 buffer 预分配在 DCP 除数下、而 runner 传入全局宽度的独立 bug,与 variable decode 长度无关。

结论:拆出独立 PR #48404,用观察到的 block table 宽度重分配 buffer;rikki 在 8×H200 上验证通过并成为 Tested-by。 · 已解决

DSpark 草稿模型在 DCP 下 slot mapping 失效 question

Leoyzen 指出 spec_decode/dflash/speculator.py 用 ctx_pos // block_size(DCP-blind)计算 slot,而目标模型用 positions // (block_size * CP_SIZE),导致 DCP>1 时接受率约 0%;drakosha 确认并补了两层修复,但 DSpark 支持未进入本 PR。

结论:本 PR 范围外;文档只声明 MTP + DCP,DSpark 待 #47926 等成熟后再议。 · unresolved

AI 注释过多与代码风格 style

LucasWilkinson 要求清理注释:“AI usually makes too many comments and too verbose; ideally we'd like to minimize this comments where it makes sense to avoid too much pollution”,并建议把 CuteDSL indexer 文件移到 dcp_indexer_cutedsl.py。

结论:drakosha 在 bb340fa59 中精简注释,只保留非显然不变量;文件位置随 #46076 合并已自然处理。 · 已解决

NVFP4 vs INT4 在 Hopper 上的选型 question

ashgold 询问 NVFP4 相对 INT4 在 Hopper 的吞吐优势;drakosha 与 rikki 均说明 SM90 无原生 FP4 tensor core,NVFP4 在 Hopper 上只能省带宽不能省算力,选它的真实理由是官方 checkpoint 带 MTP 层以及校准可信度。

结论:未做同机 A/B,但社区用户确认 NVFP4 用于长上下文(1M)时相对 FP8 有带宽优势,与 INT4 无明确吞吐差异。 · 已解决

风险与影响

主要风险集中在:

  1. 核心 decode 路径变更_forward_fp8_kv_mixed_batch 返回值从 Tensor 改为 tuple[Tensor, Tensor | None],所有调用点都必须解包;改动只覆盖了 sparse 路径内部,若外部使用者直接调用该私有方法会产生破坏性影响。
  2. 依赖外部修复:fp8_ds_mla 在 main 上的启动依赖 #48379(KV reshape 回归);DCP 下并发解码的 block table 宽度依赖 #48404 与 #50823;CPU offload 下依赖尚未合入的 #50883(open)。缺任一修复,DCP + fp8_ds_mla 的组合都可能崩溃或静默降级。
  3. 适用域受限:DCP 仅支持 ag_rs 通信后端、仅支持 mixed-batch fp8 路径;bf16 sparse 路径被显式拒绝。head-envelope guard 会拒绝 TP8/DCP8 下 128 q-heads 等配置,用户遇到 NotImplementedError 时需要自行判断是否换配置。
  4. 性能开销:空行中和引入每 token 一次 topk_indices == -1 扫描与 masked_fill_,加一次 contiguous() 拷贝;DCP=4 相对 DCP=2 在纯 decode 短输出场景存在 -1.6% ~ -10.7% 的吞吐回退(rikki A/B 实测),说明 DCP 不是普适最优。

用户侧:GLM-5.2 / DeepSeek-V3.2 用户首次能在 Hopper 上以 fp8_ds_mla + DCP + MTP 部署长上下文服务,4×H200 上实现 786k 上下文、198k needle 精确检索、DCP=1/2/4 字节级一致;生产验证方包括 ashgold(H100×8 TP8/DCP8 + AWQ)、rikki(8×H200 TP8/DCP2 + 1M 上下文 + ~40 Claude Code 用户)、Leoyzen(4×H200 TP4/DCP4 + 1M + CPU offload),以及 drakosha 本人在 GLM-5.2-NVFP4 上的持续生产运行。
系统侧:DCP 直接翻倍/四倍 KV 容量(rikki 实测 2.27M tokens at DCP=2),配合 CPU offload(native OffloadingConnector)可支撑超长上下文 batch 服务;但 DCP 维度选择变成 workload-dependent,需要用户在 KV 容量与吞吐间权衡。
团队侧:PR 经历了从自研 indexer 到复用上游机制的大幅收敛(10 个 commit、多次 rebase 与 merge 冲突解决),跨 PR 协作模式清晰,drakosha 承担主要开发与验证,Claude 辅助并全程人工复核。

核心 decode 路径变更 外部修复依赖未合入 适用域受限(ag_rs/fp8 only) DSpark 暂不支持 DCP

关联 Issue

#48379 [Bugfix] Set kv_quant_mode on the generic MLA KV-cache spec
#50823 [Bugfix] Shard UniformTypeKVCacheSpecs block table width under DCP
#50883 [Bugfix][KV Offload] Scale UniformTypeKVCacheSpecs groups by DCP

完整报告

参与讨论