成百上千个请求一起解码,KV cache 在显存里怎么放、怎么排?
本课一问:第 1 课说,很多请求一起解码,权重读一遍就能给整批用。可真实的请求长短不一、随时到来、随时结束,每个都带着自己的 KV cache。服务端到底怎么把它们凑成一批?KV 在显存里怎么放?
这一课先不碰:怎么让每个 token 的 KV 本身变小(GQA、MLA 在 C3 第 9、10 课,线性注意力在 C11 第 4 课);怎么让单个用户更快(第 3 课);快到底怎么量(第 5 课)。
从已有出发
贯穿例句的那次请求(「小明把苹果给了」→「小王」)在线上从来不是一个人在跑。第 1 课的账:锚点在 H100 上 batch 1 每秒约 65 个 token;同一步里 58 个请求一起解码,总吞吐约 2,300 个——权重读一遍,58 个请求都用上了。
可「58」是有条件的:80 GB 减去全部权重,只剩 24.44 GB 放 KV 与状态;每个请求上下文 4,096 时要 419 MB,才放得下 58 个。怎么把请求凑成一批、怎么在这 24.4 GB 里给它们分地方,就是服务端引擎(vLLM、SGLang 这些)要做的事。
先自己设计:凑满一批,一起开始、一起结束
最直接的做法和训练一样:排队的请求每 B 个凑成一批,一起 prefill、一起逐 token 生成,整批都写完了,再换下一批。
为了用真实的长短,这里把《三國志演義》验证集按原文顺序的前 64 个段落当成 64 条要生成的回复,长度就是它们用项目词表切出的 token 数(22–356 个,共 9,939 个)。一个格子是「一个槽位 × 一步」。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
同样 64 个请求、同样 8 个槽位:1,335 步、利用率 93.1%,平均完成的时间从第 1,099 步提前到第 704 步。
改进一:迭代级调度(连续批处理)
诀窍是把调度的单位从「一个请求」换成「一次前向(一步)」:每一步结束,检查哪些请求写完了,让它们离开;排队的请求立刻补进空出来的槽位。Orca 把这叫做迭代级调度 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条论文在 GPT-3 175B 上报告:同样的中位归一化延迟 190 ms 下,Orca 每秒 6.81 个请求,FasterTransformer 0.185 个,约 36.9 倍。提出方自报。 出处Yu et al., OSDI 2022(需联网) ↗需联网 。上面的步数与利用率是调度算法的计数 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条两种调度都按到达顺序在真实长度上真跑,数的是解码步;不含 prefill 与每步耗时的差别。 出处待补 :请求长度差得越多,静态批处理浪费得越多。
试三件事:① 切到「静态批处理」点「播放」,盯住每批快结束时的样子:大多数槽位已经灰了,只剩一两个长请求在跑;② 切到「连续批处理」再播放一遍:灰色几乎只在最后收尾时出现;③ 把 B 从 8 拖到 16:静态批处理的利用率从 58.7% 掉到 49.4%(一批越大,越可能碰上一个特别长的),连续批处理还有 78.6%——它最后收尾的那段空转变长了,但请求再多一些这段就会被摊薄。
新的失败:KV 按最大长度预留
请求随到随走,KV 的地方怎么给?最直接的办法是来一个请求,就在显存里划一整段连续的地方,大小按它可能写到的最大长度算——因为事先不知道它会写多长。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
按最大长度预留有两种浪费:
- 内部碎片:预留了没写到的部分,别人也用不了。
- 外部碎片:请求结束后留下的空洞夹在别人中间,拼不成一整段,新来的长请求放不进去——哪怕空闲的总量够。
vLLM 论文图 2 在 OPT-13B、ShareGPT 请求上测了三种按最大长度预留的系统(Orca 的三种变体),真 token 只占 KV 显存的 20.4%–38.2% 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条提出方的测量;具体比例取决于请求长度分布与预留策略。 出处Kwon et al. 2023 §1、图 2(本地 PDF) ↗ 。
把 KV 切成固定大小的块(vLLM 默认每块 16 个 token),请求写满一块才再要一块;块不必连续,每个请求有一张块表,记下「第几个逻辑块在显存的哪个物理块」。于是内部碎片最多是最后那个没写满的块,外部碎片消失(所有块一样大,哪个空都能用)。同样的测量里 vLLM 的 KV 利用率是 96.3%。按实际长度分页,4,096 个 token 的请求只要 256 块,同一张卡能放 58 个。
改进二:PagedAttention
名字来自操作系统的虚拟内存分页:块 ≈ 页,token ≈ 字节,请求 ≈ 进程,块表 ≈ 页表。这个类比只作入口——映射:物理块是显存里一段固定大小的 KV,逻辑块是「这个请求的第几段上下文」,块表把前者串成后者;失效点:操作系统的页可以换出到磁盘、按需换入,而注意力每一步都要读完这个请求的全部块,不存在「只有一部分在用」;vLLM 在显存不够时也会把整个请求的块换出或丢掉重算,但那是按请求整体处理的。
下面是旧版课程的分页演示,升级后迁过来:同一批请求、同一块显存,上面连续预分配,下面分页,并加上了每个请求的块表。
试三件事:① 什么都别点,先看方案 A 的利用率:斜纹全是「万一它写到最大长度呢」的预留。这个百分比是这组玩具长度算出来的,不要拿它去对论文的 20.4%–38.2%——换一组开局长度它就变。② 连点几下「生成一个 token」,看斜纹一格格被吃掉;再看方案 B 的块表:新块的物理编号东一个西一个,逻辑上仍然连续。③ 点「请求 2 完成」再点「新请求 4 进来」:A 有 16 块空闲、却放不下要 12 块的请求(凑不出连续的一段),B 直接把空洞捡回来用——这就是外部碎片。
注意力的计算要跟着块表去取 K、V:下面是锚点一个全注意力层里,一个请求怎么从共用的 K 池里按块取出自己的 K(V 完全一样)。块表是索引,取出来的才是值:
| 维度 | 含义 | 真实规模 | 小规模 |
|---|---|---|---|
| Nblk | 池里的块数(H100 80 GB 减去 BF16 权重后,按每块 1 MiB 算) | 23,305 | 6 |
| b | 块大小(vLLM 默认 16 个 token) | 16 | 2 |
| D | 每个 token 在这一层的 K:KV 头数 × 每头维度 = 4 × 256 | 1024 | 4 |
| nb | 请求 r 用到的块数(4,096 个 token ÷ 16) | 256 | 3 |
共享:并行采样与 beam search
分页还有一个副产品:几条序列可以共享同一组块。一个请求要 3 条不同的回复时,prompt 的 KV 只需存一份,三条回复的块表都指向它;某条回复要往一个共享块里写新 token 时,才把那一块复制一份(写时复制)。
回复越长,共享的 prompt 占比越小,省下的越少。vLLM 论文在 Alpaca 上测得并行采样省 6.1%–9.8%、beam search 省 37.6%–55.2% 的块(§6.3) 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条ShareGPT 上是 16.2%–30.5% 与 44.3%–66.3%;取决于 prompt 与输出的长度比。 出处Kwon et al. 2023 §6.3(本地 PDF) ↗ 。vLLM 官方博客里「最多省 55% 显存、吞吐最多 2.2 倍」说的就是这类共享——不是分页对普通单条请求的收益。
🔍 前缀缓存:不同请求之间也能共享
很多请求开头一样(同一段系统提示、同一份长文档、多轮对话的历史)。把已经算过的前缀 KV 留着,下一个请求命中时直接复用、只 prefill 新增的部分,这就是前缀缓存。SGLang 用基数树按前缀组织这些块、按 LRU 淘汰(LMSYS 博客,2024-01)。混合架构在这里多一道坎:Kimi K3 报告写道,KDA 层每个请求只有一整块会原地更新的状态,只能在稀疏的边界上存快照,于是物理块被迫做到 1,024–6,144 个 token,另用 512 个 token 的哈希粒度来找命中 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条官方自报的工程方案。 出处Kimi K3 报告 §5.4(本地 PDF) ↗ ;DeepSeek-V4 则把压缩后的 KV 存到磁盘上复用前缀(报告 §3.5.2)。命中与否能让首 token 时间差几个数量级,第 5 课回来。
⚠ 常见误解
「PagedAttention 让注意力算得更快。」恰恰相反:按块表取 K、V 多了查表和分支,vLLM 的注意力内核比 FasterTransformer 慢 20%–26%(§7.1)。它赢在显存:省下的地方能放更大的 batch,吞吐才上去。
「vLLM 省 55% 显存、吞吐 2.2 倍,是分页的一般收益。」那是并行采样和 beam search 共享 KV 块的收益(博客原文如此),旧版课程写错了口径。普通单条请求的收益来自碎片减少:论文报告同等延迟下吞吐 2–4 倍。
「连续批处理是 vLLM 发明的。」迭代级调度出自 Orca(OSDI 2022);vLLM 的贡献是分页的 KV 管理,它的实验就是拿 Orca 当基线比的。
「显存还有空,新请求就一定进得来。」连续分配时不一定:空闲的总量够,却可能拼不成一整段(上面的请求 4)。分页之后才真正「有空就能进」。
在演进地图上的位置
- 每一步把整段重新喂进模型
- KV cache
- 批处理:一次解码多个请求
- 连续批处理(迭代级调度)
- PagedAttention:KV 分块按需分配
- 投机解码:草稿猜、目标一次验
- 让模型自己当草稿:MTP 与专用草稿器
- 量化:每个权重少读几位
- 按 TTFT / TPOT / goodput 调度:分块 prefill、P/D 分离
- 采样:温度 → top-k → top-p → min-p
- 少存 KV:GQA / MLA / 线性注意力
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 连续批处理:短请求写完就走,槽位不空转;PagedAttention:KV 不再按最大长度预留,同一块显存能放下多几倍的请求 |
| ② 数学上为什么? | 分页每个请求最多浪费 b − 1 个 token、没有外部碎片,是计数 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条所有块一样大,任何空闲块都能分给任何请求。 出处待补 ;调度的步数与利用率在真实长度上真跑 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条只数解码步。 出处待补 |
| ③ 实验上呢? | vLLM:KV 利用率 20.4%–38.2% → 96.3%,同等延迟吞吐 2–4 倍;Orca:175B 上 36.9 倍 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条都是提出方在自己的模型、请求分布与基线上测的;倍数强烈依赖基线怎么选。 出处vLLM 图 2 与 §6;Orca(OSDI 2022) ↗ |
一句话
连续批处理让请求随到随走,PagedAttention 把 KV 切成小块按需发放;两者合起来,同一张卡能同时服务的请求多了几倍。
先停在这里:记住「按步调度、按块分配」,就可以去第 3 课。
再深一层:读 vLLM 论文 §4(块表与写时复制)和 Aleksa Gordić 的《Inside vLLM》,看这两件事在一个真实引擎里怎么拼到一起。
下一个缺口
这一课让整机吞吐上去了:同一张卡能同时服务几十个人。可每个用户那边,两个 token 之间照样隔着「把 51.25 GB 权重读一遍」那么久(batch 越大还越久);只有一个人在用时,99% 以上的算力还闲着。能不能拿这些闲着的算力,让单个用户也快起来,而且一个字都不改? → 第 3 课:一次前向验好几个 token,凭什么输出分布一点都不变?
你可能会问
这一课还没有收到真实提问;你的提问会记到 05-问答记录/C13-L2.md。
- 「新请求进来要先 prefill,那一步会不会把正在解码的人卡住?」 会。一整段长 prompt 塞进同一步,所有人都得等它;第 5 课用分块 prefill 和 P/D 分离来解决。
- 「显存满了、新请求又来了怎么办?」 vLLM 会抢占:把某些请求的块换到 CPU 内存,或者干脆丢掉、之后重算(论文 §4.5)。这会拉高被抢占请求的延迟——又是第 5 课的指标问题。