源码精读:RadixCache 实现细节
谁该读这一篇? 已经读过
02-core-concepts/01-radix-attention.md,想啃源码每行细节的工程师。 前置阅读: 上面那篇必读。再看一遍 §2 的TreeNode字段会有帮助。 耗时: 50 分钟 学完能: 1. 在 826 行的radix_cache.py里定位每个函数的角色; 2. 读懂_match_prefix_helper递归逻辑; 3. 解释 lock_ref / host_ref_counter 双计数器在什么场景下分别用; 4. 在 evict 时能预测哪个节点会被先淘汰; 5. 改写淘汰策略时不会踩 race 坑。
1. 文件分布
mem_cache/
├── base_prefix_cache.py ← 抽象基类
├── radix_cache.py ← 我们的主角(826 行)
├── hiradix_cache.py ← HiCache 扩展(CPU 镜像)
├── chunk_cache.py ← chunk-only 模式(关闭 radix)
├── memory_pool.py ← KV 物理存储
├── allocator.py ← slot 分配
├── evict_policy.py ← 淘汰策略接口
├── cache_init_params.py ← 初始化参数
└── cpp_radix_tree/ ← C++ 加速版(可选)
本章只精读 radix_cache.py,其它顺带提及。
2. 主要类(按行号)
| 类 | 行 | 作用 |
|---|---|---|
RadixKey |
66 | 复合 key:token ids + extra_key |
TreeNode |
208 | 树节点 |
RadixCache |
270 | 主类 |
KVCacheEventMixin |
(从其它文件) | 事件通知(HiCache 用) |
BasePrefixCache |
(base_prefix_cache.py) | 抽象基类 |
3. RadixKey(66+)
@dataclass
class RadixKey:
token_ids: List[int]
extra_key: Optional[str] = None # LoRA id / 命名空间 / cache version
def page_aligned(self, page_size):
...
def maybe_to_bigram_view(self, is_eagle):
# EAGLE 投机解码用:把 token 序列看作 bigram
...
设计意图:
- 不直接用
List[int]而是包装 → 可以加 metadata。 extra_key让"相同 token 但语义不同"的请求隔离,举例:不同 LoRA 的 KV 数值不同,必须分树。
__hash__ / __eq__ 都依赖 (tuple(token_ids), extra_key),所以 extra_key 不同的 key 严格不等。
4. TreeNode(208+)
class TreeNode:
counter = 0
def __init__(self, id=None, priority=0):
self.children = defaultdict(TreeNode) # 按首 token id 索引
self.parent: TreeNode = None
self.key: RadixKey = None # 这条边上的 token 序列
self.value: Optional[torch.Tensor] = None # device KV slot 索引
self.lock_ref = 0 # device 引用计数
self.last_access_time = time.monotonic()
self.creation_time = time.monotonic()
self.hit_count = 0 # 用于 LFU
self.host_ref_counter = 0 # CPU 镜像引用计数
self.host_value: Optional[torch.Tensor] = None # CPU 镜像
self.hash_value: Optional[List[str]] = None # HiCache 用,每 page 一个 hash
self.priority = priority # priority-aware eviction
self.id = TreeNode.counter if id is None else id
TreeNode.counter += 1
双计数器(lock_ref / host_ref_counter)的设计:
lock_ref:当前有请求在用 device 上的 KV(不能从 GPU evict)。host_ref_counter:当前有 HiCache 操作引用 CPU 镜像(不能从 CPU 释放)。
两个独立,避免"device 已经 evict 但 host 还要用"或反过来。
hit_count:每次 match_prefix 命中时 ++,可用作 LFU 排序。SGLang 默认 LRU,但可切到 LFU(evict_policy.py)。
5. RadixCache.__init__(271+)
class RadixCache(KVCacheEventMixin, BasePrefixCache):
def __init__(self, params: CacheInitParams):
self.token_to_kv_pool_allocator = params.allocator
self.page_size = params.page_size
self.disable = params.disable
self.is_eagle = params.is_eagle
self.root_node = TreeNode() # 根
self._empty_match_result = MatchResult(...)
# ... 锁 / events / mixin 状态
root_node是空节点,没有 key、没有 value,只是所有子树的父。_empty_match_result预建空结果,避免每次 alloc。disable=True时所有操作 no-op(用于关闭 prefix caching 跑对照基准)。
6. match_prefix 主路径(361+)
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
key = params.key
key, _ = key.maybe_to_bigram_view(self.is_eagle)
if self.disable or len(key) == 0:
return self._empty_match_result
key = key.page_aligned(self.page_size)
if len(key) == 0:
return self._empty_match_result
value, last_node = self._match_prefix_helper(self.root_node, key)
if value:
value = torch.cat(value)
else:
value = self._empty_match_result.device_indices
return MatchResult(
device_indices=value,
last_device_node=last_node,
last_host_node=last_node,
best_match_node=last_node,
)
注意点:
- EAGLE 视角:投机解码模式下 key 要做 bigram 变换。
- page 对齐:截掉零头,避免命中跨 page 半截。
- 真正递归在
_match_prefix_helper。
6.1 _match_prefix_helper(递归核心)
伪代码(实现细节复杂,简化):
def _match_prefix_helper(self, node, key):
"""从 node 出发,匹配 key 的最长前缀。"""
if len(key) == 0:
return [], node
first_token = key.first_token()
if first_token not in node.children:
return [], node # 当前节点子树里没匹配
child = node.children[first_token]
common_prefix_len = longest_common_prefix(child.key, key)
if common_prefix_len == len(child.key):
# 完全消耗 child 的边,递归下钻
remaining_key = key.slice_from(common_prefix_len)
child.last_access_time = time.monotonic()
child.hit_count += 1
sub_values, last_node = self._match_prefix_helper(child, remaining_key)
return [child.value] + sub_values, last_node
else:
# 部分匹配 → 分裂 child
new_node = self._split_node(child, common_prefix_len)
return [new_node.value], new_node
6.2 _split_node:就地分裂
def _split_node(self, child, split_pos):
"""把 child 在 split_pos 处一分为二。
Returns 上半部分(也是新的中间节点)。"""
head_key = child.key.slice_to(split_pos)
tail_key = child.key.slice_from(split_pos)
head_node = TreeNode()
head_node.key = head_key
head_node.value = child.value[:split_pos] # KV 切片(只是改 tensor view,不复制)
head_node.parent = child.parent
# 改 child
child.key = tail_key
child.value = child.value[split_pos:]
child.parent = head_node
head_node.children[tail_key.first_token()] = child
# 父节点的指针改向新 head
child.parent_old = child.parent # 暂存
head_node.parent.children[head_key.first_token()] = head_node
return head_node
物理 KV slot 不动,只是索引张量被切片。
7. insert 主路径(421+)
def insert(self, params: InsertParams) -> InsertResult:
"""把 (key, value=device_indices) 挂到树上。"""
# 1. 先尝试 match,命中部分跳过
matched_node, depth = self._find_attach_point(params.key)
# 2. 剩余部分建新节点
remaining_key = params.key.slice_from(depth)
remaining_value = params.value[depth:]
new_node = TreeNode()
new_node.key = remaining_key
new_node.value = remaining_value
new_node.parent = matched_node
matched_node.children[remaining_key.first_token()] = new_node
# 3. 引用计数(让插入的节点至少有一个 ref,防止立刻被 evict)
new_node.lock_ref += 1
return InsertResult(node=new_node, ...)
_find_attach_point 类似 _match_prefix_helper,但带"如果没分裂就在当前节点尾部挂新子节点"的逻辑。
8. evict 主路径(561+)
def evict(self, params: EvictParams) -> EvictResult:
"""释放 params.num_tokens 个 token 的 KV slot."""
leaves = self._collect_evictable_leaves() # lock_ref == 0 的叶子
leaves.sort(key=lambda n: (n.priority, n.last_access_time)) # LRU + priority
freed = 0
freed_slots = []
while freed < params.num_tokens and leaves:
node = leaves.pop(0)
freed_slots.append(node.value)
freed += node.value.shape[0]
self._detach(node) # 从父节点移除
# 父节点是否变成新叶子?是的话加进 queue
parent = node.parent
if parent is not self.root_node and len(parent.children) == 0 and parent.lock_ref == 0:
insort(leaves, parent, key=lambda n: (n.priority, n.last_access_time))
self.token_to_kv_pool_allocator.free(torch.cat(freed_slots))
return EvictResult(num_freed_tokens=freed)
关键不变量:
- 只 evict 叶子。
- evict 后父节点可能变叶子,要重新插入 candidate 队列。
- priority 字段让"业务标 priority=高"的节点最后才被淘汰。
9. 并发安全
实际上 RadixCache 在 Scheduler 主循环里只在一个线程访问(Scheduler 是单线程 while True)。 所以 RadixCache 内部没有锁。
但有几个 race 风险:
- HiCache 异步线程操作 host_ref_counter / host_value,要用专门的锁。
- 多 TP rank 的 RadixCache 是各自独立的;它们通过 Scheduler 的 broadcast 保持视图一致(rank 0 操作,rank > 0 跟着做相同操作)。
- 不要在 RadixCache 外部直接改 TreeNode 字段(debug 时也别)。
10. 内部节点能不能 evict?
直觉上"如果内部节点的所有子节点都被 evict 了,它就没用了"——可以 evict。 SGLang 实现里:
- evict 时父节点的子树清空后,父节点也被视为可 evict 叶子(见 §8 的循环)。
- 但 evict 父节点的"KV value"时要注意 —— 这块 KV 是真实存在的(之前某段 prefix 用过)。
- 实现上把"父节点也是叶子"的判断写在
_collect_evictable_leaves里。
11. HiCache 扩展(hiradix_cache.py)
继承 RadixCache,加:
- CPU 镜像:device evict 时,先把 KV 复制到 CPU 内存(不释放 slot 直到 CPU 复制完成)。
- 异步 prefetch:在 match_prefix 命中 CPU 镜像但 device 已 evict 时,触发后台 copy 把 KV 从 CPU 拷回 GPU。
host_ref_counter的用武之地:CPU 镜像被异步操作引用时禁止释放。
效果:
- 显存压力大时 KV "暂存"在 CPU;下次命中可以快速换回 GPU。
- 单卡 80 GB 显存的 "实际 KV 容量" 提升到 几百 GB(CPU 内存 + GPU 显存)。
源码细节超复杂,本章只点到。详见 hiradix_cache.py。
12. 关键源码索引
| 内容 | 文件:行 |
|---|---|
RadixKey |
radix_cache.py:66 |
TreeNode |
radix_cache.py:208 |
RadixCache.__init__ |
radix_cache.py:271 |
match_prefix |
radix_cache.py:361 |
insert |
radix_cache.py:421 |
evict |
radix_cache.py:561 |
evictable_size |
radix_cache.py:626 |
| HiCache 扩展 | hiradix_cache.py |
| C++ 加速 | cpp_radix_tree/ |
13. 小结
- 826 行的 RadixCache 主要由 3 个操作组成:
match_prefix/insert/evict。 _match_prefix_helper用递归 + 就地分裂,KV 物理 slot 不移动只切索引。- 双引用计数(lock_ref / host_ref_counter)分别保护 device / host KV。
- 内部节点无子时也可 evict(被循环捡进队列)。
- HiCache 是基于 RadixCache 的 CPU 镜像扩展,量级再上一个台阶。
14. 自检
_split_node时 KV 物理 slot 有没有被复制?为什么?
答案
**没有**。`_split_node` 只改父子指针 + 把原节点的 `value`(device_indices: int64 tensor)切两段,物理 KV 张量在 memory_pool 里原地不动。 原因:KV 张量很大(几 GB),复制代价不可接受;而 device_indices 只是 int64 数组,切片是张量 view 操作 O(1)。 后果:split 极廉价,可以频繁做。- 双引用计数为什么不能合一?
答案
`lock_ref`(device)和 `host_ref_counter`(CPU 镜像)独立的原因: - HiCache 把 KV 异步搬到 CPU 内存做"二级缓存",搬运是后台线程。device KV 可以已被 evict(lock_ref=0)但 CPU 镜像还在被异步操作引用(host_ref_counter>0),此时不能释放 CPU 内存。 - 反过来 device KV 在用(lock_ref>0)但 CPU 镜像没被任何操作引用(host_ref_counter=0)时,CPU 镜像可以先释放给别人。 合一就两边互相绑死,灵活性丧失。- 一个孤立的内部节点(无子、无 ref)会发生什么?
答案
evict 时它**变成新叶子**被捡进 candidate 队列继续被淘汰。`evict` 实现里循环检查"父节点是否变叶子"([`radix_cache.py:561`](../sglang/python/sglang/srt/mem_cache/radix_cache.py)),逐层往上回收。 极端情况:一连串内部节点都变叶子,从下往上一路 evict 到 root 的直接子节点(root 自己不会被 evict)。 树最终如果完全空,只剩 root,等价于 RadixCache 初始状态。- HiCache 的 prefetch 触发条件是什么?
答案
`match_prefix` 命中**有 CPU 镜像但 device 已被 evict** 的节点时,触发后台 prefetch:把 host_value 异步拷回 GPU device pool,绑回 TreeNode.value。 实现细节:`host_ref_counter += 1` 锁住 CPU 镜像;新分配 device slot;异步 cudaMemcpyHostToDevice;完成后 publish 给 RadixCache。期间该节点对 `match_prefix` 仍可见(命中长度同),但请求实际能跑还得等 prefetch 完成。 详见 [`hiradix_cache.py`](../sglang/python/sglang/srt/mem_cache/hiradix_cache.py)。- 多 TP 时 RadixCache 怎么保持视图一致?
答案
**TP rank 0 是主,rank 1+ 跟随**。 主流程:rank 0 的 Scheduler 持有"权威 RadixCache",所有 match_prefix / insert / evict 决策都在 rank 0 做;其它 rank 接收 rank 0 通过 broadcast 发来的 ScheduleBatch 元数据(含 page_indices 等),各自的本地 RadixCache 视图按指令同步更新。 关键不变量:**所有 rank 持有相同的 RadixCache 拓扑**——KV 物理上每 rank 各持一份切片(按 head 维),但树结构 / 节点元数据完全一致。这样 attention kernel 拿到的 page_indices 在所有 rank 上指向自己 GPU 上对应的 KV 切片。 实现:决策只在 rank 0 跑,其它 rank 跑 `tp_worker.py` 接收命令;用 NCCL broadcast 同步 batch 信息。15. 下一步
06-attention-backends.md— RadixCache 怎么被 attention kernel 用。02-core-concepts/03-cache-aware-scheduling.md— Scheduler 怎么用 RadixCache。02-core-concepts/05-token-attention.md— page=1 的设计选择。- 源码:
radix_cache.py、hiradix_cache.py。