Prhub

#38486 [HiCache] Publish a host store event for storage-prefetch refills

原始 PR 作者 littledgg 合并时间 2026-09-13 18:29 文件变更 4 提交数 6 评论 9 代码增减 +66 / -0

执行摘要

HiCache 补发 host 存储事件,修复路由器 L2 索引缺失

PR body 明确指出事件发布的不对称:SGLang 为 L1 发布 BlockStored(GPU),为 L2 发布 BlockStored(CPU_PINNED),但 L2 只有 write-through ack 这一个发布者;host 内存还有第二个生产者——storage(L3)prefetch refill,它通过 tree_core.insert_host() 直接插入 host 树,两套 unified tree core 在该路径上都不发事件,导致这些 block 永远进不了 cache-aware router 的低层级索引。作者把问题上升为不变式:“every path that turns a node's host_value from None into a value should publish BlockStored(CPU_PINNED). insert_host was the only exception.”,并指出 HiRadixCache._insert_helper_host 已经发布同样的事件,两套 hicache 实现当前并不一致。缺失事件不会立刻暴露——refill 后 device 副本会被加载并被 BlockStored(GPU) 覆盖;只有当 device 副本被 demote(evict_device_leaf 走 _demote 分支,节点已 backuped 所以没有写回、也没有迟到事件)而 host 副本仍存活时,问题才显现:router 认为 worker 一无所有,而实际上一次 H2D 拷贝就能服务请求——这正是 L2 hit weight 存在的意义。观测证据:8 节点部署上的 Dynamo KV router,worker 从 Mooncake L3 拉回 169,472 tokens,而该轮运行的全部 344 次路由决策中 host_pinned_blocks=0。此外移除侧是无条件发布的,_evict_host_leaf 与 _release_full_host_duplicate 会对同一批节点发 BlockRemoved(CPU_PINNED),消费者可能观察到它从未见过的 stored 事件的 remove。

值得精读,尤其是负责路由、KV 事件或 unified radix cache 的工程师。理由:diff 极小(源码 +2 行)但语义跨度大,是一个典型的“可观测性缺口会伪装成路由性能问题”的案例,PR body 的推理链(为什么问题不立即暴露、demote 分支为何不补发事件、为什么不对称的 remove 让问题可见)本身就有很高的阅读价值。
读的时候重点关注三件事:一是 insert_host 中新增 record_store 的插入位置与两条静默边界(完整命中、write-through 丢弃)是否覆盖了所有 host_value 物化路径;二是 Python 与 Rust 两套实现的同步策略,思考是否有更好的机制(例如把发布收敛到 shared recorder / 在 C++/Rust 层统一)而不是靠人工保持一行对一行;三是考虑是否需要一条端到端或回放测试来锁定“L3 refill 后 router 能看到 host_pinned_blocks”的行为,避免未来回归。
如果只想过一遍结论:可以把 Jialin 的 “Looks safe and lightweight enough to land.” 作为合入依据,但仍建议核对你们下游 router 对重复 BlockStored(CPU_PINNED) 的幂等处理。

讨论亮点

整体是低争议、快速落地的小 diff,讨论集中在两处:

  1. 测试里的 hash 计算冗余(唯一一条代码级 review 评论):alphabetc1 在 test_unified_radix_cache_unittest.py 上给出 nit——“get_hash_str already pages when given page_size, same as compute_node_hash_values: hash_values = get_hash_str(array("q", tokens), None, page_size=cache.page_size)”,指出原实现手写了 for start in range(0, len(tokens), cache.page_size) 的累积 hash 循环。作者回复 “Thx, fixed in the latest push.”,并在 commit 76bdc874(“…drop the manual loop. (review nit)”)中落地。

  2. 是否可安全合入:Jialin 以 APPROVED 给出结论 “Looks safe and lightweight enough to land.”,主要依据是事件只在启用时才有开销、且不触碰模型输出(PR body 的 Accuracy Tests 一节写 “Not applicable; this only adds a KV cache event and does not touch model outputs”)。

  3. CI 红灯归属(issue 评论线,7 条):CI 出现过 Rust 新测试的编译错误,作者在 commit 9e0f2f0 修复并本地用 pre-commit 的 clippy + rustfmt 验证,同时说明剩余 call-gate 红是因为缺少 run-ci 标签(无权自加),随后 Jialin /tag-and-rerun-ci、alphabetc1 /rerun-test -c 与 /rerun-failed-ci。最终作者逐条归因为存量问题(AMD stage-b rocm10 的 LoRA ROUGE-L 精度 flake 与 Triton deterministic test、MLX test_scheduler_mixin、Xeon CPU build),alphabetc1 用截图确认 “The failing CI is unrelated to the PR, and it also fails on main”,因此该 PR 最终带 run-ci、bypass-fastfail 标签合入。

未解决的疑虑:作者自述 workspace 级别的 clippy 未在本地执行(“The workspace-wide clippy run was not executed locally.”),属于已知的验证边界而非阻塞项。

实现拆解

  1. Python 侧发布点(python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py,insert_host()):在 new_node 建好、host_value 从 None 物化、_update_evictable_leaf_sets() 更新完可淘汰叶子集合、并写入 result.inserted_host_node = new_node.id 之后,新增 self.kv_events.record_store(new_node, medium=StorageMedium.CPU) 再 return result(源码仅 +1 行)。选择这个位置是因为它是“host 值真正落地”的唯一分支,并且此时节点的 hash_value、key 都已就位,事件里能带上正确的 block hashes 与 token_ids。

  2. Rust 侧同步(rust/sglang-radix-tree/src/unified_tree_core.rs,try_insert_host_in_namespace()):在同样的收尾位置插入 self.record_store_event_(new_node_id, StorageMedium::Cpu); 后 Ok(result)(+1 行)。两侧语义必须一致,否则 Python tree core 与 Rust tree core 在 KV 事件上会出现行为分叉,而 SGLang 的 unified radix cache 允许二者替换使用。

  3. 保留的静默边界(关键设计决策):

    • 完整命中早返回(len(key) == 0,节点已存在、没有新内容物化)不发布事件;
    • write-through(非 write-back)策略下、父节点尚未 backup 时的 refill 被丢弃(result.host_insert_dropped = True)不发布事件——因为并没有新的 host 数据产生。PR body 明确把这两点列为 out of scope 且“already correct today”;另说明 --hicache-host-memory-mode buffer_only 不走 insert_host,同样不在覆盖范围。
  4. 测试配套(双侧):

    • Rust:rust/sglang-radix-tree/src/tests/unified_tree_core.rs 新增 insert_host_publishes_a_host_store_event(+34 行),用 events_core(2) 构造带事件记录的核心,断言 take_events() 恰好得到一条 KvCacheEvent::BlockStored { medium: StorageMedium::Cpu, block_size: 2, parent_block_hash: None, ... }。
    • Python:test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 新增 test_hicache_storage_prefetch_publishes_host_only_suffix(+30 行),用 enable_kv_cache_events=True 的 fixture,直接调用 cache.tree_core.insert_host(...) 模拟 L3→L2 refill,断言 recorder 把父子相连的页合并成 1 条 BlockStored(CPU_PINNED),token_ids、block_size、hash 列表与 parent_block_hash is None 全部对齐。
  5. 演进与评审修订:6 个 commit 中有 3 个是 main 合并,实质修改为初版 + 两次修订——9e0f2f0 修复 Rust 测试编译错误(insert_host 返回 Result<InsertResult, TreeCoreRuntimeError>,需 .expect("live test node"),否则读 host_insert_dropped 直接触发 E0609),76bdc87 采纳 review nit,改用 get_hash_str(..., page_size=cache.page_size) 取代手写分页循环。

  6. 性能说明:事件关闭时两个 recorder 立即返回,热路径零成本;开启时每次 refill 增加一条 BlockStored,recorder 会合并 parent-linked 页,N 页 refill 只产生一条携带 N 个 block hash 的事件。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py 统一树核心 modified 5.48
rust/sglang-radix-tree/src/unified_tree_core.rs 前缀树底层 modified 5.48
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py 缓存单元测试 modified 5.43
rust/sglang-radix-tree/src/tests/unified_tree_core.rs 树核心测试 modified 5.68

关键符号

insert_host try_insert_host_in_namespace record_store record_store_event_ insert_host_publishes_a_host_store_event test_hicache_storage_prefetch_publishes_host_only_suffix _update_evictable_leaf_sets _evict_host_leaf

关键源码片段

python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py core-logic

Python 侧唯一的功能改动点:`insert_host()` 在 host_value 物化并挂到父节点后新增 `self.kv_events.record_store(new_node, medium=StorageMedium.CPU)`,是 cache-aware router 能看到 storage-prefetch refill 的 L2 数据的直接原因。

# python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py
# insert_host() 的核心分支(前面的 prefix 匹配与 result 组装逻辑已省略):
# 只有走到“新建 host 节点”的这条分支,host_value 才会从 None 变成实值。
def insert_host(self, node_id, key, host_value, hash_value):
    # ... 省略前缀匹配,得到 matched_length / total_len 并组装 result ...
​
    # 边界 1:完整命中(len(key) == 0)。节点已存在,没有新内容物化,因此保持静默。
    if len(key) == 0:
        if (
            node is not self.root_node
            and node.component_data[BASE_COMPONENT_TYPE].host_value is not None
        ):
            result.inserted_host_node = node.id
        return result
​
    # 边界 2:write-through(非 write-back)策略下,父节点尚未 backup 的 refill 会被丢弃。
    # 此时并没有产生新的 host 数据,同样不应发布 BlockStored(CPU_PINNED)。
    if node is not self.root_node and not node.backuped and not self.is_write_back:
        logger.info(
            "HiCache prefetch dropped %d-token refill under un-backed-up node %d",
            len(host_value),
            node.id,
        )
        result.host_insert_dropped = True
        return result
​
    # 走到这里,host_value 一定会落地:这是本 PR 唯一新增的事件发布点。
    new_node = self._new_node(priority=node.priority)
    new_node.parent = node
    new_node.key = key
    new_node.hash_value = hash_value
    new_node.component_data[BASE_COMPONENT_TYPE].host_value = host_value.clone()
    node.children[child_key] = new_node
    self._update_evictable_leaf_sets(new_node)
    self._update_evictable_leaf_sets(node)
    result.inserted_host_node = new_node.id
​
    # 新增:storage prefetch refill 直接从 L3 落进 host 池,没有 write-through ack 替它发布,
    # 只能由 host insert 自己上报,否则 router 的 L2 索引里永远看不到这些 block。
    self.kv_events.record_store(new_node, medium=StorageMedium.CPU)
    return result
rust/sglang-radix-tree/src/unified_tree_core.rs core-logic

Rust 侧对等实现:`try_insert_host_in_namespace()` 在同样的物化收尾点调用 `self.record_store_event_(new_node_id, StorageMedium::Cpu)`,保证 unified radix cache 在两套 tree core 上发布一致的 KV 事件。

// rust/sglang-radix-tree/src/unified_tree_core.rs
// try_insert_host_in_namespace() 中 host 节点落地的收尾段(前缀匹配部分已省略)。
{
    // 匹配到的头部被丢弃,未匹配的后缀挪进一个大小合适的 list。
    let mut hash_value = hash_value;
    let suffix = hash_value.split_off(matched_length / self.page_size);
    self.arena.node_mut(new_node_id).hash_value = Some(suffix);
}
​
// host_value 只写入未匹配的后缀:这里正是 None -> Some(...) 的物化点。
self.arena.set_host_value(
    new_node_id,
    FULL,
    host_value
        .narrow(0, matched_length as i64, (total_len - matched_length) as i64)
        .copy(),
);
​
let child_map_key = self.arena.node(new_node_id).key.child_key(self.page_size);
let displaced = self
    .arena
    .insert_child_edge(node_id, child_map_key, new_node_id);
assert!(
    displaced.is_none(),
    "insert_host: parent {node_id} already has a child on the new node's page"
);
​
self.update_evictable_leaf_sets_(new_node_id);
self.update_evictable_leaf_sets_(node_id);
result.inserted_host_node = Some(self.arena.node(new_node_id).id);
​
// 新增:与 Python 侧 insert_host() 语义一致——host 值一旦被物化就发布 BlockStored(CPU_PINNED),
// 避免两套 tree core 在 KV 事件上出现行为分叉。
self.record_store_event_(new_node_id, StorageMedium::Cpu);
Ok(result)
test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py test-coverage

Python 侧回归测试:新增 `test_hicache_storage_prefetch_publishes_host_only_suffix`,直接调用 `tree_core.insert_host()` 模拟 L3→L2 refill,断言 recorder 合并成单条 BlockStored(CPU_PINNED);同时是 review nit(hash 计算重复分页)的落点。

# test/registered/unit/mem_cache/test_unified_radix_cache_unittest.py
def test_hicache_storage_prefetch_publishes_host_only_suffix(self):
    """A storage-prefetch refill has no write-through ack to publish it."""
    from sglang.srt.mem_cache.utils import get_hash_str, hash_str_to_int64
​
    cache, _, _ = build_fixture(self.cfg, enable_kv_cache_events=True)
    self._init_hicache(cache)
    cache.take_events() # 清掉 reset / init 阶段的事件噪声。
​
    tokens = [1, 2, 7, 8]
    # get_hash_str 传入 page_size 时已经按页切分,等价于 compute_node_hash_values;
    # 这里正是 review nit 的修订点(原实现手写了累积 hash 循环)。
    hash_values = get_hash_str(array("q", tokens), None, page_size=cache.page_size)
    # 绕过 device 侧,直接走 insert_host,模拟 L3 -> L2 的 storage prefetch refill。
    result = cache.tree_core.insert_host(
        cache.root_node_handle(),
        RadixKey(array("q", tokens)),
        torch.tensor([100, 101, 102, 103], dtype=torch.int64),
        hash_values,
    )
    self.assertFalse(result.host_insert_dropped)
    self.assertIsNotNone(result.inserted_host_node)
​
    # recorder 会把父子相连的多页合并成一条事件,因此这里只应看到 1 条 BlockStored(CPU_PINNED)。
    stored_cpu = self._stored_events(cache, StorageMedium.CPU)
    self.assertEqual(len(stored_cpu), 1)
    self.assertEqual(list(stored_cpu[0].token_ids), tokens)
    self.assertEqual(stored_cpu[0].block_size, cache.page_size)
    self.assertEqual(
        self._event_hashes(stored_cpu),
        [hash_str_to_int64(value) for value in hash_values],
    )
    self.assertIsNone(stored_cpu[0].parent_block_hash)

评论区精华

测试中的 hash 计算是否重复分页 style

alphabetc1 在 `test_unified_radix_cache_unittest.py` 留 review nit:“get_hash_str already pages when given page_size, same as compute_node_hash_values: `hash_values = get_hash_str(array("q", tokens), None, page_size=cache.page_size)`”,指出原实现手写了 `for start in range(0, len(tokens), cache.page_size)` 的累积 hash 循环,与 `compute_node_hash_values` 的行为重复。

结论:作者回复 “Thx, fixed in the latest push.”,并在 commit `76bdc874`(“[HiCache] Use get_hash_str's page_size in the host store event test”)中删除手写循环,改用 `get_hash_str(..., page_size=cache.page_size)`。 · 已解决

只加事件、不动主逻辑是否可安全合入 设计

Jialin 以 APPROVED 给出 “Looks safe and lightweight enough to land.”;PR body 的 Speed Tests 一节论证无热路径成本——事件关闭时两个 recorder 立即返回,开启时每次 refill 只多一条 BlockStored,且 recorder 会合并 parent-linked 页,N 页 refill 只产生一条携带 N 个 block hash 的事件;Accuracy Tests 一节声明 “Not applicable; this only adds a KV cache event and does not touch model outputs”。

结论:审阅者认可该变更风险与开销均可接受并批准合入;未提出额外的性能或设计修改要求。 · 已解决

Rust 测试初版编译失败(E0609) 正确性

初版 Rust 测试直接读取 `insert_host` 返回值的 `host_insert_dropped` / `inserted_host_node`,但 `insert_host` 返回 `Result<InsertResult, TreeCoreRuntimeError>`,导致 E0609 编译错误,CI 首轮红灯;作者在 issue 评论中说明 “I've fixed the CI failure (a compile error in the new Rust test) and verified it locally with the pre-commit clippy + rustfmt hooks.”。

结论:commit `9e0f2f0` 用 `.expect("live test node")` 解包,与周围既有的 `insert_host` 测试写法保持一致;作者同时说明 workspace 级别的 clippy 未在本地跑过(“The workspace-wide clippy run was not executed locally.”)。 · 已解决

CI 红灯是否由本 PR 引入 测试

作者逐条归因:AMD stage-b(rocm10)的 LoRA ROUGE-L 精度 flake 与 Triton deterministic test、MLX 的 test_scheduler_mixin、Xeon CPU build 均为平台 / 存量问题,本 PR 自身的 Lint 与 CUDA base-b 为绿;并请求代为打 `run-ci` 标签(“The remaining `call-gate` red is just the missing `run-ci` label — I don't have permission to self-tag”)。alphabetc1 先后执行 `/rerun-test -c`、`/rerun-failed-ci`,最后给出截图并确认 “The failing CI is unrelated to the PR, and it also fails on main”。

结论:存量失败被排除,PR 带 `run-ci` 与 `bypass-fastfail` 标签合入;`/rerun-test -c` 的 `1-gpu-5090` 用例(`registered/unit/mem_cache/test_unified_radix_cache_unittest.py`)通过。 · 已解决

风险与影响

  1. 事件可能重复发布(主要风险):storage prefetch refill 落进 host 树后,若同一批 block 随后又因为 write-through ack 或 HiRadixCache._insert_helper_host 路径再发一次 BlockStored(CPU_PINNED),消费端(cache-aware router)必须对同一 block_hash 的重复 store 幂等。本 PR 只保证“完整命中早返回”与“refill 被丢弃”两条路径静默,未在事件层做去重;Python 与 Rust 两条实现的去重行为若有差异,问题只会在多节点路由侧暴露。
  2. 双实现同步属于长期维护负担:insert_host 的语义现在靠 Python 与 Rust 两侧各一行调用维持一致(record_store / record_store_event_)。未来若新增 host 值物化路径(新的 component 类型、新的 refill 入口),很容易再次漏发——PR 作者本人就是用“不变式”来描述这个约束,说明它缺乏机制性强制。
  3. 测试只覆盖单条路径、缺少端到端验证:Python 测试直接调 tree_core.insert_host(),没有走完整的 L3→L2 prefetch refill 调度链(storage backend 与 scheduler 的交互未覆盖);Rust 侧同样是单元级。PR body 中的 169,472 tokens / 344 次决策的真实症状没有对应的集成或回放测试来防止回归。
  4. 性能影响可控但有条件:kv_events 关闭时直返;开启时每次 refill 多一次 record_store 调用与事件合并开销,属于可接受量级,但大规模 L3 命中场景下的事件量增长未做压测。
  5. 兼容性:变更不涉及协议格式、配置项或模型输出,但对 BlockStored(CPU_PINNED) 已做去重假设或从未处理过额外 store 事件的下游 router 版本,需要在升级时重新验证。

对系统:修复后 cache-aware router 能正确看到 worker 持有的 L2-only 前缀,避免本可一次 H2D 拷贝解决的请求退回 L3,直接减少跨节点存储流量与首 token 延迟;症状级证据是 8 节点部署上 169,472 tokens 的无效 L3 拉取与 344 次决策中 host_pinned_blocks=0 的索引缺失。
对用户:仅在启用 KV 事件与 L3 storage 后端(如 Mooncake)并配合 cache-aware 路由的多节点部署中可感知,单机、纯 L1/L2 场景无行为差异,模型输出不受影响。
对团队:确立了“host_value 由 None 物化即发布 BlockStored(CPU_PINNED)”不变式,意味着后续任何新增 host 插入路径都要在两套 tree core(Python 与 Rust)中同时补齐事件发布;unified radix cache 的 Python/Rust 对等性成为一个需要持续审计的维度。
对下游消费者:_evict_host_leaf / _release_full_host_duplicate 的 BlockRemoved 不再出现“只删未见”的不对称,路由侧的低层级索引语义变得更自洽。

核心缓存路径变更 Python/Rust 双实现需同步维护 同一 block 的存储事件可能重复发布 测试仅覆盖单元级单条路径 缺少多节点端到端验证 workspace 级 clippy 未本地验证

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论