Prhub

#34889 [DCP]Localize HiCache DCP indices once per transfer, not per layer

原始 PR 作者 kpham-sgl 合并时间 2026-08-17 12:54 文件变更 3 提交数 10 评论 6 代码增减 +14 / -17

执行摘要

HiCache DCP 索引翻译改为无同步切片,加载循环提速约 8 倍

PR body 明确指出:load_to_device_per_layer() 的翻译结果与层无关(layer-independent),但 load 循环每层调用一次;布尔掩码索引 indices[indices % dcp_size == dcp_rank]nonzero 需要把选中数量读回主机以确定输出大小,这是一次 device→host 同步,且运行在 with device_module.stream(self.load_stream) 内,导致每一层的翻译都阻塞在上一层传输 kernel 之后,整个循环串行化。而 start_loading() 运行在 scheduler 线程(get_new_batch_prefillready_to_load_host_cache),这个停滞正好挡在 forward launch 前面,直接影响解码吞吐。

值得精读。这个 PR 展示了三个有价值的点:(1) 用步长切片替代布尔掩码索引,从根本上消除 nonzero 引入的 device→host 同步——这是 PyTorch 中容易被忽略的隐式同步陷阱,对任何调度器/流内 kernel 逻辑都有普适启示;(2) 对“翻译结果与层无关却被每层重复计算”的浪费做了清晰的代价论证(同步 + 串行化 + 调度线程停滞),并给出量化基准验证;(3) review 中混合注意力路径(HybridCacheController / HostPoolGroup)的边界案例推动了方案从“搬移调用点”收敛为“让函数本身零成本”,最终以 3 个文件、14 行新增的最小改动落地,是一个被讨论打磨到极致的性能修复。建议结合 commit 历史(10 个 commit 的收敛过程)阅读。

讨论亮点

核心讨论有两条:

  • 混合注意力路径的正确性担忧(tanth47 提出,已解决):tanth47 指出 HostPoolGroup -> HybridCacheController 的缓存路径(Kimi-K3 即此结构)中,HybridCacheController.start_loading() 仍原样转发加宽的逻辑锚点索引,而中间方案从 MLATokenToKVPoolHost.load_to_device_per_layer() 移除翻译,会破坏这类模型的锚点索引。建议在混合控制器或 HostPoolGroup 中 localize 锚点 KV 索引对。kpham-sgl 回复“改成了更聪明的索引方案,用最小改动避免 D2H 同步”,最终方案保留 per-layer 调用、把翻译本身变成零成本切片,对所有缓存路径(含混合注意力)天然安全,tanth47 最终确认 LGTM。

  • 命名语义(ispobock,nit,已解决):ispobock 在中间版本的 cache_controller.py diff 上评论:dcp_localize_indices 在调用点读起来像无条件的 DCP 工作,但 dcp_size == 1 时是恒等,建议 maybe_ 前缀让 no-op 默认显而易见。最终 base.py 的函数已命名为 maybe_dcp_kernel_indices,语义得到体现。

实现拆解

实现分五步:

  1. 定位同步热点python/sglang/srt/mem_cache/pool_host/base.py 中的 dcp_kernel_indices 每次被调用都会执行布尔掩码索引,等价于 nonzero 输出,触发 device→host 同步;在 27 层 MLA 模型上,一个 load 循环最多出现 54 次翻译,全部串行化在 load_stream 上。

  2. 重写翻译函数:将 dcp_kernel_indices 重命名为 maybe_dcp_kernel_indices,实现从布尔掩码改为步长切片 indices[self.dcp_rank :: self.dcp_size] // self.dcp_size。切片是张量视图操作,不启动 kernel、不产生 D2H 同步。等价性依赖一个前提:索引数组由完整加宽页按序拼接而成(radix 树按分配器的加宽页分页),此时每个残差类等量且位置固定,按步长选取与掩码选取结果一致。断言也从“输出计数 × dcp_size == 输入计数”弱化为“输入计数 % dcp_size == 0”——前者是必要非充分条件,后者依赖树/分配器页协议已经保证的属性。

  3. 更新调用点,保持调用结构python/sglang/srt/mem_cache/pool_host/mla.pyload_to_device_per_layerbackup_from_device_all_layer 的翻译调用改为新函数名。draft、HiSparse、HostPoolGroup 加载路径全部不动:三者在 _resolve_hicache_dcp_compatibility 下要么被拒绝要么非 DCP,翻译在那里是恒等。

  4. 测试配套test/registered/unit/mem_cache/test_hicache_dcp_host_pool.py 中 5 处函数调用同步改名,既有用例继续覆盖:无 DCP 恒等映射、对齐加宽页翻译为完整物理页、无序合并页的 owner 规则、ragged 输入拒绝、host sort 后残差配对保持。

  5. 方案演进(重要):中间版本曾把翻译上移到 cache_controller.pystart_loading()(每传输一次,host_rows, device_rows = self.mem_pool_host.dcp_localize_indices(...)),并让 MLA 的 per-layer 方法直接接收物理行;tanth47 指出 HybridCacheController / HostPoolGroup(如 Kimi-K3 混合注意力)路径仍转发加宽锚点索引后,作者改为“翻译本身无同步”的收敛方案,调用结构不变、改动最小,所有路径自动受益。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/pool_host/base.py 缓存池 modified 6.67
python/sglang/srt/mem_cache/pool_host/mla.py 缓存池 modified 5.45
test/registered/unit/mem_cache/test_hicache_dcp_host_pool.py 单元测试 modified 4.45

关键符号

maybe_dcp_kernel_indices dcp_kernel_indices load_to_device_per_layer backup_from_device_all_layer

关键源码片段

python/sglang/srt/mem_cache/pool_host/base.py core-logic

核心改动文件:`dcp_kernel_indices` 重命名为 `maybe_dcp_kernel_indices`,实现从布尔掩码索引(触发 nonzero 的 device→host 同步)改为步长切片(纯视图操作),并弱化断言,是整个性能修复的根基。

# python/sglang/srt/mem_cache/pool_host/base.py
# DCP(数据并行分片)下,radix 树 /controller 层看到的是“加宽”的逻辑槽位:
# 逻辑槽位编号 = 物理行编号 * dcp_size + rank 残差,即每个物理行被 dcp_size
# 个 rank 共享、各自展示为 dcp_size 个逻辑槽位。传输 kernel 按每 rank 物理行
# 索引,所以调用方必须先把加宽逻辑槽位翻译为本 rank 物理行。
def maybe_dcp_kernel_indices(self, indices: torch.Tensor) -> torch.Tensor:
    """把加宽逻辑槽位翻译为本 rank 物理行;dcp_size == 1 时是恒等映射。    旧实现用布尔掩码 `indices[indices % dcp_size == dcp_rank]` 选取槽位,
    该操作会走 `nonzero`,需要把选中数量读回主机才能确定输出大小,即一次
    device→host 同步;而它运行在 load_stream 内,会把逐层传输 kernel 串行
    阻塞住(27 层 MLA 每层两次翻译 = 54 次同步,直接卡在 scheduler 线程的
    forward launch 之前)。
    """
    if self.dcp_size == 1:
        return indices
    # 依赖前提:索引数组由完整加宽页按序拼接而成(radix 树按分配器的加宽页
    # 分页,页内槽位连续)。此时每个残差类等量出现且位置固定,按步长切片
    # 等价于布尔掩码的选取结果,但切片只是张量视图操作,零 kernel 启动、
    # 零同步。
    assert indices.numel() % self.dcp_size == 0, (
        "HiCache DCP translation expects runs of whole widened pages; got "
        f"{indices.numel()} logical slots with dcp_size={self.dcp_size}."
    )
    return indices[self.dcp_rank :: self.dcp_size] // self.dcp_size
python/sglang/srt/mem_cache/pool_host/mla.py core-logic

MLA host 池的两个调用点(load_to_device_per_layer、backup_from_device_all_layer)更新为使用无同步的新函数;翻译调用保留在 per-layer 循环内,但因实现变为 O(1) 视图操作,成本归零,也避免了侵入 HybridCacheController 等混合注意力路径。

# python/sglang/srt/mem_cache/pool_host/mla.py
# 每层加载入口:翻译调用仍保留在每个 layer 迭代里,但因为
# maybe_dcp_kernel_indices 已退化为 O(1) 步长切片,不再产生 kernel 启动与
# D2H 同步,无需再把翻译搬到 controller 层,也避免了对 HybridCacheController /
# HostPoolGroup 这类混合注意力路径的侵入性改动(如 Kimi-K3)。
def load_to_device_per_layer(
    self, device_pool, host_indices, device_indices, layer_id, io_backend,
    *, is_draft=False,
):
    # MTP draft 层不参与 CP 层分片;且 dcp_size > 1 时启动期已拒绝
    # speculative decoding,draft 路径的 dcp_size 恒为 1,翻译是恒等。
    if not is_draft and not self._is_device_layer_owned(device_pool, layer_id):
        return
    host_indices = self.maybe_dcp_kernel_indices(host_indices)
    device_indices = self.maybe_dcp_kernel_indices(device_indices)
    # 后续流程不变:普通层与 MTP draft 层使用不同的 host/device layer id,
    # 再按 io_backend 分发到 JIT kernel / 非 JIT kernel / direct 拷贝。
    host_layer_id = layer_id if is_draft else self._host_layer_index(layer_id)
    device_layer_id = 0 if is_draft else layer_id
    ...

评论区精华

混合注意力路径(HybridCacheController / HostPoolGroup)的 DCP 索引翻译安全 正确性

tanth47 指出:混合注意力模型的缓存路径使用 HostPoolGroup -> HybridCacheController,attention KV 池为锚点、辅助状态池分别传输(Kimi-K3 即此结构);中间版本从 MLATokenToKVPoolHost.load_to_device_per_layer() 移除翻译后,HybridCacheController.start_loading() 仍原样转发加宽的逻辑锚点索引,锚点 KV 索引对将失去本地化。他询问是否应在混合控制器或 HostPoolGroup 中 localize 锚点索引对,而保留辅助 Mamba/ 线性注意力路径不变。

结论:kpham-sgl 回复“改成了更聪明的索引方案,可以用最小改动避免 D2H 同步”;最终方案不搬移调用点,而是让翻译函数本身零成本(步长切片),所有路径(含混合注意力)自动安全。tanth47 最终确认 LGTM。 · 已解决

dcp_localize_indices 命名暗示无条件 DCP 工作 style

ispobock 在中间版本 cache_controller.py 的 diff 上评论:dcp_localize_indices 在调用点读起来像无条件的 DCP 工作,但 dcp_size == 1 时是恒等映射,建议改为 maybe 前缀使 no-op 默认显而易见;并指出底层 dcp_kernel_indices 也有同样的命名问题(pre-existing)。

结论:最终基础函数命名为 maybe_dcp_kernel_indices,语义已体现“可能为恒等”;reviewer 后续两次给予 APPROVED。 · 已解决

风险与影响

  1. 依赖页序不变式(主要风险):步长切片 indices[dcp_rank::dcp_size] 的正确性依赖“输入索引由完整加宽页按序拼接”的前提。若未来 allocator 合并、move_indices 排序或页分配顺序发生变化导致页内交错,切片会静默选出错误槽位,而新断言 numel % dcp_size == 0 只检查数量可整除,无法捕获这种错误(旧断言也只能捕获残差类数量不等的情况,同样是必要非充分)。现有测试 test_matches_owner_rule_on_merged_unordered_pagestest_positional_residue_pairing_survives_host_sort 覆盖了无序页与 sort 场景,但最终 patch 未新增 PR body 声称的“防止重复翻译”用例,防护主要依赖树/分配器页协议约束。
  2. 数值安全:到达传输 kernel 的索引与改前完全一致,无数值变化,回归面小;dcp_size == 1 时函数返回输入本身,非 DCP 用户零影响。
  3. 文档/描述不一致:PR body 描述的“翻译从 MLA 实现移除、controller 每传输 localize 一次”与最终 patch(调用点保留、翻译零成本化)存在出入,阅读代码或依赖 body 理解实现的人可能产生困惑。
  4. 性能收益边界:收益仅在 --dcp-size > 1 且启用 HiCache 时体现;单机无 DCP 部署无变化。

影响范围集中在 DCP + HiCache 的跨节点缓存加载路径:

  • 性能:8×B300 + DeepSeek-V2-Lite-Chat(MLA,27 层)、--tp-size 8 --dcp-size 8 实测,调度器线程上一个完整 load 循环从 8.99 ms(52 次翻译)降至 1.06 ms(0 次同步事件),贴近无 DCP 基线 1.02 ms(差距约 4%);aten::nonzero 完全从控制轨迹消失。
  • 用户/系统:对大规模 HiCache + DCP 部署,start_loading 不再阻塞在隐式 D2H 同步上,解码前的前向启动延迟显著下降;此路径位于 scheduler 线程,直接改善吞吐与首 token 延迟。
  • 团队/后续演进:该“切片替代掩码、以视图操作规避 nonzero 同步”的模式可复用于其他 D2H 敏感路径(如 backup_from_device_all_layer 同样受益);同时消除了 per-layer 冗余计算这一架构级浪费,为 DCP 下更大页、更多层数模型的加载扩张铺平了道路。
核心路径变更(scheduler 线程) 依赖页序不变式 描述与最终实现有出入

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论