Mini-SGLang 源码解析(六):KV Cache 管理系统
学习文件:kvcache/base.py, kvcache/mha_pool.py, kvcache/naive_manager.py, kvcache/radix_manager.py
1. 为什么需要 KV Cache 管理?
在阶段一我们学习了 KV Cache 的基本概念:缓存已计算的 Key 和 Value,避免重复计算。
但在实际系统中,KV Cache 管理面临更多挑战:
挑战1:内存有限
- GPU 内存有限(例如 24GB)
- 每个请求的 KV Cache 可能很大(长文本、长对话)
- 需要驱逐策略来释放空间
挑战2:前缀复用
- 多个请求可能有相同的前缀(例如 System Prompt)
- 重复存储浪费内存
- 需要前缀共享机制
挑战3:并发安全
- 多个请求同时访问缓存
- 驱逐时不能删除正在使用的缓存
- 需要锁机制
2. KV Cache 系统架构
Mini-SGLang 的 KV Cache 系统分为两层:
1 | ┌─────────────────────────────────────────┐ |
类比:
- BaseKVCache = 仓库(存储货物)
- BaseCacheManager = 仓库管理员(决定存什么、删什么、怎么找)
3. BaseKVCache:存储层
3.1 核心接口
1 | class BaseKVCache(ABC): |
3.2 职责
- 分配物理存储空间(GPU 显存)
- 提供读写接口(k_cache, v_cache, store_kv)
- 管理多层缓存(Transformer 的每一层都有 KV Cache)
3.3 关键设计
为什么 k_cache 和 v_cache 需要 layer_id?
因为 Transformer 有多层(例如 Llama 有 32 层),每一层都有独立的 KV Cache:
1 | Layer 0: K[0], V[0] |
4. BaseCacheManager:管理层
4.1 核心接口
1 | class BaseCacheManager(ABC): |
4.2 职责
- 前缀匹配:找到可以复用的缓存
- 生命周期管理:插入、驱逐、锁定
- 并发安全:防止正在使用的缓存被驱逐
- 完整性检查:检测缓存损坏
5. BaseCacheHandle:缓存句柄
1 |
|
作用:
- 代表"缓存中的某个前缀"
- 类似于"提货单"或"借书卡"
- 用于 lock/unlock 操作
为什么需要 handle?
因为 match_prefix 返回的 indices 只是物理地址,不包含管理信息。handle 提供了:
- 匹配的长度(
cached_len) - 抽象的引用(用于锁定)
6. SizeInfo:缓存大小信息
1 | class SizeInfo(NamedTuple): |
为什么要区分 evictable 和 protected?
- protected_size:被
lock_handle锁定的缓存,正在使用中,不能驱逐 - evictable_size:未被锁定的缓存,可以驱逐
类比:
- 图书馆的书分为"在架上的"(可借)和"已借出的"(不可借)
7. 完整的缓存使用流程
假设有一个请求 "Hello world how are you",缓存中已有 "Hello world"。
步骤1:匹配前缀
1 | input_ids = [Hello, world, how, are, you] |
作用:找到可以复用的部分,避免重复计算。
步骤2:锁定 handle
1 | lock_handle(handle, unlock=False) |
作用:防止并发驱逐删除正在使用的缓存。
步骤3:分配新 page
1 | new_pages = allocate_pages(3) # 为 [how, are, you] 分配 |
步骤4:插入新前缀
1 | already_cached = insert_prefix( |
作用:更新缓存管理器的数据结构(例如 Radix Tree)。
步骤5:释放重复的 page
1 | free_pages([page_5, page_12]) |
作用:因为这两个 page 已经在缓存中了,不需要重复存储。
步骤6:存储新 token 的 KV
1 | store_kv(k, v, out_loc=[page_20, page_21, page_22], layer_id) |
作用:实际存储 KV 数据到物理位置。
步骤7:解锁 handle
1 | lock_handle(handle, unlock=True) |
作用:使用完毕,允许后续驱逐。
8. 关键设计问题
8.1 为什么 match_prefix 不修改缓存?
原因:
- match_prefix 是只读操作(查询)
- 可以并发调用,不需要加锁
- 修改操作由
insert_prefix负责
8.2 为什么 lock_handle 要在 insert_prefix 之前?
并发安全问题:
1 | # ❌ 错误顺序 |
8.3 为什么 insert_prefix 返回 already_cached?
避免重复存储:
1 | # 缓存中已有: "Hello world" |
8.4 为什么 evict 可能驱逐更多?
驱逐粒度是"前缀",不是"page":
1 | # 缓存中有两个前缀: |
为什么不能只驱逐半个前缀?
- 前缀匹配需要完整的 token 序列
- 驱逐 “Hello wor” 保留 “ld” 没有意义
- 驱逐策略:选择一个或多个完整的前缀驱逐
9. check_integrity:缓存完整性检查
9.1 什么情况下缓存会损坏?
-
引用计数错误:
- 某个 handle 被锁定,但 size_info 没有正确更新
- 导致
evictable_size + protected_size != total_size
-
Radix Tree 结构错误(后面会学到):
- 父子节点关系断裂
- 节点的 token 序列不连续
-
indices 越界:
- 返回的 indices 超出了 KV Cache 的实际大小
-
内存泄漏:
- 某些前缀被删除了,但对应的 page 没有释放
9.2 check_integrity 的作用
定期检查这些不一致,及时发现 bug。
10. KVCacheLayout:缓存布局
1 | class KVCacheLayout(enum.Enum): |
两种布局的区别:
LayerFirst(层优先)
1 | # Shape: [num_layers, num_pages, page_size, num_heads, head_dim] |
优点:
- 同一层的数据连续存储
- 适合按层遍历
PageFirst(页优先)
1 | # Shape: [num_pages, num_layers, page_size, num_heads, head_dim] |
优点:
- 同一个 page 的所有层连续存储
- 适合按 page 分配/释放
费曼挑战
挑战1:BaseKVCache vs BaseCacheManager
问题:用简单的话解释 BaseKVCache 和 BaseCacheManager 的区别。
解答:
- BaseKVCache = 仓库,负责实际存储货物(KV Tensor)
- BaseCacheManager = 仓库管理员,负责决定存什么、删什么、怎么找货物
关键类比:
- 就像图书馆的"书架"(存储)和"图书管理系统"(管理)
挑战2:为什么需要 lock_handle?
问题:解释 lock_handle 的作用,以及不锁定会发生什么。
解答:
- 作用:防止正在使用的缓存被驱逐
- 不锁定的后果:并发场景下,线程A正在使用某个前缀,线程B可能因为内存不足驱逐它,导致线程A访问到无效数据
关键类比:
- 就像图书馆借书,借出的书不能被下架
挑战3:handle vs indices
问题:解释 match_prefix 返回的 handle 和 indices 分别是什么。
解答:
- handle:抽象的引用,代表"缓存中的某个前缀",包含
cached_len,用于 lock/unlock - indices:物理地址,指向 KV Cache 中的实际存储位置,用于 Attention 计算
关键类比:
- handle = 图书馆借书卡(证明你借了这本书)
- indices = 书架编号(告诉你书在哪个架子上)
挑战4:insert_prefix 的返回值
问题:解释 insert_prefix 为什么返回 already_cached。
解答:
- 返回值:已经在缓存中的前缀长度
- 作用:避免重复存储,调用者应该释放这些重复的 page
关键场景:
1 | # 缓存中已有: "Hello world" |
挑战5:为什么 evict 可能驱逐更多?
问题:解释为什么 evict 的实际驱逐大小可能大于请求大小。
解答:
- 原因:驱逐粒度是"前缀",不是"page"
- 不能只驱逐半个前缀:因为前缀匹配需要完整的 token 序列
关键场景:
1 | # 请求驱逐 5 pages |
学习总结
核心收获
-
两层架构:
- BaseKVCache(存储层)+ BaseCacheManager(管理层)
- 分离关注点,提高可扩展性
-
并发安全:
- lock/unlock 机制防止驱逐正在使用的缓存
- match → lock → insert → unlock 流程
-
前缀复用:
- match_prefix 找到可复用的部分
- insert_prefix 避免重复存储
-
驱逐策略:
- 驱逐粒度是"前缀",不是"page"
- evictable vs protected 区分
-
完整性检查:
- check_integrity 检测缓存损坏
- 及时发现引用计数、结构错误等问题
11. MHAKVCache:BaseKVCache 的实现
11.1 核心职责
MHAKVCache 是 BaseKVCache 的具体实现,负责:
- 在 GPU 上分配物理存储空间
- 实现 k_cache, v_cache, store_kv 接口
- 支持 Tensor Parallelism(TP)
- 支持两种缓存布局(PageFirst / LayerFirst)
11.2 初始化参数
1 | class MHAKVCache(BaseKVCache): |
11.3 核心设计
11.3.1 统一的 kv_buffer
1 | # 创建一个统一的 buffer,包含 K 和 V |
为什么用统一的 buffer?
- ✅ 一次分配,减少内存碎片
- ✅ K 和 V 的形状完全相同,可以共享布局
- ✅ 简化内存管理
11.3.2 支持 Tensor Parallelism(TP)
1 | tp_info = get_tp_info() |
TP 的作用:
- 将 KV 头均匀分配到多个 GPU 上
- 解决单 GPU 显存不够的问题
为什么按 KV 头切分?
因为 Attention 计算时,每个 head 独立计算,互不依赖:
1 | # 单 GPU(无 TP) |
为什么不按 head_dim 切分?
- ❌ Attention 计算需要完整的 head_dim(Q @ K^T)
- ❌ 需要频繁的 GPU 间通信(All-Gather)
- ❌ 通信开销远大于计算开销
TP 的切分原则:沿着"可并行"的维度切分
11.3.3 支持两种布局
PageFirst(页优先):
1 | # 原始形状:(2, num_pages, num_layers, local_kv_heads, head_dim) |
LayerFirst(层优先):
1 | # 原始形状:(2, num_layers, num_pages, local_kv_heads, head_dim) |
为什么最后都统一成 LayerFirst?
因为代码的其他部分(Attention、store_kv)都假设是 LayerFirst 布局:
1 | self._k_buffer[layer_id] # 访问第 layer_id 层的所有 page |
所以 PageFirst 需要 permute 成 LayerFirst:
1 | .permute(0, 2, 1, 3, 4) # (2, pages, layers, ...) → (2, layers, pages, ...) |
11.3.4 多了一个维度 1
1 | self._kv_buffer = kv_buffer.view(2, num_layers, num_pages, 1, local_kv_heads, head_dim) |
为什么需要这个维度?
为了支持 Paged Attention 的批处理。在 Attention 计算时:
1 | # 单个 token 的 K/V |
为什么 page_size = 1?
这是一个简化的实现。实际的 Paged Attention(例如 vLLM)中,每个 page 存储多个 token(例如 16 个),可以减少 page 数量。
但 mini-sglang 为了简化,每个 page 只存储 1 个 token。
如果改成 page_size = 16 需要怎么改?
- 初始化:
1 | PAGE_SIZE = 16 |
- storage_shape:
1 | self._storage_shape = (num_pages, PAGE_SIZE, local_kv_heads, head_dim) |
- out_loc 的含义变了:
1 | # 当前:out_loc[i] = page_id(直接指向 page) |
- store_cache kernel 也需要改:
1 | # 当前:直接写入 k_cache[page_id] |
11.3.5 store_kv 实现
1 | def store_kv(self, k, v, out_loc, layer_id): |
为什么要 view 成 storage_shape?
因为 store_cache kernel 期望的输入形状是:
1 | k_cache: (num_pages, num_kv_heads, head_dim) |
但 self._k_buffer[layer_id] 的形状是:
1 | (num_pages, 1, local_kv_heads, head_dim) |
所以需要 view(self._storage_shape) 去掉这个维度:
1 | (num_pages, 1, local_kv_heads, head_dim) → (num_pages, local_kv_heads, head_dim) |
11.4 完整示例
假设有以下配置:
- 4 个 GPU(TP = 4)
- Llama-7B 模型:32 层,32 个 KV 头,128 维
- 分配 1000 个 page
- 使用 bf16(2 字节)
- LayerFirst 布局
每个 GPU 上的形状:
1 | # local_kv_heads = 32 // 4 = 8 |
store_kv 的输入形状(单个 GPU):
1 | k.shape = (batch_size, local_kv_heads, head_dim) |
费曼挑战(续)
挑战6:为什么用统一的 kv_buffer?
问题:解释为什么 MHAKVCache 用一个统一的 buffer 存储 K 和 V。
解答:
- 原因1:K 和 V 的形状完全相同,可以共享布局
- 原因2:一次分配,减少内存碎片
- 原因3:简化内存管理
关键代码:
1 | kv_buffer = torch.empty((2, num_layers, num_pages, local_kv_heads, head_dim), ...) |
挑战7:为什么 TP 按 KV 头切分?
问题:解释为什么 Tensor Parallelism 按 KV 头切分,而不是按 head_dim 切分。
解答:
- 原因:Attention 计算时,每个 head 独立计算,互不依赖
- 优点:每个 GPU 独立计算,无需通信
- 如果按 head_dim 切分:Attention 计算需要完整的 head_dim(Q @ K^T),需要频繁的 GPU 间通信
关键原则:
- TP 沿着"可并行"的维度切分
挑战8:PageFirst vs LayerFirst
问题:解释 PageFirst 和 LayerFirst 两种布局的区别,以及为什么最后都统一成 LayerFirst。
解答:
- PageFirst:同一个 page 的所有层连续存储,适合按 page 分配/释放
- LayerFirst:同一层的所有 page 连续存储,适合按层访问
- 为什么统一成 LayerFirst:因为代码的其他部分(Attention、store_kv)都假设是 LayerFirst 布局
关键代码:
1 | self._k_buffer[layer_id] # 访问第 layer_id 层的所有 page |
挑战9:为什么多了一个维度 1?
问题:解释 _kv_buffer.view(2, num_layers, num_pages, 1, local_kv_heads, head_dim) 中间为什么多了一个维度 1。
解答:
- 原因:为了支持 Paged Attention 的批处理
- 含义:这个维度表示"每个 page 有多少个 token"(page_size)
- 为什么是 1:mini-sglang 简化实现,每个 page 只存储 1 个 token
如果改成 page_size = 16:
- 需要修改初始化、storage_shape、out_loc 的含义、store_cache kernel
挑战10:为什么 store_kv 要 view?
问题:解释 store_kv 为什么要 view 成 storage_shape。
解答:
- 原因:匹配 store_cache kernel 的接口
- 问题:
self._k_buffer[layer_id]的形状是(num_pages, 1, local_kv_heads, head_dim),多了一个维度1 - 解决:
view(self._storage_shape)去掉这个维度,变成(num_pages, local_kv_heads, head_dim)
学习总结
核心收获
-
两层架构:
- BaseKVCache(存储层)+ BaseCacheManager(管理层)
- 分离关注点,提高可扩展性
-
并发安全:
- lock/unlock 机制防止驱逐正在使用的缓存
- match → lock → insert → unlock 流程
-
前缀复用:
- match_prefix 找到可复用的部分
- insert_prefix 避免重复存储
-
驱逐策略:
- 驱逐粒度是"前缀",不是"page"
- evictable vs protected 区分
-
完整性检查:
- check_integrity 检测缓存损坏
- 及时发现引用计数、结构错误等问题
-
MHAKVCache 实现:
- 统一的 kv_buffer(一次分配,减少碎片)
- 支持 TP(按 KV 头切分)
- 支持两种布局(最终统一成 LayerFirst)
- page_size = 1(简化实现)
- store_kv 调用自定义 CUDA kernel
-
TP 的切分原则:
- 沿着"可并行"的维度切分
- Attention 按 head 切分
- 不切分 batch_size, seq_len, head_dim
下一步
- 学习
naive_manager.py:BaseCacheManager 的简单实现(无缓存管理) - 学习
radix_manager.py:BaseCacheManager 的高级实现(Radix Tree 前缀共享)
12. NaiveCacheManager:极简实现
12.1 核心设计思想
NaiveCacheManager 是 BaseCacheManager 的极简实现,不做任何缓存管理:
1 | class NaiveCacheManager(BaseCacheManager): |
设计哲学:
- ❌ 不匹配前缀(总是返回空)
- ❌ 不锁定 handle(什么都不做)
- ❌ 不插入前缀(直接返回全部长度)
- ❌ 不支持驱逐(抛出异常)
- ❌ 不检查完整性(什么都不做)
12.2 逐个方法分析
12.2.1 match_prefix:总是返回空
1 | def match_prefix(self, input_ids: torch.Tensor) -> Tuple[NaiveCacheHandle, torch.Tensor]: |
含义:
- 总是返回
cached_len = 0(没有匹配到任何前缀) - 返回空的
indices(没有可复用的缓存)
效果:每个请求都需要重新计算,无法复用缓存。
性能影响:
1 | # 场景:两个请求有相同的前缀 |
12.2.2 lock_handle:什么都不做
1 | def lock_handle(self, handle: BaseCacheHandle, unlock: bool = False) -> None: |
为什么可以这样?
- 因为 NaiveCacheManager 不支持驱逐
- 没有驱逐,就不需要锁定
12.2.3 insert_prefix:返回全部长度
1 | def insert_prefix(self, input_ids: torch.Tensor, indices: torch.Tensor) -> int: |
含义:
- 检查
indices和input_ids长度相同 - 返回
len(indices)(表示所有 token 都已在缓存中)
等等,这不对吧?
是的,这是一个反直觉的设计。insert_prefix 返回的是"已经在缓存中的长度",返回 len(indices) 意味着:
- “这些 token 都已经在缓存中了,不需要再插入”
但这是一个谎言!因为 NaiveCacheManager 根本没有缓存任何东西。
为什么要撒这个谎?
因为 NaiveCacheManager 的设计假设:
- 调用者已经通过
store_kv存储了 KV 到物理位置 insert_prefix只是"通知"缓存管理器记录这个前缀- 但 NaiveCacheManager 不需要记录任何信息(因为不做缓存管理)
- 所以直接返回
len(indices),告诉调用者"我已经知道了,不用再存储了"
调用者的行为:
1 | already_cached = insert_prefix(input_ids, indices) # 返回 len(indices) |
生命周期:
1 | # 1. 分配 page |
12.2.4 evict:不支持驱逐
1 | def evict(self, size: int) -> torch.Tensor: |
为什么不支持?
根本原因:它不记录任何缓存信息
1 | # 如果强行实现 evict 会怎样? |
NaiveCacheManager 的模式:
- 分配 page → 使用 → 立即释放
- 不保留任何缓存
- 所以不需要驱逐
12.2.5 size_info:总是返回 0
1 |
|
含义:没有任何缓存。
12.2.6 check_integrity:什么都不做
1 | def check_integrity(self) -> None: |
为什么?
- 没有数据结构可以检查
12.3 empty_tensor 的作用
1 | def __init__(self, device): |
为什么要预先创建?
原因1:避免重复创建
1 | # ❌ 不好的实现 |
原因2:保证在正确的设备上
1 | # 如果不指定 device,可能在 CPU 上 |
原因3:类型一致性
1 | # 确保返回的 indices 类型是 int32 |
12.4 适用场景
✅ 适合的场景
-
基准测试:
- 对比缓存管理的性能提升
- 提供 baseline
-
调试:
- 简化系统,排除缓存管理的影响
- 快速定位问题
-
单次请求:
- 每个请求都是独立的
- 不需要复用前缀
-
内存充足:
- 不需要驱逐缓存
- 每个请求都有足够的 page
❌ 不适合的场景
-
多轮对话:
- 需要复用 System Prompt
- 需要复用历史对话
-
批量请求:
- 多个请求有相同的前缀
- 需要前缀共享
-
内存不足:
- 需要驱逐缓存释放空间
- NaiveCacheManager 不支持驱逐
-
生产环境:
- 需要高性能
- 需要前缀复用
12.5 NaiveCacheManager vs RadixCacheManager
| 特性 | NaiveCacheManager | RadixCacheManager |
|---|---|---|
| 前缀匹配 | ❌ 总是返回空 | ✅ Radix Tree 匹配 |
| 前缀复用 | ❌ 无法复用 | ✅ 自动复用 |
| 驱逐策略 | ❌ 不支持 | ✅ LRU/FIFO |
| 内存管理 | ❌ 即用即销毁 | ✅ 长期保留 |
| 数据结构 | ❌ 无 | ✅ Radix Tree |
| 性能 | ❌ 低 | ✅ 高 |
| 适用场景 | 基准测试、调试 | 生产环境 |
费曼挑战(续)
挑战11:NaiveCacheManager 的设计思想
问题:用简单的话解释 NaiveCacheManager 的核心设计思想。
解答:
- 核心思想:不做任何缓存管理,提供极简实现
- 行为:所有方法都是空实现或返回默认值
- 效果:每个请求都需要重新计算,无法复用前缀
关键类比:
- 就像一个"假的"图书管理系统,不记录任何借阅信息
挑战12:match_prefix 返回空的影响
问题:解释 match_prefix 总是返回空对性能的影响。
解答:
- 影响:无法复用前缀,重复计算
- 场景:两个请求有相同的前缀 “Hello world”,都需要重新计算
- 对比:RadixCacheManager 可以复用前缀,只计算一次
关键场景:
1 | # Request 1: "Hello world how are you" |
挑战13:insert_prefix 的"谎言"
问题:解释 insert_prefix 为什么返回 len(indices),这是什么意思。
解答:
- 返回值含义:“所有 token 都已在缓存中”
- 为什么是谎言:NaiveCacheManager 根本没有缓存任何东西
- 为什么这样设计:因为不需要记录任何信息,直接告诉调用者"我已经知道了"
调用者行为:
1 | already_cached = insert_prefix(input_ids, indices) # 返回 len(indices) |
挑战14:为什么不支持 evict?
问题:解释为什么 NaiveCacheManager 不支持
解答:
- 根本原因:它不记录任何缓存信息
- 无法实现:不知道哪些 page 被使用了,不知道驱逐哪些
- 不需要驱逐:因为 page 在请求结束时立即释放,不保留任何缓存
关键理解:
- NaiveCacheManager 的模式是"即用即销毁"
挑战15:empty_tensor 的作用
问题:解释为什么要预先创建 empty_tensor。
解答:
- 原因1:避免重复创建(性能优化)
- 原因2:保证在正确的设备上(GPU)
- 原因3:类型一致性(int32)
关键代码:
1 | self.empty_tensor = torch.empty(0, dtype=torch.int32, device=device) |
学习总结
核心收获
-
两层架构:
- BaseKVCache(存储层)+ BaseCacheManager(管理层)
- 分离关注点,提高可扩展性
-
并发安全:
- lock/unlock 机制防止驱逐正在使用的缓存
- match → lock → insert → unlock 流程
-
前缀复用:
- match_prefix 找到可复用的部分
- insert_prefix 避免重复存储
-
驱逐策略:
- 驱逐粒度是"前缀",不是"page"
- evictable vs protected 区分
-
完整性检查:
- check_integrity 检测缓存损坏
- 及时发现引用计数、结构错误等问题
-
MHAKVCache 实现:
- 统一的 kv_buffer(一次分配,减少碎片)
- 支持 TP(按 KV 头切分)
- 支持两种布局(最终统一成 LayerFirst)
- page_size = 1(简化实现)
- store_kv 调用自定义 CUDA kernel
-
TP 的切分原则:
- 沿着"可并行"的维度切分
- Attention 按 head 切分
- 不切分 batch_size, seq_len, head_dim
-
NaiveCacheManager 实现:
- 极简实现,不做任何缓存管理
- 所有方法都是空实现或返回默认值
- 适合基准测试、调试、单次请求
- 不适合多轮对话、批量请求、生产环境
-
NaiveCacheManager 的关键理解:
- 不是"关闭缓存",而是"不管理缓存"
- 不会内存泄漏,但性能很差
- 提供 baseline 对比
13. RadixCacheManager:Radix Tree 前缀共享
13.1 什么是 Radix Tree?
Radix Tree(基数树) 是一种压缩的前缀树,用于高效存储和查找共享前缀的字符串。
类比:
- 想象一个文件系统的目录结构
- 共享的路径只存储一次
- 例如:
/home/user/doc1.txt和/home/user/doc2.txt共享/home/user/
在 KV Cache 中的应用:
- 多个请求可能有相同的前缀(例如 System Prompt)
- Radix Tree 可以让这些请求共享前缀的 KV Cache
- 节省内存,提高性能
例子:
1 | # Request 1: "Hello world" |
13.2 RadixTreeNode:树的节点
1 | class RadixTreeNode: |
关键字段:
-
children:子节点字典
- key = 第一个 token ID
- value = 子节点
-
ref_count:引用计数
- 表示有多少个请求正在使用这个节点
- ref_count > 0:受保护,不能驱逐
- ref_count = 0:可驱逐
-
timestamp:时间戳
- 用于 LRU 驱逐策略
- 最近访问的节点 timestamp 更大
-
_key 和 _value:
- _key:token 序列(例如
[Hello, world]) - _value:page indices(例如
[page_5, page_12])
- _key:token 序列(例如
13.3 核心方法详解
13.3.1 _walk:遍历树找到最长匹配
1 | def _walk(self, input_ids: torch.Tensor) -> Tuple[RadixTreeNode, int]: |
作用:从根节点开始,沿着树向下走,找到最长匹配的前缀。
例子:
1 | # 树结构: |
13.3.2 _split_at:节点分裂
1 | def _split_at(self, pos: int) -> RadixTreeNode: |
作用:“细胞分裂”,将一个节点分裂成两个节点。
例子:
1 | # 原节点: |
关键点:
- 新节点继承原节点的 ref_count(因为引用的是同一条路径)
- 原节点变成新节点的子节点
13.3.3 match_prefix:匹配前缀
1 | def match_prefix(self, input_ids: torch.Tensor) -> Tuple[RadixCacheHandle, torch.Tensor]: |
作用:找到最长匹配的前缀,返回 handle 和 indices。
为什么需要向上遍历收集 value?
因为整个路径都是匹配的,计算时需要所有信息:
1 | # 树结构: |
13.3.4 insert_prefix:插入新前缀
1 | def insert_prefix(self, input_ids: torch.Tensor, indices: torch.Tensor) -> int: |
作用:插入新的前缀到树中。
返回值:实际匹配的长度(和 NaiveCacheManager 的区别)。
例子:
1 | # 树结构: |
13.3.5 lock_handle:锁定/解锁节点
1 | def lock_handle(self, handle: BaseCacheHandle, unlock: bool = False): |
作用:锁定从当前节点到根节点的整条路径。
为什么要锁定整条路径?
因为使用当前节点需要从根到当前节点的所有信息:
1 | # 树结构: |
如果只锁定 Node3 会怎样?
1 | # 只锁定 Node3 |
13.3.6 evict:驱逐缓存
1 | def evict(self, size: int) -> torch.Tensor: |
作用:使用 LRU 策略驱逐最旧的叶子节点。
为什么使用最小堆?
- 按 timestamp 排序
- 驱逐最久没使用的节点(LRU)
为什么只驱逐叶子节点?
原因1:叶子节点没有依赖
1 | # 树结构: |
原因2:驱逐叶子节点后,父节点可能也变成叶子
1 | # 初始状态: |
13.4 完整示例
假设有以下场景:
1 | # Request 1: "Hello world" |
步骤1:插入 Request 1
1 | # 初始状态: |
步骤2:插入 Request 2(分裂)
1 | # 插入 "Hello there" |
步骤3:插入 Request 3
1 | # 插入 "Hello world how are you" |
步骤4:Request 1 和 2 结束
1 | # Request 1 unlock |
步骤5:驱逐 2 个 page
1 | # 可驱逐的叶子节点:Node3 (length=1) |
13.5 root 节点的特殊性
1 | def __init__(self, device): |
为什么 ref_count = 1?
防止被驱逐:
- root.ref_count = 1,永远不会被驱逐
- 保证树的根节点始终存在
- 作为所有路径的起点:
1 | root |
- 简化 lock_handle 逻辑:
1 | while not node.is_root(): # 遍历到 root 就停止 |
13.6 完全相同的请求
1 | # 树结构: |
为什么要更新 timestamp?
- 使用 LRU 驱逐策略
- 最近使用的节点 timestamp 更大
- 驱逐时优先驱逐 timestamp 最小的节点
- 更新 timestamp 可以防止常用的前缀被驱逐
费曼挑战(续)
挑战16:Radix Tree 的核心思想
问题:用简单的话解释 Radix Tree 的核心思想。
解答:
- 核心思想:压缩的前缀树,共享相同的前缀
- 类比:文件系统的目录结构,共享的路径只存储一次
- 效果:节省内存,提高性能
关键场景:
1 | # "Hello world" 和 "Hello there" 共享 "Hello" |
挑战17:ref_count 的作用
问题:解释 ref_count 的作用,以及为什么需要它。
解答:
- 作用:引用计数,表示有多少个请求正在使用这个节点
- ref_count > 0:受保护,不能驱逐
- ref_count = 0:可驱逐
- 为什么需要:防止正在使用的缓存被驱逐
挑战18:_walk 和 _split_at
问题:解释 _walk 方法做了什么,以及为什么需要 _split_at。
解答:
- _walk:从根节点开始,沿着树向下走,找到最长匹配的前缀
- _split_at:“细胞分裂”,将一个节点分裂成两个节点
- 为什么需要分裂:当只有部分匹配时,需要分裂节点来共享前缀
关键场景:
1 | # 原节点:[Hello, world] |
挑战19:lock_handle 锁定整条路径
问题:解释为什么 lock_handle 要锁定从当前节点到根节点的整条路径。
解答:
- 原因:使用当前节点需要从根到当前节点的所有信息
- 如果只锁定当前节点:父节点可能被驱逐,导致数据损坏
关键场景:
1 | # 使用 Node3 需要: |
挑战20:evict 只驱逐叶子节点
问题:解释为什么 evict 只驱逐叶子节点。
解答:
- 原因1:叶子节点没有依赖,驱逐不会破坏树结构
- 原因2:驱逐叶子节点后,父节点可能也变成叶子,可以继续驱逐
- 如果驱逐非叶子节点:子节点会失去父节点,树结构被破坏
关键代码:
1 | if parent.is_leaf() and parent.ref_count == 0: |


