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 技术演化时间线
  1. GPU 与 CPU 分层缓存 #2693
  2. 高效 CPU–GPU 搬运 #7313
  3. 外部存储层 #7704
  4. 递归状态与辅助 buffer 存储 #21259
  5. Target 与 draft 状态恢复 #30393
  6. 分支 checkpoint 增量备份 #33639
  7. Host 内存改作传输缓冲 #34798

HiCache

设计与机制

把所有可复用前缀都留在 GPU 上,会挤占活跃请求所需的显存。因此,HiCache 把前缀的保存位置与模型的使用位置分开:GPU HBM 是 L1,CPU DRAM 是 L2,外部存储是 L3。Attention 仍由 GPU 执行,较低层保留原本会被丢弃的数据,cache controller 负责备份和加载。

缓存存在哪里
存储路径:集群
集群
推理节点 1
推理节点 2
↕ 集群网络 ↕

假设两次请求都以同一份文档开头,只是后面的问题不同。SGLang 可以复用文档对应的 KV,只计算新问题。HiRadixTree 负责匹配相同的前缀,并记录本地缓存的位置;HiCache 负责在需要时把下层缓存搬回 GPU。

HiCache 怎样复用前缀
新请求
同一份文档复用缓存
新问题需要计算

请求到来时的缓存

L1GPU 显存有文档缓存
L2CPU 内存有文档缓存
L3外部存储有文档缓存
接下来L1 → 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 恢复时,可以直接写入这一整块,减少开销。

KV 布局与搬运
GPU · L1 · layer-first内存排列 →模型层 00/3 页模型层 10/3 页模型层 20/3 页页 1模型层 0页 2模型层 0页 3模型层 0页 1模型层 1页 2模型层 1页 3模型层 1页 1模型层 2页 2模型层 2页 3模型层 2Host · L2 · page-first内存排列 →页 1页 2页 3页 1模型层 0模型层 1模型层 2页 2模型层 0模型层 1模型层 2页 3模型层 0模型层 1模型层 2L3 · 外部存储页 1模型层 0模型层 1模型层 2页 2模型层 0模型层 1模型层 2页 3模型层 0模型层 1模型层 2
L3 → Host:整页读取,页 1
IO step 0/12

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

L3 → Host
页 1整页页 2整页页 3整页
Host → GPU
层 03 页层 13 页层 23 页
GPU 计算
层 0层 1层 2
036912 单位

与计算重叠完成时刻: 10

L3 → Host
页 1整页页 2整页页 3整页
Host → GPU
层 03 页层 13 页层 23 页
GPU 计算
层 0层 1层 2
036912 单位
t = 0/12

缓存策略

缓存策略决定什么保存/等待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;存储读取很慢时,直接重算反而更早结束。预取争取在请求执行前读完,上述策略则处理届时还没读完的部分。如图所示为不同策略在重算成本不同时的成本差异:

预取策略
可复用前缀 X = 12 tokens轮到 B 时已就绪 Y = 4 tokens额外等待上限 Z = 2 单位
原已就绪等待中读回需要重算

Best-effort 最慢首 token t = 8

复用 4 · 重算 8
123456789101112
B 开始执行: t = 0
161
02468101214

wait-complete 最快首 token t = 6

复用 12 · 重算 0
123456789101112
B 开始执行: t = 4
411
02468101214

timeout 首 token t = 7

复用 8 · 重算 4
123456789101112
B 开始执行: t = 2
2131
02468101214
等待读取Host → GPU重算新问题计算
6 单位
重算便宜重算昂贵
Best-effort: 8 单位; wait-complete: 6 单位; timeout: 7 单位

备份

后续请求能命中 Host,前提是此前保存过副本。GPU 算好一份文档的 KV 后,HiCache 可以把它备份到 Host;启用 L3 后,还能进一步保存到存储。备份会在下层增加副本,GPU 上的原数据可以继续保留,直到后续被驱逐。因此,同一段前缀 KV 可以同时存在于 L1、L2、L3,但并非每段 KV 都始终保留三份。但如果每段缓存都备份,那些以后再也没人问的文档,也会消耗搬运带宽和缓存容量。

对于 GPU → Host 备份,HiCache 提供三种选择:

策略备份时机主要取舍
Write-throughKV 进入缓存后,主动备份到 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

全注意力 KV 池
文档 KV
没有文档缓存
递归状态池
独立快照只读完「文档」未保存
Host 已保存甲的 KV 和末尾快照

Host · L2

全注意力 KV 池
文档 KV问题甲 KV
已保存
递归状态池
独立快照读完「文档+问题甲」已保存
独立快照只读完「文档」未保存
t = 0/4

恢复测试采用 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 在搬完后是否继续保留副本。

Host 内存模式
1/5A:KV 就绪

缓存模式(默认)

实例 A实例 BGPU
P
空闲
Host
空闲
空闲
L3
空闲共享存储

Buffer-only(可选)

实例 A实例 BGPU
P
空闲
Host
空闲
空闲
L3
空闲共享存储
t = 0/4

选择这个模式,可以用更小的 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 和输入吞吐。

长上下文基准
仅 GPU
8.61
+ CPU L2
4.84
+ 3FS L3
1.83
+ 预先填充 L3
1.43
010
3FS L3 · 相对仅 GPU78.7%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。

多轮对话基准结果图示
DeepSeek-V4-Flash有效输入吞吐(tokens/s)
L19.4K
L1+L214.3K
L1+L2+L3145.5K
Cache hit rate 随对话轮数的变化
命中率 = 每轮命中的前缀 token 总数 ÷ 完整 prompt token 总数。曲线按原图采样点重绘,数值为读图近似值。0%25%50%75%100%0102030405055对话轮次(从 0 开始)L1+L2+L3L1+L2L1对话轮次(从 0 开始) 0 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈0%对话轮次(从 0 开始) 5 L1: ≈83% L1+L2: ≈83% L1+L2+L3: ≈83%对话轮次(从 0 开始) 10 L1: ≈91% L1+L2: ≈91% L1+L2+L3: ≈91%对话轮次(从 0 开始) 15 L1: ≈94% L1+L2: ≈94% L1+L2+L3: ≈94%对话轮次(从 0 开始) 20 L1: ≈0% L1+L2: ≈95% L1+L2+L3: ≈95%对话轮次(从 0 开始) 25 L1: ≈0% L1+L2: ≈96% L1+L2+L3: ≈96%对话轮次(从 0 开始) 30 L1: ≈0% L1+L2: ≈97% L1+L2+L3: ≈97%对话轮次(从 0 开始) 35 L1: ≈0% L1+L2: ≈97% L1+L2+L3: ≈97%对话轮次(从 0 开始) 40 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈98%对话轮次(从 0 开始) 45 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈98%对话轮次(从 0 开始) 50 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈98%对话轮次(从 0 开始) 55 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈98%
L3 配置收尾指标命中率~98%平均 TTFT< 9 s
Inkling-Small有效输入吞吐(tokens/s)
L115.5K
L1+L221.1K
L1+L2+L367.1K
Cache hit rate 随对话轮数的变化
命中率 = 每轮命中的前缀 token 总数 ÷ 完整 prompt token 总数。曲线按原图采样点重绘,数值为读图近似值。0%25%50%75%100%051015202529对话轮次(从 0 开始)L1+L2+L3L1+L2L1对话轮次(从 0 开始) 0 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈0%对话轮次(从 0 开始) 1 L1: ≈6% L1+L2: ≈51% L1+L2+L3: ≈51%对话轮次(从 0 开始) 2 L1: ≈0% L1+L2: ≈68% L1+L2+L3: ≈68%对话轮次(从 0 开始) 3 L1: ≈0% L1+L2: ≈76% L1+L2+L3: ≈76%对话轮次(从 0 开始) 4 L1: ≈0% L1+L2: ≈81% L1+L2+L3: ≈81%对话轮次(从 0 开始) 5 L1: ≈0% L1+L2: ≈84% L1+L2+L3: ≈84%对话轮次(从 0 开始) 6 L1: ≈0% L1+L2: ≈86% L1+L2+L3: ≈86%对话轮次(从 0 开始) 7 L1: ≈0% L1+L2: ≈88% L1+L2+L3: ≈88%对话轮次(从 0 开始) 8 L1: ≈0% L1+L2: ≈89% L1+L2+L3: ≈89%对话轮次(从 0 开始) 9 L1: ≈0% L1+L2: ≈90% L1+L2+L3: ≈90%对话轮次(从 0 开始) 10 L1: ≈0% L1+L2: ≈91% L1+L2+L3: ≈91%对话轮次(从 0 开始) 11 L1: ≈0% L1+L2: ≈92% L1+L2+L3: ≈92%对话轮次(从 0 开始) 12 L1: ≈0% L1+L2: ≈93% L1+L2+L3: ≈93%对话轮次(从 0 开始) 13 L1: ≈0% L1+L2: ≈93% L1+L2+L3: ≈93%对话轮次(从 0 开始) 14 L1: ≈0% L1+L2: ≈94% L1+L2+L3: ≈94%对话轮次(从 0 开始) 15 L1: ≈0% L1+L2: ≈94% L1+L2+L3: ≈94%对话轮次(从 0 开始) 16 L1: ≈0% L1+L2: ≈94% L1+L2+L3: ≈94%对话轮次(从 0 开始) 17 L1: ≈0% L1+L2: ≈95% L1+L2+L3: ≈95%对话轮次(从 0 开始) 18 L1: ≈0% L1+L2: ≈62% L1+L2+L3: ≈95%对话轮次(从 0 开始) 19 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈95%对话轮次(从 0 开始) 20 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈95%对话轮次(从 0 开始) 21 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 22 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 23 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 24 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 25 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 26 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈96%对话轮次(从 0 开始) 27 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈97%对话轮次(从 0 开始) 28 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈97%对话轮次(从 0 开始) 29 L1: ≈0% L1+L2: ≈0% L1+L2+L3: ≈97%
L3 配置收尾指标命中率96.8%平均 TTFT1.23 s

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 时间留给新增输入。

上手试试 · Try on SGLang 打开 SGLang 仓库,在真实推理引擎里跑一跑这套机制

延伸阅读

可视化教学由 Yichi Zhang 写作。