SGLang Unified Memory:前缀保留量 1.9 倍,重复请求耗时降低 37.9%

发布 更新

SGLang 为了支持现代的混合注意力模型,既要保存每个 token 的 KV(用于full attention),也要保存循环状态(用于mamba attention)。如果给它们各分一块固定大小的显存,可能一边已经放满,另一边却还有空位,导致显存放不满。Unified Memory 则让两种attention共用缓存区,按需动态分配空间。本文讲清如何实现这样的效果,并基于此做了什么优化。在一项缓存压力实验中,驱逐优化让保留的前缀增至 1.9 倍,返回请求的总耗时降低 37.9%。

前置要求:请阅读 Unified Radix Cache。

背景

模型在推理过程中,会保存key和value,我们称之为KV,供接下来生成答案时使用。那么KV Cache就是存储KV的容器。而在现代的混合模型中,有不同类型的attention层,它们保存KV的方式也不同:

  • Full Attention层为每个 token 保存 key 和 value。 读入的 token 越多,KV 就越多。生成答案时,注意力会读取这些 KV,回看文档内容。
  • Mamba Attention层不断更新一块大小固定的状态。 这类attention的KV会不断覆写入一个固定的状态存储里。每读入一个新 token,固定的状态里的内容会变化,但这块状态不会变大。因此,只有在精确定位到同一个时刻的快照(checkpoint)的情况下,才能复用KV,否则就会因为覆写,导致状态无法复用。因此每个活跃请求都需要自己的工作状态。如果要把某一时刻的状态另存一份,供以后复用,这份快照(checkpoint)还需要另占一个状态槽位。

下图只看一个请求。上下文变长时,KV 不断累积,循环状态则在原来的空间里更新。混合模型继续生成时,两者都需要。

两种保存历史的方式一个请求 · 未另存 checkpoint · 尺寸不按比例
全注意力 KV4 份 token KV
循环状态
已更新到 token 4一份工作状态
大小不变

这就带来了容量分配问题。大量短请求需要很多状态槽位,但每个请求的 KV 很少;少量长请求需要的工作状态较少,KV 却很多。如果 SGLang 在启动时就给两类数据各划一块固定大小的显存,一边可能已经放满,另一边却还有空位,无法同时应对两种情况。

Unified Memory

共用空闲空间

SGLang 的 Unified Memory 让两类数据使用同一块 GPU 显存。循环状态从一端向中间增长,KV 从另一端向中间增长。中间的空闲空间,哪一边需要就分给哪一边。整块显存的大小不变,两类数据能用到的容量随需求调整。

假设总容量是 24 个单位,每个请求需要 2 单位循环状态和 1 单位 KV。如果固定给状态 8 单位、给 KV 16 单位,只能放下四个请求:状态池已经满了,KV 池却还有 12 单位空闲。改成共享池后,图中的七个请求总共只用 21 单位,可以全部放下。

显存池容量示意模型 · 总量 24 单位 · 状态占 2 单位/请求 · 不含前缀共享
固定分池固定边界: 8 / 16
状态容量: 8KV 容量: 16
0物理偏移24
放下的请求 4 / 7等待 3空闲单位 12状态池已满
共享池
0物理偏移24
放下的请求 7 / 7等待 0空闲单位 3所有请求均可放下

长上下文可能遇到相反的情况:KV 放不下了,状态池却还有空位。可以切到“长上下文”查看。共享池解决的是固定分界带来的容量浪费;如果两类数据加起来已经放满整个缓冲区,请求仍然需要等待,或者先驱逐一部分缓存。

这里的 Unified Memory,指的是同一张 GPU 上几类缓存共用容量,不涉及把数据搬到 CPU。HiCache负责另一件事:把缓存历史保存在 CPU 内存或外部存储里,需要时再加载回来。

把数据放成可以搬移的块

上一节的共享缓冲区是一整段连续的显存。分配器从两端往里分配。因此我们还需要做到:释放后的空间还回中间的空闲区;后面还会看到,它有时要挪动数据块来填补空洞。这些操作都有一个前提:分配器管理的每个块,在显存里都得连成一段。

KV 默认并不是这样存的。SGLang 按页分配 KV,一页存若干个 token;而每个 Full Attention 层都要为这些 token 各存一份 KV。默认每层有独立的缓冲区,于是同一页会按层拆成好几小块,分散在各层的缓冲区里。释放这一页,每层缓冲区里各留下一个小空洞。因此这些空洞都是分散在每一层的独立存储里。

所以 Unified Memory 改用按页存放的布局:同一页在所有层的 KV 紧挨着放,组成一段连续的空间,称为 page envelope。页里的数据完全不变,变的只是它们在显存里的位置。现在释放这一页,腾出的是一整段连续空间,可以还给共享空闲区;搬移这一页时,也只需复制这一整段,不用逐层去搬。

使用不同存放布局的代价则是:

  • kernel 必须能按跨步方式读取每一层的数据,所以 Unified Memory 目前只支持一部分 attention 后端,也要求 K 和 V 的 head_dim 相同。
  • 每次 forward 前,需要把虚拟编号换算成物理位置。

实测开销见文末“实验结果”。

下图用 3 层、2 页做对比,提供直观展示。

KV 页布局3 层 · 2 页 · 第 1 页加框
每层独立存放第 1 页:3 段分散的空间
在同一缓冲区里按页存放第 1 页:1 段连续的空间

循环状态也按块存放,一个块包含各个循环层所需的状态。KV 页和状态块保留各自的大小,但都从同一个缓冲区取空间。因此,释放一个较大的状态块,就可能放下好几个较小的 KV 页。

实时搬运整合已释放空间

在Unified Memory中,释放的memory block可能夹在两个仍在使用的块之间。图中,状态块 A、B、C 占据左端。释放 B 后,虽然出现了一个空洞,C 却把它和中间的共享空闲区隔开了,导致无法完全利用上已经释放的空间。因此,SGLang 把 C 搬到 B 原来的位置,C 腾出的空间就能接入共享空闲区,用来分配新的 KV 页。

C 搬走后,请求还得找得到它。SGLang 给 C 一个不变的虚拟编号,再用一张表记录它当前的位置。请求仍然引用 C,表里的记录从“C 在位置 8”改成“C 在位置 4”。这样,搬移数据时不用逐个修改所有引用它的请求。

回收内部空洞归一化尺寸 · 状态块 4 单位 · KV 页 1 单位
不变的引用请求引用 C
块编号 → 当前位置
A → 0B → 4C → 8
0物理偏移24
共享空闲区 6内部空洞 0
t = 0/3

这个过程叫作压实(compaction)。SGLang 可以先留下空洞,等某次分配需要这段空间时再压实。搬移必须等到安全的时候才能进行,如果 GPU 计算或网络传输仍在使用这块数据,就不能在操作中途把它搬走。

共享池初版引入了这套分配和位置映射机制。后续扩展让三类数据都能共用缓冲区:全注意力 KV、滑动窗口 KV 和循环状态。滑动窗口层只需要最近一段 token,其容量需求也可能与另外两类不同。

Unified Memory的显存驱逐策略

请求完成后,SGLang 可以保留文档的 KV,以及请求末尾的 checkpoint。另一个请求如果以完全相同的 token 前缀开头,就能复用这些历史,只计算新加的后缀。而显存有限的时候,驱逐这段缓存能立即腾出空间,但后续请求可能就得重新计算它。

共用显存以后,驱逐决策也要把所有能回收的容量算进去。假设一次新分配需要 6 单位 KV 空间。删掉一段缓存前缀,会释放 1 单位 KV 和 2 单位状态 checkpoint。删掉两段,再进行必要的压实,就已经有了(1 + 2)* 2 = 6 单位可用空间。如果只数释放了多少 KV,就只会算到 2 单位,进而继续删掉本来可以留下的前缀。

驱逐的停止时机示意模型 · 每段前缀:KV 1 + 状态 2 · 释放的空间均可回收
还需腾出的 KV 空间 6 单位
优化前 · 只数释放的 KV仍需驱逐
P1已缓存
P2已缓存
P3已缓存
P4已缓存
P5已缓存
P6已缓存
释放 KV 0+释放状态 0=可回收容量 0
计入容量 / 所需容量 0 / 6保留的前缀 6 / 6
优化后 · 计入共享容量仍需驱逐
P1已缓存
P2已缓存
P3已缓存
P4已缓存
P5已缓存
P6已缓存
释放 KV 0+释放状态 0=可回收容量 0
计入容量 / 所需容量 0 / 6保留的前缀 6 / 6
t = 0/6

SGLang 的驱逐优化会在每次驱逐后,检查共享分配器能否满足当前分配。能满足就停止。这个检查也会计入另一类数据腾出且可以回收的空间,因此能留下更多有用的缓存前缀。

实验结果

下面的实验先缓存 28 段不同的前缀,再发送一个无关的长请求制造显存压力,最后重新请求原来的前缀。前缀还在,就能省下重复处理输入的计算;前缀被驱逐了,就需要重算。

两组都已经启用 Unified Memory。 比较的是为新分配腾空间时,驱逐在什么时候停止。采用改进后的规则,更多前缀得以保留,28 个返回的请求也更早完成。

缓存驱逐Qwen3.5-4B · RTX 5090 32 GB · Triton · 两组均启用 Unified Memory
保留的前缀前缀保留量 1.9 倍
优化前
10 / 28
优化后
19 / 28
优化前优化后已驱逐
28 段前缀 × 约 390 tokens → 7,000-token 压力请求 → 28 次重放 · 耗时:6 次运行中位数 来源:sglang#33091

这个收益有前提:一次分配要足够大。实验中,7,000 token 的长请求一次申请全部 KV 空间;如果按 2,048 token 分四次申请,新旧规则的结果一样,都保留 17 段前缀。整体吞吐也没有稳定提升。

Unified Memory 本身也有开销:维护位置表、压实时复制数据块,以及上文说的按页布局代价。在 5 个模型上,token 间延迟中位数从快 0.81% 到慢 1.32% 不等。它的收益是更好地利用显存,而不是让计算更快。

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

延伸阅读

可视化教学由 Yichi Zhang 写作。