SGLang HiCache 读路径:预取、回载和调度流程

沿着前缀匹配、存储预取和主机内存到 GPU 的回载过程,梳理 HiCache 如何确认并提交一次缓存命中。

Code walkthroughsSGLang runtimeLLM servingKV cacheHiCacheSGLang

HiCache 读路径回答的是:请求到来以后,SGLang 如何判断一段前缀能否复用,以及是否要把这段 KV 从主机内存或存储层拉回来。

这条链路不能简化成“存储层有键就算命中”。真正参与推理的是 GPU KV 槽位;存储层命中只说明数据可能可以先搬回主机内存,再进一步回载到 GPU。

读完这篇,应该能把一次远端命中拆成三个问题:L3 中是否有对应缓存页、主机内存是否已经填充、GPU 槽位是否真正提交。普通源码笔记容易停在 batch_get 返回成功;这里更关心调度器为什么不能为了远端缓存无限等待。

源码版本与范围

本文基于 SGLang v0.5.14 的 HiCache 读路径阅读,主要入口:

text
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

关键调用链:

text
前缀匹配
  -> prefetch_from_storage(...)
  -> HiCacheStorage.batch_exists / batch_exists_v2
  -> 分配主机内存页
  -> HiCacheStorage.batch_get_v1/v2
  -> 存储后端填充主机 KV 缓存页
  -> load_back(...)
  -> GPU KV 槽位可复用

本文只分析读路径的状态和边界,不覆盖回写策略与注意力内核执行,也不提供远端命中收益的基准测试。

flow

读路径总览:从前缀匹配到 GPU 槽位提交

读路径不是一次简单查询,而是先判断前缀所在层级,再决定是否值得把远端缓存页拉回运行时。

  1. 1

    前缀匹配

    基数树

    先找到已有前缀,并区分 GPU、主机内存和存储层候选。

    node.valuenode.host_value哈希键
  2. 2

    探测存储层

    batch_exists

    根据 page key 查询连续命中长度;混合缓存还要合并辅助内存池的结果。

    页对齐v1 / v2可用前缀
  3. 3

    预取到主机内存

    batch_get

    后端把命中的页写入主机 KV 池,而不是直接写入 GPU。

    主机内存分配存储层 -> 主机内存ongoing_prefetch
  4. 4

    回载

    提交设备槽位

    调度器仍要检查配额、阈值和锁,成功后才能复用。

    mem_quotaload_back_thresholdnode.value

读路径的三种命中

一次前缀匹配以后,HiCache 要区分三类命中:

text
GPU 命中:
  node.value 存在,可直接用于注意力计算

主机内存命中:
  node.host_value 存在,需要通过 H->D 回载

存储层命中:
  L3 后端存在对应页,需要预取到主机内存

layer view

一次前缀匹配之后,命中位置决定后续成本

存储层命中只是最远的一层命中;真正跳过预填充之前,数据还要先回到主机内存,再回到 GPU。

GPU 命中

node.value 已存在

设备槽位就绪注意力计算可直接复用

主机内存命中

node.host_value 已存在

需要回载H->D 复制检查 mem_quota

存储层命中

L3 后端中存在对应页

batch_exists预取存储层 -> 主机内存页对齐

这三种命中的成本完全不同。GPU 命中几乎只是本地索引复用;主机内存命中需要设备传输;存储层命中还要经过后端查询、远端数据搬运、主机缓冲区分配和后续回载。

prefetch_from_storage 做什么

prefetch_from_storage(...) 的目标是把 L3 中可能存在的前缀页提前读入主机内存池。

核心流程:

text
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 路径中:

text
batch_exists(keys) -> 连续存在的页数

混合缓存路径中:

text
batch_exists_v2(keys, pool_transfers) -> PoolTransferResult

v2 的返回结果更关键,因为它需要合并 KV 与辅助内存池的命中边界:

text
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 池。

核心流程:

text
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 时,读取路径会进入:

text
MooncakeStore.batch_get_v1(keys, host_indices)
MooncakeStore.batch_get_v2(transfers)

v1 会根据每个 page key 生成 Mooncake 对象键,再从主机内存池取得目标缓冲区指针:

text
key_strs, buffer_ptrs, buffer_sizes =
  _batch_preprocess(keys, host_indices)

然后调用:

text
batch_get_into(key_strs, buffer_ptrs, buffer_sizes)
batch_get_into_multi_buffers(key_strs, buffer_ptrs, buffer_sizes)

Mooncake Store 返回每个对象的读取结果,Mooncake 后端再据此判断每个缓存页是否读取成功:

text
MHA 页:
  K 对象成功 && V 对象成功

MLA 页:
  K 对象成功

这解释了为什么一个 page key 可能对应多个 Mooncake 对象。HiCache 只关心整个缓存页是否可用;Mooncake Store 看到的则是更细粒度的对象。

读路径完整链路

完整读链可以这样画:

text
请求 token
  -> 基数树前缀匹配
  -> GPU 命中可直接使用
  -> 主机内存命中触发 load_back
  -> 存储层候选命中触发 prefetch_from_storage
  -> HiCacheStorage.batch_exists / batch_get
  -> 填充主机内存池
  -> load_back 到 GPU
  -> 注意力内核使用设备 KV 槽位

call path

远端 KV 读取要分成存储层到主机内存、主机内存到 GPU 两段

如果只验证 batch_get 成功,只能说明 L3 已经填充主机内存;还不能说明这段前缀已经可以用于注意力计算。

调度器HiCache存储后端主机 KV 池GPU KV 池
  1. 1

    调度器

    HiCache

    基数树前缀匹配

    先判断 GPU 和主机内存中的已有前缀,再决定是否从存储层预取

  2. 2

    HiCache

    存储后端

    batch_exists / batch_get

    根据 page key 查询连续命中长度,并按缓存页汇总对象读取结果

  3. 3

    存储后端

    主机 KV 池

    prefetch_from_storage

    Mooncake 后端调用 batch_get_into,把数据写入主机缓存页

  4. 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 读取路径时,最重要的不是问“存储后端有没有这个键”,而是问这段前缀是否已经穿过完整复用链路:

text
存储层中存在对应页
  -> 数据进入主机内存池
  -> 主机页映射回预期的逻辑页
  -> load_back 通过阈值、配额和锁检查
  -> GPU 槽位提交完成

这也是 batch_exists_v2load_back_threshold 和页对齐都不能跳过的原因。它们不是接口细节,而是在保护调度器:远端缓存可以提高吞吐,但不能把请求拖入不可控的等待路径。