SGLang HiCache:支持混合模型,输入吞吐最高达 15.5 倍
SGLang 通过复用缓存前缀避免重复 prefill,但 GPU 显存无法保留所有不断增长的对话。HiCache 把缓存扩展到 CPU 内存和外部存储。要让新增容量产生收益,还需要高效搬运数据、权衡等待与重算,并完整恢复混合模型和投机解码所需的状态。本文从分层设计出发,逐项解释这些工程问题、对应实现与实际效果。在 DeepSeek-V4-Flash 多轮基准中,加入 Mooncake L3 后,有效输入吞吐达到仅 GPU 缓存的 15.5 倍,缓存命中率保持在约 98%。
背景
模型为读入的 token 计算 key 和 value,简称 KV。生成时,attention 通过这些 KV 读取前文。当另一个请求以相同的 token 前缀开头,SGLang 就能复用对应的 KV,只计算新增的后缀。这减少了 prefill 工作,也缩短了首 token 延迟,即 TTFT。
GPU 显存容量限制了这种复用。对话每一轮都在增长,更多客户端又带来更多历史。缓存满了以后,某段前缀可能刚被驱逐,下一个请求就需要它。SGLang 的 HiCache 用容量更大的存储层保留这些前缀。要让新增容量产生收益,恢复前缀必须比重新计算更划算,而且恢复的状态必须足以让模型从这个前缀继续执行。
HiCache 先接入 CPU 内存,再扩展到外部存储。后续工作把恢复范围扩展到混合模型和 draft 状态,并提出了让 host 内存只作传输缓冲的方案。时间线展示这些变化的背景,后文则沿着它们解决的工程问题展开。
HiCache
设计与机制
把所有可复用前缀都留在 GPU 上,会挤占活跃请求所需的显存。因此,HiCache 把前缀的保存位置与模型的使用位置分开:GPU HBM 是 L1,CPU DRAM 是 L2,外部存储是 L3。Attention 仍由 GPU 执行,较低层保留原本会被丢弃的数据,cache controller 负责备份和加载。
假设两次请求都以同一份文档开头,只是后面的问题不同。SGLang 可以复用文档对应的 KV,只计算新问题。HiRadixTree 负责匹配相同的前缀,并记录本地缓存的位置;HiCache 负责在需要时把下层缓存搬回 GPU。
请求到来时的缓存
L1 命中直接复用;L2 命中先搬回 L1;L3 命中先取到 L2,再搬回 L1。只有三层都找不到的部分才需要重新计算。HiCache 的收益就在这里:即使 GPU 清掉了某段缓存,只要下层还保留着,就有机会省掉重算。这要求两次请求的前缀 token 一致,仅仅意思相近不能复用。
工程问题纵览
分层设计提供了保留和恢复前缀的路径。要让这条路径真正有用,实现中还需要解决几类问题:
- 内存布局: GPU 计算默认按模型层排列数据,存储 I/O 则按照page排列数据,怎样让不同的排列顺序之间仍然高效搬运?
- 重叠搬运与计算操作: 哪些传输可以与 GPU 计算重叠,让GPU不空等?
- 缓存策略: 预取和备份都消耗时间、带宽与容量,什么时候值得等待,什么时候值得多存一份?
- 混合模型与 draft 状态: token 匹配到了,所需历史状态却可能不完整,哪些数据必须随前缀一起恢复?
- Host 内存: 每个实例都有私有 L2 池,L3 已经保留历史时,host 还需要长期保存多少数据?
- 存储后端: 不同系统的 I/O 实现各异,怎样共用 HiCache 的调度与恢复机制?
内存布局
这项优化解决 GPU 计算与存储 I/O 所需的 KV 排列顺序不同的问题。GPU 每次计算一个模型层,为了便于读取,默认会把一层的KV放一起。而L3外部存储则按页读写(如第一页包含token 1-4,第二页包含 5-8)
因此,如果在Host段仍然不改变排列方式的话,如图所示,在搬运过程中就会有多次小的搬运开销。相反,如果Host段使用按页排列,则可以一次搬运一整页到L3外部存储,减少了搬运次数与开销。
因此,HiCache 让 Host 按页排列,作为中转站。 HiCache 让 Host 在搬运时转换布局。如图所示。图中高亮第 1 页:Before 里,它分散在 Host 的多个区域;After 里,它集中成一块。Host → 存储备份时,可以直接读取这一整块;存储 → Host 恢复时,可以直接写入这一整块,减少开销。
GPU ↔ Host 这一段,SGLang自己研发的GPU 辅助的 I/O kernel 让传输吞吐最高达到标准 cudaMemcpyAsync 路径的 3 倍。Host ↔ 存储 这一段,按页排列让存储能读写连续的大块数据;zero-copy 让存储客户端直接访问 Host 池里的这些数据。按页排列与 zero-copy 配合,让 Host–存储吞吐最高达到 2 倍。
重叠搬运与计算操作
前文讲了搬运如何加速,这项优化则是把 Host → GPU 搬运缓存 KV 与 GPU 计算新 token 重叠起来。在搬运过程中,我们不想要GPU空等,理想状态是每搬运一点GPU就算一点。因此,需要 HiCache 在第一个模型层的 KV 到齐后就开始计算,同时继续搬运后续模型层的 KV。
以三层模型为例:先把文档在第 0 层的 KV 从 Host 搬到 GPU。GPU 随即对新问题执行第 0 层计算,并通过这份缓存 KV 读取文档内容。与此同时,传输流搬运文档在第 1、2 层的 KV。
Host到GPU能这样重叠,是因为Host搬回 GPU 时可以按模型层取数据。第 0 层所需的 KV 搬齐后,GPU 就能计算第 0 层,同时继续搬运后续层。每层只等自己的 KV,无需等所有层到齐。L2 命中可直接开始这一过程;L3 命中则先把所需 KV 读到 Host,因为Host无法直接从L3按模型层取数据,必须按照页整块取(上一段提到的内容)。
先加载全部层完成时刻: 12
与计算重叠完成时刻: 10
缓存策略
缓存策略决定什么保存/等待KV读取。这是一个很经典的tradeoff模型,如果重算更快就重算,反之亦然。多存一份,后续就多一次命中的机会,但现在要花带宽、占内存;多等一会显存读取,可能省去重算时间,但读取本身也会花时间,请求会更晚开始。
预取
假设 GPU 正在处理请求 A,此时请求 B 到来,B 所需的文档 KV 保存在 L3。HiCache 可以在 B 等待调度时,提前把它的 KV 读到 Host,让这段读取与 A 的计算同时进行。等轮到 B 时,就能把已经到达 Host 的 KV 搬上 GPU,省去临时读取存储的等待。
如果轮到 B 执行时,L3 还没读完,就由预取策略决定下一步。Best-effort 不再等,复用已经就绪的前缀,剩下的重算;wait-complete 继续等待读取完成;timeout 给读取额外时间,到达等待上限后,使用已经就绪的前缀开始执行。
继续等是否划算,要比较两件事:把缺失 KV 读回来要多久,把这部分重新算出来要多久。文档很长时,多等一小会儿可能省下大量 prefill;存储读取很慢时,直接重算反而更早结束。预取争取在请求执行前读完,上述策略则处理届时还没读完的部分。如图所示为不同策略在重算成本不同时的成本差异:
Best-effort 最慢首 token t = 8
wait-complete 最快首 token t = 6
timeout 首 token t = 7
备份
后续请求能命中 Host,前提是此前保存过副本。GPU 算好一份文档的 KV 后,HiCache 可以把它备份到 Host;启用 L3 后,还能进一步保存到存储。备份会在下层增加副本,GPU 上的原数据可以继续保留,直到后续被驱逐。因此,同一段前缀 KV 可以同时存在于 L1、L2、L3,但并非每段 KV 都始终保留三份。但如果每段缓存都备份,那些以后再也没人问的文档,也会消耗搬运带宽和缓存容量。
对于 GPU → Host 备份,HiCache 提供三种选择:
| 策略 | 备份时机 | 主要取舍 |
|---|---|---|
| Write-through | KV 进入缓存后,主动备份到 Host | 保留更多前缀,也产生更多备份流量 |
| Write-through-selective | 根据命中次数,挑选热点前缀备份到 Host | 减少给冷门前缀花的带宽 |
| Write-back | 等 KV 要从 GPU 驱逐时,再备份到 Host | 推迟复制,但驱逐时要多做一次备份 |
混合模型的状态
混合模型除了全注意力的 KV,还可能需要滑窗内的 KV、递归层的状态快照(checkpoint)。Unified Radix Cache 检查所需状态是否齐全,判断能从哪里继续计算。HiCache 负责把这些状态备份到 Host、L3,并在需要时恢复到 GPU。
补存递归状态快照
这个问题针对 linear attention、Mamba 等用递归状态保存历史的层。状态随读入的 token 不断更新,checkpoint 就是在某段前缀末尾保存的状态快照。全注意力层按 token 保留 KV;递归层因为会不断覆盖,所以保留更加麻烦。
假设请求甲是“文档+问题甲”,Host 已保存它的全注意力 KV,以及读完问题甲后的快照。请求乙是“同一文档+问题乙”。本例中,处理乙时在文档末尾生成了一份新快照,此时还没读入任何问题。甲的旧快照已经包含问题甲,不能代替这份文档快照。
Before:备份由新节点触发。 原来的插入流程在新增缓存节点时发起备份;执行时,节点只要已有 Host 全注意力 KV,就被视为“已备份”并跳过。因此,已有前缀后来生成的递归快照只能留在 GPU,缺少单独补存到 Host 的路径。
After:已有前缀新增快照,也触发补存。 SGLang 在 write-through 路径上检查当前前缀的递归快照:GPU 有而 Host 没有,就为它单独安排备份。只复制缺失的快照,已有 KV 和已有快照都保留原样。这样,即使新快照从 GPU 驱逐,也能从 Host 恢复,减少重算。
甲、乙各自结束后的快照也可以分别保存,用来继续各自的历史。这里补存的是更早的文档末尾快照:后续请求可以先复用同一份文档,再计算自己的问题。
GPU · L1
Host · L2
恢复测试采用 write-through 策略:先生成新分支的快照,再把它们从 GPU 驱逐,最后重新请求共享前缀。完整恢复 6.4K 共享前缀的请求从 1/10 增至 10/10,token 命中率从 15.74% 增至 64.00%。每个请求包含 6.4K 共享 token 和 3.6K 新 token,因此 64% 表示共享前缀全部恢复。详见分支备份测试。
使用投机解码时,还需要一起备份和恢复 draft 模型的历史状态。否则,即使 target 命中了前缀缓存,draft 也可能因历史缺失而降低候选 token 的接受率。
Buffer Only模式
前面介绍的是 Host 作为 L2 缓存的用法:把 KV 留在内存里,供后续请求直接复用。若已启用 L3,但每个实例的 Host 内存有限,可以选择 Buffer-only 模式,把长期保存缓存历史的任务交给 L3,Host 只保留搬运所需的临时数据。
图中,实例 A 备份前缀 P 时,先把 KV 从 GPU 搬到 Host,再写入 L3;写入完成后,就释放这批 Host 槽位供后续搬运使用。实例 B 恢复 P 时,先从 L3 读到自己的 Host 缓冲,再搬到 GPU,搬完后释放缓冲。GPU ↔ Host ↔ L3 的搬运路径保持不变,变化的是 Host 在搬完后是否继续保留副本。
缓存模式(默认)
Buffer-only(可选)
选择这个模式,可以用更小的 Host 池完成 GPU 与 L3 之间的搬运。代价是 GPU 未命中时,要复用缓存就需要从 L3 读取,少了直接命中 L2 的机会。Host 内存充足时,可以继续使用前面的缓存模式,保留可复用的 KV,减少存储读取。
整体效果
长输入和多轮对话的历史会超过 GPU 显存容量。下面的基准依次加入 CPU 内存和外部存储,观察保留更多历史之后,首 token 延迟和输入吞吐怎样变化。
长上下文 · 2025
原文的 LooGLE 测试验证较低层缓存能否减少长输入的重复 prefill。它在八张 H20-3e 上运行 DeepSeek-R1,比较仅 GPU 缓存、增加 CPU L2、增加 3FS L3,以及单独预先填充 L3 的情况。图中可以查看平均 TTFT 和输入吞吐。
普通 L3 的输入吞吐为 51,168 tokens/s,平均 TTFT 为 1.83 秒。如果在请求到来之前,就把可复用的 KV 填入 L3,更多请求可以直接加载缓存前缀。输入吞吐提高到 76,322 tokens/s,平均 TTFT 降至 1.43 秒。
混合模型多轮对话 · 2026
多轮对话提供了不断增长的工作集:每一轮都新增后续请求可能复用的历史。2026 年 8 月的测试比较 GPU L1、L1 加 CPU L2,以及再加 500 GiB Mooncake L3。DeepSeek-V4-Flash 使用 FULL 与 SWA,Inkling-Small 使用 FULL、SWA 与 MAMBA。
L1 最先丢失可复用历史,L2 推迟了容量上限出现的时刻。相比仅 L1,增加 L3 后,DeepSeek-V4-Flash 的有效输入吞吐从 9.4K 增至 145.5K tokens/s,缓存命中率保持在约 98%。Inkling-Small 从 15.5K 增至 67.1K,最终命中率为 96.8%。结果表明,当混合模型的对话历史超过较小缓存的容量时,较低层仍能保住复用机会。详见多轮基准。
输入吞吐统计服务每秒处理的所有输入 token,包括命中缓存的部分。复用一段长前缀,就能省去这段前缀的 KV 重算,把更多 GPU 时间留给新增输入。
延伸阅读
可视化教学由 Yichi Zhang 写作。