Prhub

#25065 [UnifiedTree] fix: backup SWA-split parent before child under write-through

原始 PR 作者 alphabetc1 合并时间 2026-05-25 00:21 文件变更 2 提交数 2 评论 5 代码增减 +37 / -1

执行摘要

修复 SWA 分裂叶子在 write-through 下丢失备份的 bug

修复 HiCache write_through 模式下 SWA 边界分裂叶子被静默丢弃的 bug:当插入跨越 swa_evicted_seqlen 时,产生的 split parent 没有 host_value,导致 child 的 write_backup 被跳过,后续设备驱逐时节点被删除而非降级为主机节点,违反已有不变性“每个备份节点的父节点也必须备份”。

值得精读:patch 虽小但修复了 write-through + SWA 路径下缓存一致性 bug,递归备份方案设计简洁,测试完整。关注 write_backup 中递归的边界条件,以及测试对 swa_evicted_seqlen 的构造方式。

讨论亮点

Review 中 gemini-code-assist[bot] 建议移除 not write_back 守卫并传播 write_back 标志,以确保所有策略下的一致性;hzh0425 提出是否应该在 SWAComponent.commit_insert_component_data_split_node 中触发 _inc_hit_count 来避免递归回溯所有祖先。alphabetc1 回应:在 commit_insert_component_data 中添加 _inc_hit_count 会使 SWA 感知树备份逻辑,更合适的做法是放入 _split_node;且递归回溯仅发生在父节点缺失备份时,不会产生多余写入。最终 hzh0425 批准了当前方案。

实现拆解

  1. 修改 write_backup 方法python/sglang/srt/mem_cache/unified_radix_cache.py 第 1172 行):当 write_back == False 且父节点非根且未备份时,不再直接返回 0,而是递归调用 self.write_backup(node.parent),只有递归备份失败(返回值 ≤ 0)时返回 0。
  2. 新增单元测试test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 第 1651-1684 行):test_hicache_write_through_offloads_swa_split_leaf 测试构造一个 SWA 边界分裂场景,设置 write_through_threshold=1,插入后调用 writing_check(write_back=True)evict,断言分裂叶子被驱逐、已备份、且位于可驱逐主机叶子集合中。
  3. 递归保证:递归由正常的 write_backup 生命周期管理(锁引用、ongoing_write_through 簿记、主机池记账),无需独立的清理路径;递归深度受 radix 树深度限制;仅当父节点确实缺失主机备份时触发,不会导致已备份祖先的冗余写入。
文件 模块 状态 重要度
python/sglang/srt/mem_cache/unified_radix_cache.py 缓存层 modified 5.53
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 单元测试 modified 5.8

关键符号

write_backup test_hicache_write_through_offloads_swa_split_leaf

关键源码片段

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

修复核心:在 write_backup 中递归备份未备份的父节点,确保 write-through 不变性。

def write_backup(self, node: UnifiedTreeNode, write_back: bool = False) -> int:
    """Backup a node's data from device to host (D->H)."""
    if self.cache_controller is None:
        return 0
​
    # Backup invariant (write-through): parent must be backuped first
    if not write_back and (
        node.parent is not self.root_node and not node.parent.backuped
    ):
        # 递归备份父节点,若失败则返回 0
        if self.write_backup(node.parent) <= 0:
            return 0
​
    device_value = node.component_data[BASE_COMPONENT_TYPE].value
    kv_xfer = PoolTransfer(name=PoolName.KV, device_indices=device_value)
​
    # Build aux transfers, keyed per component.
    comp_xfers: dict[ComponentType, list] = {}
    for comp in self._components_tuple:
        if comp.component_type == BASE_COMPONENT_TYPE:
            continue
        t = comp.build_hicache_transfers(node, CacheTransferPhase.BACKUP_HOST)
        if t:
            comp_xfers[comp.component_type] = t
    sidecar_xfers = self._build_sidecar_transfers(
        CacheTransferPhase.BACKUP_HOST, kv_xfer, comp_xfers
    )
​
    # Pre-evict host if insufficient
    kv_tokens = len(device_value)
    host_avail = self.cache_controller.mem_pool_host.available_size()
    if host_avail < kv_tokens:
        needed = kv_tokens - host_avail
        evicted = self.evict_host(needed)
        if evicted < needed:
            return 0
​
    # ... 后续写入主机池逻辑不变
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py test-coverage

新增测试验证 SWA 边界分裂叶子在 write-through 下能被正确备份和驱逐,是回归防护的关键。

def test_hicache_write_through_offloads_swa_split_leaf(self):
    """A SWA boundary-split leaf should offload normally under write-through."""
    if not self.cfg.has_swa:
        self.skipTest("requires SWA")
    if self.cfg.has_mamba:
        self.skipTest("SWA-only path keeps the split setup simple")
​
    ps = self.cfg.page_size
    tree, allocator, _ = build_fixture(self.cfg)
    self._init_hicache(tree)
    tree.write_through_threshold = 1 # 每次命中都触发 write-through
​
    seq = self._make_seq(1, 2)
    value = self._alloc(allocator, len(seq))
    # 插入时设置 swa_evicted_seqlen 为 page_size,强制产生分裂
    result = tree.insert(
        InsertParams(
            key=RadixKey(seq),
            value=value,
            swa_evicted_seqlen=ps,
        )
    )
    self.assertEqual(result.prefix_len, 0)
​
    # 验证树结构:root -> split_parent -> split_leaf
    self.assertEqual(len(tree.root_node.children), 1)
    split_parent = next(iter(tree.root_node.children.values()))
    self.assertEqual(len(split_parent.children), 1)
    split_leaf = next(iter(split_parent.children.values()))
​
    # 触发 write_backup 和驱逐
    tree.writing_check(write_back=True)
    tree.evict(EvictParams(num_tokens=len(seq)))
​
    # 断言叶子已被驱逐、已备份、且出现在可驱逐主机叶子集合中
    self.assertTrue(split_leaf.evicted)
    self.assertTrue(split_leaf.backuped)
    self.assertIn(split_leaf, tree.evictable_host_leaves)
    tree.sanity_check() # 额外验证树结构完整性

评论区精华

递归备份 vs 在 SWA 分裂点触发备份 设计

hzh0425 建议在 SWAComponent.commit_insert_component_data 或 _split_node 中触发 _inc_hit_count 以避免递归回溯所有祖先;alphabetc1 认为将 _inc_hit_count 放入 commit_insert_component_data 会使 SWA 感知树备份逻辑,更合适的是放入 _split_node;最终 hzh0425 同意当前方案。

结论:采用递归备份方案,因为递归深度受 radix 树深度限制且仅对未备份父节点触发,不会造成多余开销。 · 已解决

移除 not write_back 守卫 正确性

gemini-code-assist[bot] 建议移除 not write_back 守卫并传播 write_back 标志,以保障所有策略下的一致性。

结论:未采纳,当前修复仅针对 write-through 场景,非 write_back 路径下保持不变。 · 未采纳

风险与影响

递归调用 write_backup 可能增加递归深度,但 radix 树深度通常很小(受序列长度限制),风险可控。该递归仅在父节点未备份时触发,不会导致已备份祖先的冗余备份。若父节点备份失败(如主机空间不足),递归会返回 0,保持原有行为不变。对非 write-through 场景无影响。新测试覆盖了 SWA-only 路径,未覆盖同时有 MAMBA + SWA 的混合场景。

影响范围仅限于 HiCache 的 write_through 策略 + SWA 组件。修复后,跨越 SWA 驱逐边界的叶子在 write-through 模式下能被正确备份和降级,避免重复计算。对非 SWA 或非 write-through 场景无影响。新增测试确保不变性不被破坏。

递归深度风险低 仅 SWA+write-through 路径 缺少混合组件场景测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论