Prhub

#37091 [Unified Cache][1/N]: Support cache contract for external linker

原始 PR 作者 hzh0425 合并时间 2026-08-30 22:24 文件变更 9 提交数 2 评论 5 代码增减 +319 / -12

执行摘要

为外部缓存链接器定义传输与加载契约,并跟踪插入采用的 KV 范围

PR body 明确说明这是 external linker 系列的第一个 PR,从原先的完整实现 #35687 中拆分出来,目的是引入组件级传输和加载生命周期契约,并在 UnifiedTree 插入时记录被 Full/SWA 采用的范围,以便后续去重远程加载。由于只动缓存侧契约,linker 接线与后端实现留到后续 PR,这样可以独立评审缓存侧的正确性。

建议精读。该 PR 展示了如何在不污染既有路径的前提下,通过阶段枚举和组件钩子为跨节点 KV 缓存定义清晰契约,值得学习其“先契约后实现”的拆分策略。关注重点:record_adopted_range 的区间合并语义、external_cache_stored 与 HiCache 备份的互斥设计,以及 SWA 的 TRAILING_PAGES 窗口对齐方式。

讨论亮点

该 PR 没有行内 review 评论,仅有一条 huangtingwei9988 的 APPROVED。Issue 评论主要是作者触发 CI 重跑(/rerun-test 与 /rerun-group)以及机器人回报测试通过,AMD ROCm 网格失败但未在评论中展开。从实现可以推断设计者有意将 external_cache_stored 与 HiCache 的 backuped 状态分离,使外部链接器存储过的祖先节点在 _build_backup_kv_action 中跳过重复备份;Mamba 组件则用显式断言标明能力边界,避免后续误用。

实现拆解

实现按“契约定义、组件实现、树状态集成、测试配套”四步展开:

  1. 定义外部链接器阶段契约:在 tree_component.py 中新增 LinkerTransferPhase(LOOKUP / LOAD / OFFLOAD)和 ExternalLinkerLoadPhase(PREPARE / COMMIT / ABORT)两个枚举,并在 TreeComponent 基类增加默认钩子 build_external_linker_transferupdate_external_linker_load,默认分别返回 None 与原样透传,保证未接入链路时行为不变。

  2. 按组件实现传输构造

    • full_component.py 新增 _full_allocator() 统一选择 full-attention 池;build_external_linker_transfer 在 OFFLOAD 时导出整段设备 KV 索引与逐页哈希,LOOKUP 只带 keys,LOAD 时先按页数计算不足并驱逐,再分配设备槽位填入 PoolTransfer.device_indicesupdate_external_linker_load 在 ABORT 阶段释放预留槽位。
    • swa_component.py 更复杂:OFFLOAD 只导出滑动窗口尾部若干页并标记 PoolHitPolicy.TRAILING_PAGES;LOAD 分配 swa_attn_allocator 的槽位;PREPARE 阶段将 full 与 SWA 槽位建立 set_full_to_swa_mapping,并按 prefix_len 推算 swa_evicted_seqlen 写回 req.kv;COMMIT 阶段用最终落定的 canonical full 索引重新映射。
    • mamba_component.py 显式 raise AssertionError,声明暂不支持外部链接器模式,避免静默错误。
  3. 为 UnifiedTree 增加外部缓存状态与采用范围记录unified_tree_core.pyUnifiedTreeNode 新增 external_cache_stored 标志,UnifiedTreeCore 新增 enable_external_cache_linker 开关(默认 False);_inc_hit_count_and_check 在开启外部链接器时改用 external_cache_stored 判断是否触发 write-back,_add_new_node 在开启时也计算 hash_value_split_node 传播该标志;_build_backup_kv_actionexternal_cache_stored 的祖先视为无需再备份。同时在 InsertResult 中新增 adopted_rangesrecord_adopted_range(),在 insert walk 的 unevict、overlap、commit 三个位置按组件记录实际吸收 KV 的区间,供后续去重远端加载。

  4. 配套调整与测试hicache_storage.pyPoolTransferResult 新增 restorable_prefix_pages 元数据,用于多 rank 场景下由调用方对可恢复前缀页集合求交;components/__init__.py 导出新枚举;测试文件 test_unified_radix_cache_unittest.pytest_prev_prefix_len 中开启 track_adopted_ranges 并断言 adopted_ranges[ComponentType.FULL] 的区间正确性。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/unified_cache/components/swa_component.py 缓存组件 modified 7.97
python/sglang/srt/mem_cache/unified_cache/components/full_component.py 缓存组件 modified 7.97
python/sglang/srt/mem_cache/unified_cache/components/tree_component.py 组件契约 modified 7.61
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py 树核心 modified 6.58
python/sglang/srt/mem_cache/base_prefix_cache.py 前缀缓存 modified 6.5
python/sglang/srt/mem_cache/unified_cache/components/mamba_component.py 缓存组件 modified 5.94
python/sglang/srt/mem_cache/hicache_storage.py 缓存存储 modified 4.83
python/sglang/srt/mem_cache/unified_cache/components/__init__.py 组件导出 modified 4.1
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 缓存测试 modified 4.67

关键符号

build_external_linker_transfer update_external_linker_load record_adopted_range _full_allocator update_component_on_insert_overlap recover_after_unevict commit_insert_component_data

关键源码片段

python/sglang/srt/mem_cache/unified_cache/components/full_component.py core-logic

Full 组件为整个外部链接器提供 KV 主数据的 OFFLOAD/LOOKUP/LOAD 实现,并抽象出 _full_allocator 处理 SWA 开启时的池选择。

def _full_allocator(self):
    """返回独占 full-attention 池的分配器。"""
    # SWA 启用时 full 池与 SWA 池分离,需要单独选择
    allocator = self.cache.token_to_kv_pool_allocator
    return allocator.full_attn_allocator if self.cache.is_swa_enabled else allocator
​
​
def build_external_linker_transfer(
    self,
    phase: LinkerTransferPhase,
    node: Optional[UnifiedTreeNode],
    keys: Optional[Sequence[str]],
) -> Optional[PoolTransfer]:
    # OFFLOAD:把设备上的整段 KV 连同每页哈希交给外部存储
    if phase == LinkerTransferPhase.OFFLOAD:
        if node is None or not node.hash_value:
            return None
        value = node.component_data[self.component_type].value
        if value is None:
            return None
        return PoolTransfer(
            name=PoolName.KV,
            device_indices=value.to(torch.int64), # 设备页索引,供远端读取
            keys=list(node.hash_value), # 每页哈希,用于远端去重
        )
​
    if not keys:
        return None
​
    # LOOKUP:只查询远端是否存在这些页
    if phase == LinkerTransferPhase.LOOKUP:
        return PoolTransfer(name=PoolName.KV, keys=list(keys))
​
    # LOAD:先按页数预留设备 KV 槽位,缺页走本地驱逐腾位
    if phase == LinkerTransferPhase.LOAD:
        allocator = self._full_allocator()
        num_tokens = len(keys) * self.cache.page_size
        shortfall = max(0, num_tokens - allocator.available_size())
        if shortfall:
            self.cache.evict(EvictParams(num_tokens=shortfall))
        slots = allocator.alloc(num_tokens)
        if slots is None:
            return None
        return PoolTransfer(
            name=PoolName.KV,
            device_indices=slots.to(torch.int64), # 远端写入的落点
            keys=list(keys),
        )
​
​
def update_external_linker_load(
    self,
    phase: ExternalLinkerLoadPhase,
    req: Req,
    full_transfer: PoolTransfer,
    transfer: PoolTransfer,
    prefix_len: int,
    *,
    insert_result: Optional[InsertResult] = None,
    canonical_full: Optional[torch.Tensor] = None,
) -> Optional[PoolTransfer]:
    # ABORT:远端加载失败时释放本地预留的槽位
    if phase == ExternalLinkerLoadPhase.ABORT:
        self._full_allocator().free(transfer.device_indices)
        return None
    return transfer
python/sglang/srt/mem_cache/unified_cache/components/tree_component.py core-logic

定义外部链接器的阶段枚举与组件基类钩子,是整个契约的骨架,所有组件实现都依赖这里的抽象。

class LinkerTransferPhase(str, Enum):
    LOOKUP = "lookup" # 仅查询远端是否已有对应 KV 页
    LOAD = "load" # 向远端请求数据并写入本地池
    OFFLOAD = "offload" # 将本地设备页导出到远端存储
​
​
class ExternalLinkerLoadPhase(str, Enum):
    PREPARE = "prepare" # 远端数据到达前预留本地槽位 / 映射
    COMMIT = "commit" # 数据落盘后提交映射与请求状态
    ABORT = "abort" # 加载失败,释放 PREPARE 预留的资源
​
​
class TreeComponent(ABC):
    # ---- External Cache Linker Hooks ----
​
    def build_external_linker_transfer(
        self,
        phase: LinkerTransferPhase,
        node: Optional[UnifiedTreeNode],
        keys: Optional[Sequence[str]],
    ) -> Optional[PoolTransfer]:
        """构建本组件与外部缓存之间的一次直接传输。        ``node`` 在 OFFLOAD 时携带设备页数据,其他阶段为 None;
        ``keys`` 是设备未缓存尾部的每页哈希(page 0 为第一个未缓存页),
        在 LOOKUP / LOAD 时提供,OFFLOAD 时取 ``node.hash_value``。
        默认返回 None,表示该组件不参与外部链接。
        """
        return None
​
    def update_external_linker_load(
        self,
        phase: ExternalLinkerLoadPhase,
        req: Req,
        full_transfer: PoolTransfer,
        transfer: PoolTransfer,
        prefix_len: int,
        *,
        insert_result: Optional[InsertResult] = None,
        canonical_full: Optional[torch.Tensor] = None,
    ) -> Optional[PoolTransfer]:
        """准备、提交或中止本组件的一次直接加载。"""
        return transfer

评论区精华

外部链接器契约的设计权衡 question

PR 没有行内 review 评论,仅有一条 APPROVED。从提交历史可看到第二次提交专门修复 KV bookkeeping ownership,说明实现过程中曾发现状态归属问题。结合代码推断,设计者有意识地将 external_cache_stored 与 HiCache backuped 分离,避免远端已存储的节点再次写回;Mamba 则用断言硬性拒绝外部链接。

结论:未记录正式讨论;契约边界和 Mamba 不支持以代码形式固化为后续约束。 · 已解决

风险与影响

核心风险集中在默认路径的回归与边界状态的一致性上:

  • unified_tree_core.py 的插入 walk 在所有组件方法签名中新增 result: InsertResult,任何遗漏更新该签名的第三方组件或子类都会导致 TypeError;但仓库内组件均已同步。
  • record_adopted_range 目前只在 SWA 与 unevict 路径上被调用,Full 的普通插入通过 _insert_commit_step_insert_walk_step 显式记录,若后续组件漏记会导致远端加载去重不完整。
  • external_cache_stored_split_node 中传播但在 _add_new_node 中初始化为 False,新节点若由已存储节点派生,可能存在需要手工恢复标志的遗漏点。
  • swa_component.update_external_linker_loadreq.kvswa_evicted_seqlen 计算依赖 prefix_len 与窗口页数,若外部 linker 返回的 keys 顺序或页面边界与本地窗口不对齐,会引入静默错误。
  • 新开关 enable_external_cache_linker 默认 False,所有新增逻辑在未启用时不生效,回归面被控制在契约接入后。

对现有用户与系统:无行为影响,因为外部链接器尚未接线且开关默认关闭。对开发团队:这是一次核心缓存模块的接口扩展,后续外部链接器 PR(如 #37151)将直接依赖这些钩子与元数据;同时 InsertResult/InsertParams 的字段扩展要求所有调用 cache.insert 的下游保持兼容。对测试体系:新增的 adopted_ranges 断言覆盖了插入去重语义,但尚缺少对 OFFLOAD/LOAD/ABORT 全生命周期的单元测试。

新增契约默认关闭 组件方法签名变更 测试覆盖有限 跨模块状态依赖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论