Prhub

#39487 [Fix][DCP] Localize widened KV ids in MLA retraction CPU backup/restore

原始 PR 作者 kpham-sgl 合并时间 2026-09-15 13:33 文件变更 6 提交数 9 评论 2 代码增减 +106 / -37

执行摘要

修复 DCP 下 MLA 重删 CPU 备份 / 恢复的 KV 索引越界崩溃

Issue#38645 记录了线上的具体崩溃:在 PD-disaggregation decode 节点、DCP8、Kimi-K3(MLA + KDA 混合)配置下,decode 容量压力触发 retract_decode 后,调度器在 Req.offload_kv_cache -> TokenToKVPoolAllocator.get_cpu_copy -> MLATokenToKVPool.get_cpu_copy 的 kv_buffer[layer_id][chunk_indices] 处抛 CUDA device-side assert。Issue 明确给出的根因是索引空间不一致:「req_to_token stores DCP-widened logical KV indices」而「per-rank MLA kv_buffer stores only rank-local physical rows」,正常 MLA 读写路径都会先做 owner 过滤与 // dcp_size 折叠,只有重删 CPU 备份/恢复路径漏掉了这一步;Issue 还提示即使 id 仍在范围内,也可能「address the wrong physical row and silently copy/restore incorrect KV data」。PR body 补充:本 PR 同时是 Draft 验证 PR#38790 的正式落地版本。

值得精读,尤其对维护 DCP / PD 分离 / HiCache / KV 内存池的同学。重点关注三处设计决策:① 把索引换算从 host pool 方法提升为 layers/dcp/layout.py 纯函数的动机与收益;② _write_loc_dcp_span + write_loc_is_dcp_resolved 这对契约如何避免统一池上的双重换算;③ 为什么可以安全删除「整加宽页」断言——理解 seqlen - 1 的 ragged 前缀与「起始页对齐」这个更弱前提的差别。若只关心结论,可记住两点:崩溃触发条件是 DCP>1 时用加宽 id 直接索引 rank 局部 KV buffer;修复方式是 indices[dcp_rank::dcp_size] // dcp_size。合并后建议跟进两件事:为 maybe_dcp_kernel_indices 补一个起始对齐的显式校验或更强的测试守护,以及排查作者提到的「恢复请求重复 token」独立缺陷。

讨论亮点

本 PR 的评审讨论非常轻量:review_comments_count = 0,唯一一条 review 来自 ShangmingCai 的 APPROVED ——「This minor fix looks good to me.」。Issue 线程中的两条评论都是流程性的:作者用 /rerun-test 指定 5 个测试文件,github-actions[bot] 回报 ubuntu-latest、1-gpu-5090、4-gpu-b200、8-gpu-b200 四个 runner 上的用例全部 ✅。真正有价值的「讨论」发生在 PR body 与 commit message 里:作者主动说明了断言被删的原因(重删前缀是 seqlen - 1 的 ragged run)、_write_loc_dcp_span 与 write_loc_is_dcp_resolved 的契约、以及一个刻意不修的问题——强制重删下约 4/48 的恢复请求会退化成 token 重复,且 --dcp-size 1 同样复现,因此判定为独立问题而非 DCP 缺陷。

实现拆解

  1. 抽取统一的 DCP 索引换算入口(layers/dcp/layout.py):把原 HostKVCache.maybe_dcp_kernel_indices 方法(位于 mem_cache/pool_host/base.py)改写成模块级纯函数 maybe_dcp_kernel_indices(indices, dcp_size, dcp_rank),与 get_dcp_lens、filter_dcp_local_kv_indices、filter_dcp_local_chunk_kv_indices 等既有 DCP 索引数学并列,dcp_size == 1 时恒等返回。这样做的原因有两个:一是新调用方 mem_cache/memory_pool.py 不属于 HiCache host pool 体系,方法式 API 无法复用;二是纯函数把依赖显式化为参数,避免全局状态耦合。

  2. 放宽页对齐断言:原实现带 assert indices.numel() % self.dcp_size == 0(要求整加宽页),但重删前缀只有 seqlen - 1 个 token、本身是 ragged 的,断言会误杀;新函数只依赖「run 起始页对齐」这一更弱前提,用 indices[dcp_rank::dcp_size] // dcp_size 的步长视图选择本 rank 槽位并折叠为物理行。相应地 pool_host/mla.py 的 load_to_device_per_layer 与 backup_from_device_all_layer 改为传入 self.dcp_size、self.dcp_rank。

  3. 修复 PD 重删备份/恢复主路径(mem_cache/memory_pool.py):MLATokenToKVPool.get_cpu_copy 与 load_cpu_copy 在分块拷贝前各做一次 maybe_dcp_kernel_indices(indices, self._write_loc_dcp_span, get_parallel().attn_dcp_rank)。这里用 _write_loc_dcp_span 而不是裸 dcp_size,是为了沿用池上已有的 write_loc_is_dcp_resolved 契约:统一池里 req_to_token 存的已是物理行,此时 span 为 1、函数透传,天然规避二次换算;备份与恢复各自独立换算,因为恢复阶段 req_to_token 里已是重新分配后的新加宽 id,不能复用备份时的物理行。KDA/Mamba 的 mamba_indices 明确不做转换(其索引空间独立)。

  4. 测试配套:test/registered/unit/mem_cache/test_decode_retraction_backup.py 新增 TestDcpRetractionBackup,用 _bare_mla_pool() 构造只含 kv_buffer 的裸 MLA 池、用 get_parallel().override(...) 模拟 DCP=4/rank=1,覆盖 test_restore_lands_on_new_owned_rows(备份+恢复往返落到新的本 rank 行,未涉及行不变)与 test_resolved_pool_takes_ids_as_rows(write_loc_is_dcp_resolved=True 时 id 原样透传,防双重换算);test/registered/unit/mem_cache/test_hicache_dcp_host_pool.py 改为直接调用纯函数,删除 _bare_pool 与随断言一起失效的 test_ragged_run_is_rejected。

  5. 演进与验证:9 个 commit 几乎全是作者自迭代——先把布尔掩码换成步长视图(1fe7d91),再尝试在两个 cpu-copy 调用点内联(6397112、747510c),随后改为共享 helper(dec6560、3d8bd25),最后把测试用例从 test_retraction_mamba_backup 迁到 test_decode_retraction_backup(60efcfe、40dba53)。CI 侧 /rerun-test 覆盖 test_hicache_dcp_host_pool.py、test_decode_retraction_backup.py、test_unified_radix_cache_kl_dcp.py、test_kimi_linear_pd_dcp4.py、test_kimi_linear_pd_dcp4_dspark.py,五个目标全部通过。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/memory_pool.py 内存池 modified 6.32
python/sglang/srt/layers/dcp/layout.py 索引换算 modified 6.79
test/registered/unit/mem_cache/test_decode_retraction_backup.py 重删测试 modified 6.96
python/sglang/srt/mem_cache/pool_host/base.py 宿主缓存 modified 6.22
python/sglang/srt/mem_cache/pool_host/mla.py 宿主缓存 modified 5.92
test/registered/unit/mem_cache/test_hicache_dcp_host_pool.py 缓存测试 modified 5.58

关键符号

maybe_dcp_kernel_indices MLATokenToKVPool.get_cpu_copy MLATokenToKVPool.load_cpu_copy MLATokenToKVPoolHost.load_to_device_per_layer MLATokenToKVPoolHost.backup_from_device_all_layer TestDcpRetractionBackup.test_restore_lands_on_new_owned_rows TestDcpRetractionBackup.test_resolved_pool_takes_ids_as_rows

关键源码片段

python/sglang/srt/mem_cache/memory_pool.py core-logic

修复落点:MLATokenToKVPool.get_cpu_copy / load_cpu_copy 现在在索引物理 kv_buffer 前先做 DCP 本地化,并靠 _write_loc_dcp_span 兼容统一池的已物理化 id。

# python/sglang/srt/mem_cache/memory_pool.py :: MLATokenToKVPool
​
def get_cpu_copy(self, indices, mamba_indices=None, req_pool_index=None):
    # 重删备份:req_to_token 给的是 DCP 加宽逻辑 id,kv_buffer 只存本 rank 的物理行。
    # _write_loc_dcp_span 在统一池已物理化时为 1(write_loc_is_dcp_resolved),
    # 此时 maybe_dcp_kernel_indices 原样透传,天然避免二次换算。
    indices = maybe_dcp_kernel_indices(
        indices, self._write_loc_dcp_span, get_parallel().attn_dcp_rank
    )
    current_platform.synchronize()
    kv_cache_cpu = []
    chunk_size = self.cpu_offloading_chunk_size
    for layer_id in range(self.layer_num):
        kv_cache_cpu.append([])
        if self.kv_buffer[layer_id].shape[0] == 0:
            continue
        # 分块拷贝,chunk_indices 这里已经是本 rank 的物理行号
        for i in range(0, len(indices), chunk_size):
            chunk_indices = indices[i : i + chunk_size]
            kv_cpu = self.kv_buffer[layer_id][chunk_indices].to(
                "cpu", non_blocking=True
            )
            kv_cache_cpu[-1].append(kv_cpu)
    current_platform.synchronize()
    return kv_cache_cpu
​
​
def load_cpu_copy(
    self, kv_cache_cpu, indices, mamba_indices=None, req_pool_index=None
):
    # 恢复同样独立换算:重删后重新分配的是「新的加宽 id」,
    # 不能复用备份时算出的物理行号。KDA/Mamba 状态的 mamba_indices 不做转换。
    indices = maybe_dcp_kernel_indices(
        indices, self._write_loc_dcp_span, get_parallel().attn_dcp_rank
    )
    current_platform.synchronize()
    chunk_size = self.cpu_offloading_chunk_size
    for layer_id in range(self.layer_num):
        if self.kv_buffer[layer_id].shape[0] == 0:
            continue
        for i in range(0, len(indices), chunk_size):
            chunk_indices = indices[i : i + chunk_size]
            kv_cpu = kv_cache_cpu[layer_id][i // chunk_size]
            # 换算对称性的显式检查:备份与恢复两侧必须得到同样多的行
            assert kv_cpu.shape[0] == len(chunk_indices)
            kv_chunk = kv_cpu.to(self.kv_buffer[0].device, non_blocking=True)
            self.kv_buffer[layer_id][chunk_indices] = kv_chunk
    current_platform.synchronize()
python/sglang/srt/layers/dcp/layout.py core-logic

新增共享纯函数 maybe_dcp_kernel_indices,把「加宽逻辑槽位 -> 本 rank 物理行」的换算从 HiCache host pool 提升为 DCP 索引数学模块的公共能力。

# python/sglang/srt/layers/dcp/layout.py
# 该模块原本只放 DCP 的纯索引数学(get_dcp_lens、filter_dcp_local_kv_indices 等)。
# 本 PR 把原本是 HostKVCache 方法的换算提升到这里,
# 让 HiCache host pool 与 MLA 池的重删 CPU 备份 / 恢复共用同一份语义。
​
def maybe_dcp_kernel_indices(
    indices: torch.Tensor, dcp_size: int, dcp_rank: int
) -> torch.Tensor:
    """Widened logical slots -> this rank's physical rows.
​
    Owner rule: slot % dcp_size == dcp_rank, row = slot // dcp_size. The run
    starts page-aligned, so a strided view selects the owned slots without a mask.
    """
    if dcp_size == 1:
        return indices # DCP=1 是恒等情形,直接返回原张量避免多余拷贝
    # 关键取舍:去掉原实现的「必须整加宽页」断言(重删前缀是 seqlen - 1,天然 ragged),
    # 只依赖更弱的前提「run 起始页对齐」,用步长视图代替布尔掩码:
    # 选中 indices[dcp_rank]、indices[dcp_rank + dcp_size]、...,再整体 // dcp_size
    return indices[dcp_rank::dcp_size] // dcp_size
​
​
# 同模块中的对照实现:读取侧只做选择、仍保持加宽 id,由调用方再折叠
# def filter_dcp_local_kv_indices(kv_indices):
# parallel = get_parallel()
# if parallel.dcp_enabled:
# kv_indices = kv_indices[kv_indices % parallel.dcp_size == parallel.dcp_rank]
# return kv_indices
test/registered/unit/mem_cache/test_decode_retraction_backup.py test-coverage

新增 TestDcpRetractionBackup,用裸 MLA 池 + DCP override 复现「加宽 id 当行号」的两个方向:往返落到新行、以及已物理化池的透传。

# test/registered/unit/mem_cache/test_decode_retraction_backup.py
DCP_SIZE = 4
DCP_RANK = 1
DCP_ROWS = 8
​
​
def _bare_mla_pool() -> MLATokenToKVPool:
    # 绕过构造函数:只挂上 kv_buffer / layer_num / 分块大小,
    # 每行以 arange + 100 * layer 填充,方便断言「KV 被搬到了哪一行」。
    pool = object.__new__(MLATokenToKVPool)
    pool.layer_num = 2
    pool.cpu_offloading_chunk_size = 3
    pool.kv_buffer = [
        (torch.arange(DCP_ROWS, dtype=torch.float32) + 100 * layer).view(DCP_ROWS, 1, 1)
        for layer in range(pool.layer_num)
    ]
    return pool
​
​
def _dcp():
    # 通过 runtime_context 覆盖并行配置,模拟 DCP=4 且本 rank 为 1
    return get_parallel().override(
        dcp_enabled=True, attn_dcp_size=DCP_SIZE, attn_dcp_rank=DCP_RANK
    )
​
​
class TestDcpRetractionBackup(unittest.TestCase):
    """`req_to_token` names KV slots in the widened DCP id space while
    `kv_buffer` holds only this rank's rows; a widened id used as a row index
    reads past the buffer or copies another token's row."""
​
    def test_restore_lands_on_new_owned_rows(self):
        pool = _bare_mla_pool()
        before = [buf.clone() for buf in pool.kv_buffer]
        old_widened = torch.arange(0, 12, dtype=torch.int64) # 备份来源的加宽 id
        new_widened = torch.arange(12, 24, dtype=torch.int64) # 重删后新分配的加宽 id
​
        with _dcp():
            # 备份与恢复各自独立换算:旧 id 读出本 rank 行,写回新 id 对应的本 rank 行
            pool.load_cpu_copy(pool.get_cpu_copy(old_widened), new_widened)
​
        old_rows = old_widened[DCP_RANK::DCP_SIZE] // DCP_SIZE
        new_rows = new_widened[DCP_RANK::DCP_SIZE] // DCP_SIZE
        self.assertEqual(new_rows.tolist(), [3, 4, 5])
        untouched = torch.tensor(
            [r for r in range(DCP_ROWS) if r not in new_rows.tolist()]
        )
        for layer in range(pool.layer_num):
            torch.testing.assert_close(
                pool.kv_buffer[layer][new_rows], before[layer][old_rows]
            ) # 数据确实搬到了新的本 rank 行
            torch.testing.assert_close(
                pool.kv_buffer[layer][untouched], before[layer][untouched]
            ) # 未涉及的行保持原样,排除误写
​
    def test_resolved_pool_takes_ids_as_rows(self):
        pool = _bare_mla_pool()
        pool.write_loc_is_dcp_resolved = True # 统一池:req_to_token 中已是物理行
        rows = torch.arange(DCP_ROWS, dtype=torch.int64)
​
        with _dcp():
            kv_cpu = pool.get_cpu_copy(rows)
​
        for layer in range(pool.layer_num):
            # 双重换算防护:DCP 打开时也要原样透传
            torch.testing.assert_close(torch.cat(kv_cpu[layer]), pool.kv_buffer[layer])

评论区精华

索引换算逻辑该放在哪一层 设计

原始实现是 HostKVCache 上的方法(mem_cache/pool_host/base.py),但新调用方 MLATokenToKVPool 不在 HiCache host pool 体系内。commit 历史显示作者先尝试在两个 cpu-copy 调用点内联(6397112、747510c),随后改为共享 helper(dec6560),最终整体搬进 layers/dcp/layout.py(3d8bd25)并泛化 docstring(71cebe4)。

结论:确定为 layers/dcp/layout.py 的模块级纯函数 maybe_dcp_kernel_indices(indices, dcp_size, dcp_rank),dcp_size/rank 显式传参,HiCache host pool 与 MLA 池共用。 · 已解决

删除「整加宽页」断言的正确性边界 正确性

原方法带 assert indices.numel() % dcp_size == 0,PR 说明重删前缀是 seqlen - 1 个 token、本身就是 ragged,断言会误杀;新函数只依赖「run 起始页对齐」这一更弱前提,用 indices[dcp_rank::dcp_size] 步长视图代替布尔掩码。test_hicache_dcp_host_pool.py 中守护该断言的 test_ragged_run_is_rejected 一并被删除。

结论:断言被接受移除,换取重删路径可用;代价是函数对「起始对齐」的假设从强制校验退化为文档契约,未来误用会静默选错槽位。 · resolved-with-residual-risk

统一池上的双重换算防护 设计

get_cpu_copy/load_cpu_copy 使用 self._write_loc_dcp_span 而非裸 dcp_size:统一池已把 id 物理化(write_loc_is_dcp_resolved)时 span 为 1,函数透传。为固化该契约,新增 test_resolved_pool_takes_ids_as_rows 断言 DCP 开启时 id 仍原样作为行使用。

结论:以池属性作为开关,单一换算点同时服务「加宽 id」与「已物理化 id」两种池,避免二次折叠。 · 已解决

DCP 回归用例的归属测试文件 测试

commit 60efcfe 先把 DCP 用例折进 test_retraction_mamba_backup,紧接 40dba53 又迁到 test_decode_retraction_backup,说明作者在按「与重删备份链路最近」的原则寻找落点。

结论:最终落在 test/registered/unit/mem_cache/test_decode_retraction_backup.py 的 TestDcpRetractionBackup,与既有的 MHA/draft 备份用例共用同一裸池构造风格。 · 已解决

评审结论与验证强度 other

ShangmingCai 给出唯一评审意见「This minor fix looks good to me.」,无行内评论;作者用 /rerun-test 拉起 5 个目标测试(hicache_dcp_host_pool、decode_retraction_backup、unified_radix_cache_kl_dcp、kimi_linear_pd_dcp4、kimi_linear_pd_dcp4_dspark),bot 回报四个 runner 全部通过。

结论:代码评审轻量通过;深度验证证据主要来自 PR body 中的 4xB200 实测(main 约 12 次重删即 assert,本分支 140-200 次重删无 assert)。 · informational

风险与影响

  1. 静默错误行风险(最高):把加宽 id 当行号使用时,只要 id 落在此 rank 的行范围内就不会崩溃,而是复制/写回错误 token 的 KV(Issue#38645 原文明确警告)。本 PR 修掉了该路径,但此类错误缺乏端到端可观测性,主要靠 test_restore_lands_on_new_owned_rows 这类单测与 Kimi-Linear-48B 的 logprob 对比守护。
  2. 页对齐断言移除的边界:新函数删掉了 indices.numel() % dcp_size == 0 的 fail-fast 断言,改为口头契约「run starts page-aligned」。若未来有调用方传入起始未对齐的 run(例如带 offset 的 chunk 视图),步长视图会静默选错槽位而不报错;test_hicache_dcp_host_pool.py 中原本守护这一点的 test_ragged_run_is_rejected 也被删除,回归网出现缺口。
  3. 对池契约的隐式依赖:get_cpu_copy / load_cpu_copy 的正确性依赖 _write_loc_dcp_span(配合 write_loc_is_dcp_resolved)与实际索引空间一致。MLATokenToKVPool 的子类(如 MLATokenToKVPoolFP4)若未正确维护该属性,会出现误换算或属性缺失;get_parallel().attn_dcp_rank 取的是全局并行上下文而非池自身 rank,两者不一致时会算错行。
  4. 未覆盖范围:Mamba/KDA 的 mamba_indices 不做转换,load_cpu_copy 中「恢复后重复 token 退化」问题被明确留作后续;assert kv_cpu.shape[0] == len(chunk_indices) 是换算对称性的唯一显式检查,一旦备份/恢复两侧 span 判断不同会在此处断言失败而非静默错位。
  5. CI 信号混杂:PR body 中的 CI 状态槽位显示 PR Test (Base)/(Extra)/(AMD ROCm 10) 三个 job 为 ❌,而针对性 rerun 的 5 个测试全绿,合并前需要确认失败项与本次变更无关。

用户侧:PD 分离 + DCP > 1 + MLA(Kimi 系列)的 decode 节点在容量压力触发重删时不再崩溃,请求可继续完成;作者在 4xB200 上实测 main 分支约 12 次重删即 assert,本分支 140–200 次重删无 assert,per-token logprob 与不重删参考值在噪声范围内。系统侧:layers/dcp/layout.py 成为 DCP 索引换算的公共入口,HiCache host pool 与 KV 池的语义一致性问题被收敛到单点;memory_pool.py 处于热路径上游,但只在备份/恢复时执行,对正常 decode 吞吐无影响(转发路径不受影响)。团队侧:这是「加宽逻辑索引 vs rank 物理行」这一 DCP 核心约定的一次契约补齐,后续任何新增访问 kv_buffer 的旁路路径都应先过 maybe_dcp_kernel_indices。测试层面新增的 TestDcpRetractionBackup 提供了可复用的裸池构造方法(_bare_mla_pool + _dcp),便于继续补 DCP 场景用例。

静默错行风险 页对齐断言移除 双重换算依赖池契约 KDA/Mamba 状态未覆盖 已知 token 重复退化未修 PR 主 CI 槽位显示失败

关联 Issue

#38645 [Bug][DCP][PD Disagg] Decode retraction crashes in get_cpu_copy with CUDA device-side assert
#38790 [BugFix][DCP] Localize MLA KV indices for retraction CPU backup/restore

完整报告

参与讨论