最近在学习KV Cache,看到Mooncake 里一个所见略同的关于索引结构的 RFC:要不要在 Store 侧维护 radix 辅助索引,让缓存驱逐满足这个约束:
ini
children alive => parent alive
翻译成人话就是:如果某个子前缀还在缓存里,那它的父前缀也应该还在。
这个约束听起来很合理。prefix cache 本来就依赖"连续前缀"。如果父 block 已经被驱逐,只剩下后面的 suffix block,那这些 suffix KV 很可能用不上。缓存还占着,但实际复用不了。
但问题在于合理,并不代表值得做。
维护 radix index 有成本。驱逐时可能要扫更多候选,Store 侧还会有锁、元数据内存、HA replay、prefix query 这些问题。于是我用真实数据集跑了一轮模拟,想先回答一个更窄的问题:
只看 eviction policy,radix-aware eviction 到底能换回多少收益?
先说结论:
radix_lru不建议上,toolagent trace 上比 LRU 更差radix_cost_aware有价值,但更适合作为 workload-gated policy- 默认策略仍然应该是 approximate LRU
- 真正进生产前,还要单独测 Store 侧锁、内存和 prefix-query 成本
背景:为什么 orphaned suffix 是个问题
Prefix cache 的核心不是"某个 block 在不在缓存里",而是"从开头开始,连续命中了多长"。
假设一个请求前缀是:
rust
A -> B -> C -> D
如果缓存里有 C 和 D,但 A 或 B 已经没了,那普通 prefix reuse 场景下基本不能直接从 C 开始复用。除非系统支持更复杂的 selective recomputation,否则后面的 suffix KV 不能算真正命中。
这类状态我在报告里叫 orphaned suffix。
普通 LRU 很容易产生这个问题。它只看对象粒度的 recency,不知道 prefix tree 的父子关系。Radix-aware eviction 想解决的就是这个:驱逐时尽量不要留下"子还活着,父没了"的结构。
策略的完善
一开始我没有直接跑数据。先修了本地模拟器里一个会影响结论的问题。
之前的 radix index 在 block eviction 后没有 unregister。也就是说,block 已经不在 cache 里了,但 radix tree 里还留着路径。这样跑出来的 radix_lru 和 radix_cost_aware 会带着陈旧索引做决策,结果不能信。
这次主要修了几类东西:
- eviction 后从 radix tree 删除路径
- 增加
orphaned_suffix_ratio - 把
cache_hit_depth改成严格 prefix hit - 同时间戳事件按调度顺序处理
- eviction tie-break 固定,保证 trace replay 可复现
其中最重要的是 strict-prefix hit。
旧口径容易把"这个 block 还在缓存里"算成命中。新口径只认最长连续前缀。父 block 缺了,后面的 block 不再算可复用 KV。这个口径更接近 vLLM、SGLang、Mooncake 这类 prefix cache 系统实际关心的东西。
实验验证
实验采用 Mooncake FAST'25 的三份 trace:
- conversation
- toolagent
- synthetic
它们都有 hash_ids,可以保留 prefix/cache block identity。这个很关键。只有 token 长度的 trace,比如 Azure Splitwise 或 BurstGPT,可以看流量形态,但不能直接拿来判断 prefix reuse。
对比三个策略:
| 策略 | 含义 |
|---|---|
lru |
baseline,近似 LRU |
radix_lru |
保持 radix 父子约束的 leaf-first LRU |
radix_cost_aware |
在 radix 结构上考虑共享度和复用价值 |
注意,这不是生产 Store benchmark。
这轮模拟不测:
- Master lock contention
- metadata memory
- HA replay
GetKeysByPrefixlatency
它只看 eviction policy 在模拟器里的行为。
结果 1:radix 约束确实能消掉 orphaned suffix
这个结果比较符合预期。
在三份 FAST'25 trace 上,两个 radix 策略都把 orphaned_suffix_ratio 打到了 0。普通 LRU 会留下大约 0.015 到 0.022 的 orphaned suffix。
主表里几行数据是这样的:
| workload | policy | strict hit | orphaned_suffix_ratio | eviction-induced prefill |
|---|---|---|---|---|
| conversation | lru | 0.205 | 0.022 | 1178 |
| conversation | radix_lru | 0.204 | 0.000 | 335 |
| conversation | radix_cost_aware | 0.204 | 0.000 | 335 |
| toolagent | lru | 0.611 | 0.015 | 1200 |
| toolagent | radix_lru | 0.587 | 0.000 | 1673 |
| toolagent | radix_cost_aware | 0.616 | 0.000 | 969 |
conversation trace 上,radix 策略把 eviction-induced prefill 从 1178 降到 335。这个收益很明显。
但注意,它没有提高 strict hit。conversation 上三个策略的 strict hit 都在 0.204 到 0.205 附近。也就是说,radix 主要减少了 eviction 带来的 recompute damage,不是直接把 hit rate 拉上去了。
结果 2:radix_lru 在 toolagent 上劣化
toolagent trace的结果令人意外。一开始我以为 radix_lru 至少会是个"保守但稳定"的方案。结果不是。
在 toolagent full trace 上:
| 指标 | LRU | radix_lru |
|---|---|---|
| strict hit | 0.611 | 0.587 |
| eviction-induced prefill | 1200 | 1673 |
| TTFT p95 | 17.14 ms | 22.37 ms |
radix_lru 确实消除了 orphaned suffix,但它让 hit 更差,recompute 更多,TTFT 也更差。
toolagent 流量的 prefix reuse 更集中,哪些 prefix 值得保护很重要。只按 leaf-first 的方式维护树结构,可能会保护错东西。结构正确,不等于策略正确。
这点挺关键。很多时候我们会把数据结构的"正确性"误读成策略收益。但在这里不成立。
结果 3:radix_cost_aware 存在收益
radix_cost_aware 在 toolagent 上就好不少:
| 指标 | LRU | radix_cost_aware |
|---|---|---|
| strict hit | 0.611 | 0.616 |
| eviction-induced prefill | 1200 | 969 |
| TTFT p95 | 17.14 ms | 19.09 ms |
| radix_nodes_avg | 1664 | 370 |
cost-aware的意义在于:保护共享度高、复用价值更高的 prefix,而不是机械地按 leaf-first 做 LRU。
然而,toolagent 上,radix_cost_aware 的 TTFT p95 还是比 LRU 差。overhead proxy 也不低:大约 591 次 candidate scan 换 1 次 saved eviction-induced prefill。
这提醒我们:Store 侧真做 radix eviction,不能只看 cache hit,还要看驱逐路径本身是不是太重。
容量 sweep:收益只在某些压力区间明显
我又对 toolagent 跑了一组容量 sweep:
bash
10, 40, 160, 640, 2560 blocks/node
结果挺有意思。
| blocks/node | policy | strict hit | TTFT p95 | eviction-induced |
|---|---|---|---|---|
| 40 | lru | 0.611 | 17.14 | 1200 |
| 40 | radix_lru | 0.587 | 22.37 | 1673 |
| 40 | radix_cost_aware | 0.616 | 19.09 | 969 |
| 160 | lru | 0.546 | 39.26 | 1403 |
| 160 | radix_cost_aware | 0.550 | 39.21 | 1309 |
| 2560 | lru | 0.559 | 64.28 | 921 |
| 2560 | radix_cost_aware | 0.561 | 63.45 | 985 |
40 和 160 blocks/node 下,radix_cost_aware 比较有价值。到了 2560 blocks/node,它的 strict hit 和 TTFT 略好一点,但 eviction-induced prefill 反而更高:985 vs 921。
所以这不是一个"容量越大越好"的故事。更准确地说,它在中等压力、prefix reuse 结构明显的时候比较有用。压力太低,收益变小。压力太极端,也可能选错保护对象。
还有一个 caveat:不同 policy 下 admission 会有轻微差异,所以 TTFT 和 hit depth 是在各自 completed requests 上算的。小幅 delta 不能过度解读。要更严格的话,后面应该加 matched-request 或 all-arrivals 分析。
结论
这轮模拟之后,初步的判断是:
radix_lru 不值得做默认策略。它解决了 orphaned suffix,但在 toolagent 上把更重要的指标搞差了。
radix_cost_aware 可以继续推进,但它应该是 gated policy。系统要先判断当前 workload 是否有足够强的 prefix reuse,再决定是否启用。直接替换 LRU 风险太大。
LRU 继续做默认值。它不完美,会留下 orphaned suffix,但简单、高效、稳定。
至于 Store 侧 radix auxiliary index,要不要进生产,还缺模拟器给不了的东西:
- prefix query 延迟
- Master / Store 锁竞争
- 元数据内存
- HA replay 成本
这几个必须用更贴近 Store 的 benchmark 测。Mooncake-Sim 能帮我们排除一些坏方向,比如 plain radix_lru。但它不能替生产系统做决定。
本次最大的收获是:不要把"数据结构上更正确"直接等同于"调度策略上更好"。先让策略变聪明,再决定数据结构值不值得进主路径。