SGLang HiCache 读路径:预取、回载和调度流程
沿着前缀匹配、存储预取和主机内存到 GPU 的回载过程,梳理 HiCache 如何确认并提交一次缓存命中。
HiCache 读路径回答的是:请求到来以后,SGLang 如何判断一段前缀能否复用,以及是否要把这段 KV 从主机内存或存储层拉回来。
这条链路不能简化成“存储层有键就算命中”。真正参与推理的是 GPU KV 槽位;存储层命中只说明数据可能可以先搬回主机内存,再进一步回载到 GPU。
读完这篇,应该能把一次远端命中拆成三个问题:L3 中是否有对应缓存页、主机内存是否已经填充、GPU 槽位是否真正提交。普通源码笔记容易停在 batch_get 返回成功;这里更关心调度器为什么不能为了远端缓存无限等待。
源码版本与范围
本文基于 SGLang v0.5.14 的 HiCache 读路径阅读,主要入口:
python/sglang/srt/mem_cache/unified_radix_cache.py
python/sglang/srt/mem_cache/hicache_storage.py
python/sglang/srt/mem_cache/storage/mooncake_store/mooncake_store.py
关键调用链:
前缀匹配
-> prefetch_from_storage(...)
-> HiCacheStorage.batch_exists / batch_exists_v2
-> 分配主机内存页
-> HiCacheStorage.batch_get_v1/v2
-> 存储后端填充主机 KV 缓存页
-> load_back(...)
-> GPU KV 槽位可复用
本文只分析读路径的状态和边界,不覆盖回写策略与注意力内核执行,也不提供远端命中收益的基准测试。
flow
读路径总览:从前缀匹配到 GPU 槽位提交
读路径不是一次简单查询,而是先判断前缀所在层级,再决定是否值得把远端缓存页拉回运行时。
- 1
前缀匹配
基数树
先找到已有前缀,并区分 GPU、主机内存和存储层候选。
node.valuenode.host_value哈希键 - 2
探测存储层
batch_exists
根据 page key 查询连续命中长度;混合缓存还要合并辅助内存池的结果。
页对齐v1 / v2可用前缀 - 3
预取到主机内存
batch_get
后端把命中的页写入主机 KV 池,而不是直接写入 GPU。
主机内存分配存储层 -> 主机内存ongoing_prefetch - 4
回载
提交设备槽位
调度器仍要检查配额、阈值和锁,成功后才能复用。
mem_quotaload_back_thresholdnode.value
读路径的三种命中
一次前缀匹配以后,HiCache 要区分三类命中:
GPU 命中:
node.value 存在,可直接用于注意力计算
主机内存命中:
node.host_value 存在,需要通过 H->D 回载
存储层命中:
L3 后端存在对应页,需要预取到主机内存
layer view
一次前缀匹配之后,命中位置决定后续成本
存储层命中只是最远的一层命中;真正跳过预填充之前,数据还要先回到主机内存,再回到 GPU。
GPU 命中
node.value 已存在
主机内存命中
node.host_value 已存在
存储层命中
L3 后端中存在对应页
这三种命中的成本完全不同。GPU 命中几乎只是本地索引复用;主机内存命中需要设备传输;存储层命中还要经过后端查询、远端数据搬运、主机缓冲区分配和后续回载。
prefetch_from_storage 做什么
prefetch_from_storage(...) 的目标是把 L3 中可能存在的前缀页提前读入主机内存池。
核心流程:
prefetch_from_storage(req_id, last_host_node, new_input_tokens, ...)
-> 把 new_input_tokens 对齐到页面边界
-> 检查 prefetch_threshold 和速率限制
-> 为 KV 分配主机内存索引
-> 为辅助内存池构造 PoolTransfer
-> cache_controller.prefetch(...)
-> HiCacheStorage.batch_get_v1/v2(...)
-> 记录 ongoing_prefetch
这里有几个边界很重要。
第一,预取键必须按页对齐。存储后端按页查询,不能对任意 token 偏移执行通用命中。
第二,预取需要主机内存空间。如果主机内存池不足,HiCache 会先尝试淘汰已有内容;如果仍然无法分配,就缩短预取长度或直接放弃。
第三,预取成功只表示数据进入了主机内存,并未进入 GPU,因此还不能直接供注意力内核使用。
batch_exists 决定可用前缀边界
存储后端通常先查询存在性,再决定能够预取多少页。
普通 KV 路径中:
batch_exists(keys) -> 连续存在的页数
混合缓存路径中:
batch_exists_v2(keys, pool_transfers) -> PoolTransferResult
v2 的返回结果更关键,因为它需要合并 KV 与辅助内存池的命中边界:
kv_hit_pages = 10
mamba_hit_pages = 8
swa_hit_pages = 9
final usable pages = 8
如果只看 KV 页是否存在,就可能把缺少辅助状态的前缀当成可复用,导致后续模型状态不完整。
外部存储到主机内存,不等于主机内存到 GPU
从存储层预取的结果,是主机内存池中出现一段 host_indices。之后如果调度器决定使用这段前缀,还需要执行 load_back(...)。
load_back(best_match_node, mem_quota, req) 的目标是把主机 KV 页搬回 GPU KV 池。
核心流程:
load_back(best_match_node)
-> inc_host_lock_ref(best_match_node)
-> 为 KV 构造 LOAD_BACK transfer
-> 为 side pools 构造 LOAD_BACK transfers
-> 检查 load_back_threshold 和 mem_quota
-> GPU 空间不够时 evict
-> cache_controller.load(...)
-> commit device_indices
-> 更新 evictable sets
-> 记录 ongoing_load_back
注意 load_back_threshold。如果要搬回 GPU 的 token 太少,HiCache 可能直接放弃,因为传输和调度成本不划算。
回载时为什么需要加锁
回载是一条异步路径。搬运期间同时存在几个风险:
- 主机缓冲区被回收。
- 基数树节点被拆分或删除。
- GPU 空间被别的请求抢占。
- 辅助内存池与 KV 池的状态提交不一致。
因此,回载期间会同时持有主机侧和 GPU 侧引用。成功后才把 device_indices 提交回节点;失败时则释放引用,不把节点标记为 GPU 可用。
这点很关键:只有设备索引提交以后,这段前缀才真正回到 L1。
调度器为什么不能无条件等待
远端缓存命中不一定值得等待。HiCache 读路径中有多种提前放弃条件:
- 预取的 token 数低于阈值
- 预取触发限流
- 主机内存池无法分配足够空间
- 回载 token 数低于阈值
- 回载量超过当前 GPU 内存配额
- 辅助内存池不满足命中策略
- 存储后端批量读取失败
这些条件说明 HiCache 是一套考虑性能成本的缓存,而不是强一致的远端内存抽象。它的目标是提高吞吐、减少重复预填充,但不能为了等待缓存而阻塞调度器。
Mooncake 后端的读取路径
当 L3 后端为 Mooncake 时,读取路径会进入:
MooncakeStore.batch_get_v1(keys, host_indices)
MooncakeStore.batch_get_v2(transfers)
v1 会根据每个 page key 生成 Mooncake 对象键,再从主机内存池取得目标缓冲区指针:
key_strs, buffer_ptrs, buffer_sizes =
_batch_preprocess(keys, host_indices)
然后调用:
batch_get_into(key_strs, buffer_ptrs, buffer_sizes)
batch_get_into_multi_buffers(key_strs, buffer_ptrs, buffer_sizes)
Mooncake Store 返回每个对象的读取结果,Mooncake 后端再据此判断每个缓存页是否读取成功:
MHA 页:
K 对象成功 && V 对象成功
MLA 页:
K 对象成功
这解释了为什么一个 page key 可能对应多个 Mooncake 对象。HiCache 只关心整个缓存页是否可用;Mooncake Store 看到的则是更细粒度的对象。
读路径完整链路
完整读链可以这样画:
请求 token
-> 基数树前缀匹配
-> GPU 命中可直接使用
-> 主机内存命中触发 load_back
-> 存储层候选命中触发 prefetch_from_storage
-> HiCacheStorage.batch_exists / batch_get
-> 填充主机内存池
-> load_back 到 GPU
-> 注意力内核使用设备 KV 槽位
call path
远端 KV 读取要分成存储层到主机内存、主机内存到 GPU 两段
如果只验证 batch_get 成功,只能说明 L3 已经填充主机内存;还不能说明这段前缀已经可以用于注意力计算。
- 1
调度器
HiCache
基数树前缀匹配
先判断 GPU 和主机内存中的已有前缀,再决定是否从存储层预取
- 2
HiCache
存储后端
batch_exists / batch_get
根据 page key 查询连续命中长度,并按缓存页汇总对象读取结果
- 3
存储后端
主机 KV 池
prefetch_from_storage
Mooncake 后端调用 batch_get_into,把数据写入主机缓存页
- 4
HiCache
GPU KV 池
load_back(best_match_node)
分配设备槽位并提交 node.value 后,注意力计算才能复用
所以,“远端 KV 缓存命中”至少要问三个问题:
- L3 后端中对应的缓存页是否存在?
- 数据是否已经读回主机内存池?
- 对应主机缓存页是否已经回载到 GPU?
只有第三个问题为真,推理计算才能真正跳过这段预填充。
这条路径能证明什么
evidence boundary
读路径能说明什么
这篇文章可以建立读路径的状态转换模型,但不能把源码路径直接外推为性能或生产正确性结论。
能够确认
- SGLang HiCache 读路径至少分为前缀匹配、存储探测、预取到主机内存和回载到 GPU 四步。
- 存储层命中只说明存在可以尝试拉回的页,不等于注意力计算已经能复用 GPU KV 槽位。
- batch_exists_v2 和辅助内存池会影响最终可用的前缀边界。
- 阈值、配额和锁都是调度边界,不是可以省略的接口细节。
不能单独证明
- 不证明远端 KV 在任意负载下一定降低延迟。
- 不证明某次 batch_get 成功后,后续请求一定会复用这段前缀。
- 不覆盖完整的分布式失效、租约过期、副本放置或传输重试语义。
- 不替代端到端日志、指标、追踪和基准测试验证。
结论:存储层命中只是候选结果
阅读 HiCache 读取路径时,最重要的不是问“存储后端有没有这个键”,而是问这段前缀是否已经穿过完整复用链路:
存储层中存在对应页
-> 数据进入主机内存池
-> 主机页映射回预期的逻辑页
-> load_back 通过阈值、配额和锁检查
-> GPU 槽位提交完成
这也是 batch_exists_v2、load_back_threshold 和页对齐都不能跳过的原因。它们不是接口细节,而是在保护调度器:远端缓存可以提高吞吐,但不能把请求拖入不可控的等待路径。