从0训练LLM · 现代大模型体系 阅读 + 实验 ≈ 40 分钟 页内实验 ×4
C13 推理:生成为什么慢、怎么变快 第 2 课

成百上千个请求一起解码,KV cache 在显存里怎么放、怎么排?

服务端:连续批处理与 PagedAttention
batch 能把权重的读取摊薄,可请求长短不一、随到随走。凑满一批一起开始、一起结束,一批里短的写完了只能空转:用验证集 64 个真实段落当回复,槽位利用率只有 58.7%;每一步都让写完的离开、排队的补进来(连续批处理),升到 93.1%。KV 按最大长度连续预留,现有系统只有 20.4%–38.2% 的显存存着真 token;PagedAttention 把它切成 16 个 token 一块、按需发放,升到 96.3%。
❓

本课一问:第 1 课说,很多请求一起解码,权重读一遍就能给整批用。可真实的请求长短不一、随时到来、随时结束,每个都带着自己的 KV cache。服务端到底怎么把它们凑成一批?KV 在显存里怎么放?

这一课先不碰:怎么让每个 token 的 KV 本身变小(GQA、MLA 在 C3 第 9、10 课,线性注意力在 C11 第 4 课);怎么让单个用户更快(第 3 课);快到底怎么量(第 5 课)。

从已有出发

从已有出发 · 一次请求在服务端走过的路 点任一格看锚点 Qwen3.8-27B 上的真实量
decode 循环:每个 token 走一圈
旁路:

贯穿例句的那次请求(「小明把苹果给了」→「小王」)在线上从来不是一个人在跑。第 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 个)。一个格子是「一个槽位 × 一步」。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案静态批处理:凑满 B 个一起开始、一起结束
同一批请求,两种调度:槽位 × 解码步
在生成空转(这一批里别人还没完)当前步

改进一:迭代级调度(连续批处理)

诀窍是把调度的单位从「一个请求」换成「一次前向(一步)」:每一步结束,检查哪些请求写完了,让它们离开;排队的请求立刻补进空出来的槽位。Orca 把这叫做迭代级调度 。上面的步数与利用率是调度算法的计数 :请求长度差得越多,静态批处理浪费得越多。

算法模拟器 · 两种调度的泳道(真实段落长度,浏览器真跑调度)
同一批请求,两种调度:槽位 × 解码步
在生成空转(这一批里别人还没完)当前步

🎯

试三件事:① 切到「静态批处理」点「播放」,盯住每批快结束时的样子:大多数槽位已经灰了,只剩一两个长请求在跑;② 切到「连续批处理」再播放一遍:灰色几乎只在最后收尾时出现;③ 把 B 从 8 拖到 16:静态批处理的利用率从 58.7% 掉到 49.4%(一批越大,越可能碰上一个特别长的),连续批处理还有 78.6%——它最后收尾的那段空转变长了,但请求再多一些这段就会被摊薄。

新的失败:KV 按最大长度预留

请求随到随走,KV 的地方怎么给?最直接的办法是来一个请求,就在显存里划一整段连续的地方,大小按它可能写到的最大长度算——因为事先不知道它会写多长。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案每个请求按最大长度,连续预留一整段 KV

按最大长度预留有两种浪费:

  • 内部碎片:预留了没写到的部分,别人也用不了。
  • 外部碎片:请求结束后留下的空洞夹在别人中间,拼不成一整段,新来的长请求放不进去——哪怕空闲的总量够。

vLLM 论文图 2 在 OPT-13B、ShareGPT 请求上测了三种按最大长度预留的系统(Orca 的三种变体),真 token 只占 KV 显存的 20.4%–38.2% 。

改进二:PagedAttention

名字来自操作系统的虚拟内存分页:块 ≈ 页,token ≈ 字节,请求 ≈ 进程,块表 ≈ 页表。这个类比只作入口——映射:物理块是显存里一段固定大小的 KV,逻辑块是「这个请求的第几段上下文」,块表把前者串成后者;失效点:操作系统的页可以换出到磁盘、按需换入,而注意力每一步都要读完这个请求的全部块,不存在「只有一部分在用」;vLLM 在显存不够时也会把整个请求的块换出或丢掉重算,但那是按请求整体处理的。

分页之后,一个请求最多浪费多少
= × b ⇒ 浪费 = ≤
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
锚点里,一块 = 16 个 token × 每 token 65,536 B = 1 MiB(16 个全注意力层合计)。GDN 的固定状态每个请求一份、大小不变,不需要分页。

下面是旧版课程的分页演示,升级后迁过来:同一批请求、同一块显存,上面连续预分配,下面分页,并加上了每个请求的块表。

模拟器 · 连续预分配 vs 分页(玩具规模,利用率真算)
同一批请求、同一块显存:连续预分配 vs 分页

🎯

试三件事:① 什么都别点,先看方案 A 的利用率:斜纹全是「万一它写到最大长度呢」的预留。这个百分比是这组玩具长度算出来的,不要拿它去对论文的 20.4%–38.2%——换一组开局长度它就变。② 连点几下「生成一个 token」,看斜纹一格格被吃掉;再看方案 B 的块表:新块的物理编号东一个西一个,逻辑上仍然连续。③ 点「请求 2 完成」再点「新请求 4 进来」:A 有 16 块空闲、却放不下要 12 块的请求(凑不出连续的一段),B 直接把空洞捡回来用——这就是外部碎片。

注意力的计算要跟着块表去取 K、V:下面是锚点一个全注意力层里,一个请求怎么从共用的 K 池里按块取出自己的 K(V 完全一样)。块表是索引,取出来的才是值:

按块表取 K:物理块池 → 块表索引 → 这个请求的 K
Kr = gather(Kpool, tabler)
输入输出 编号槽 = 索引
边长按对数比例缩放,块边上的数字是真实值;同一维度符号的边在全图等长。
维度含义 真实规模 小规模
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
池的块数由 anchor-model.json 与 H100 的 80 GB 现算;真实实现里每层各有一个这样的池,块编号在各层之间共用。

分页还有一个副产品:几条序列可以共享同一组块。一个请求要 3 条不同的回复时,prompt 的 KV 只需存一份,三条回复的块表都指向它;某条回复要往一个共享块里写新 token 时,才把那一块复制一份(写时复制)。

模拟器 · 并行采样 3 条回复的共享块(玩具规模,块数真算)
并行采样 3 条回复:共享 prompt 的 KV 块

回复越长,共享的 prompt 占比越小,省下的越少。vLLM 论文在 Alpaca 上测得并行采样省 6.1%–9.8%、beam search 省 37.6%–55.2% 的块(§6.3) 。vLLM 官方博客里「最多省 55% 显存、吞吐最多 2.2 倍」说的就是这类共享——不是分页对普通单条请求的收益。

🔍 前缀缓存:不同请求之间也能共享

很多请求开头一样(同一段系统提示、同一份长文档、多轮对话的历史)。把已经算过的前缀 KV 留着,下一个请求命中时直接复用、只 prefill 新增的部分,这就是前缀缓存。SGLang 用基数树按前缀组织这些块、按 LRU 淘汰(LMSYS 博客,2024-01)。混合架构在这里多一道坎:Kimi K3 报告写道,KDA 层每个请求只有一整块会原地更新的状态,只能在稀疏的边界上存快照,于是物理块被迫做到 1,024–6,144 个 token,另用 512 个 token 的哈希粒度来找命中 ;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)。分页之后才真正「有空就能进」。

在演进地图上的位置

推理加速的演进:每一代都被上一代的某个真数字逼出来(点名字去那一课;完整版在章首页)
  1. 每一步把整段重新喂进模型
  2. KV cache
  3. 批处理:一次解码多个请求
  4. 连续批处理(迭代级调度)
  5. PagedAttention:KV 分块按需分配
  6. 投机解码:草稿猜、目标一次验
  7. 让模型自己当草稿:MTP 与专用草稿器
  8. 量化:每个权重少读几位
  9. 按 TTFT / TPOT / goodput 调度:分块 prefill、P/D 分离
  10. 采样:温度 → top-k → top-p → min-p
  11. 少存 KV:GQA / MLA / 线性注意力
第四代PagedAttention:KV 分块按需分配vLLM,SOSP 2023你在这里
解决了16 token 一块、块表映射:KV 显存利用率 96.3%,同等延迟吞吐 2–4 倍(vLLM 自报);锚点 4K 上下文时一张 H100 能放 58 个请求。
卡在单个用户仍是每个 token 一次完整前向:batch 1 时 99% 以上的算力闲着。
数字口径:锚点 Qwen3.8-27B 的字节与时间是 H100 SXM 上的屋顶线上界(不是实测),由 anchor-model.json 现算;调度、投机、量化、采样的数字来自我们项目的 3 个小模型(章内 precomputed.json,脚本 gen_c13_precomputed.py,2026-09-26);vLLM、DeepSeek-V3 的数字出自本地 PDF,Orca、DistServe 出自论文原文,均为提出方自报。

证据三问

问本课的回答
① 它解决什么问题?连续批处理:短请求写完就走,槽位不空转;PagedAttention:KV 不再按最大长度预留,同一块显存能放下多几倍的请求
② 数学上为什么?分页每个请求最多浪费 b − 1 个 token、没有外部碎片,是计数 ;调度的步数与利用率在真实长度上真跑
③ 实验上呢?vLLM:KV 利用率 20.4%–38.2% → 96.3%,同等延迟吞吐 2–4 倍;Orca:175B 上 36.9 倍

一句话

📌

连续批处理让请求随到随走,PagedAttention 把 KV 切成小块按需发放;两者合起来,同一张卡能同时服务的请求多了几倍。

🚦

先停在这里:记住「按步调度、按块分配」,就可以去第 3 课。
再深一层:读 vLLM 论文 §4(块表与写时复制)和 Aleksa Gordić 的《Inside vLLM》,看这两件事在一个真实引擎里怎么拼到一起。

下一个缺口

这一课让整机吞吐上去了:同一张卡能同时服务几十个人。可每个用户那边,两个 token 之间照样隔着「把 51.25 GB 权重读一遍」那么久(batch 越大还越久);只有一个人在用时,99% 以上的算力还闲着。能不能拿这些闲着的算力,让单个用户也快起来,而且一个字都不改? → 第 3 课:一次前向验好几个 token,凭什么输出分布一点都不变?

你可能会问

这一课还没有收到真实提问;你的提问会记到 05-问答记录/C13-L2.md。