对应 Maps to:CMU 15-779(Fall 2025,Zhihao Jia),Lecture 16 · Wed 10/29 · LLMs: Serving (Batching, PagedAttention, RadixAttention)。 必读论文:W. Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention(vLLM,SOSP 2023);L. Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs(NeurIPS 2024)。 依据说明:本讲以原课 slides
15-LLM-serving-part1.pdf为主线:
- 自回归解码;预填充阶段与解码阶段;KV 缓存;
- “LLM 又慢又贵”的几个数字(GPT-3 175B 至少要 10 块 A100-40GB、生成 256 个 token 约 20 秒、每个请求的 KV 缓存 3 GB);
- 解码时间线、批处理、静态批处理的问题;连续批处理(Orca)及其逐步演示;
- KV 缓存动态增长;静态 KV 管理的浪费(内部 / 外部碎片,只有 20–40% 的 KV 显存存了 token);
- PagedAttention:KV 块、虚拟化的块表、按需分配、内存效率;
- RadixAttention:KV 缓存复用、基数树 + LRU;
- CPU 开销隐藏:重叠调度器、推迟停止条件检查。
slides 里 PagedAttention 部分来自 vLLM 作者的报告。重叠调度器是 SGLang 在 NeurIPS 论文之后(0.4 版)加入的工程优化,论文里没有;本讲按 slides 介绍。论文数字只做转述。
serving.py(迭代时间模型、请求流、两种 KV 管理、两种调度)、radix.py(基数树前缀缓存)和全部实验都是本讲义自己设计的;请求长度是合成的(ShareGPT 式 / Alpaca 式的形状),不是真实数据集。 配套代码 Code:code/lecture16/(5 个脚本 + 两个共享模块;只需 numpy;最长的约 30 秒) 预计阅读时间:3 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标
学完这一讲,你应该能够:
- 解释 KV 缓存为什么存在、它多大,以及预填充和解码在 roofline 上分别落在哪里。
- 说清楚静态批处理浪费在哪里,连续批处理(迭代级调度)怎样改进,并能复现 slides 的逐步演示。
- 列出连续预留 KV 显存的三种浪费(预留、内部碎片、外部碎片),解释 PagedAttention 的块表、按需分配、按块计算的注意力。
- 用引用计数和写时复制解释并行采样、束搜索怎样共享 KV 块。
- 说明 RadixAttention 的基数树、LRU 淘汰和”最长共享前缀优先”调度,以及它为什么在深度优先序下最优。
- 估算 CPU 调度开销对吞吐的影响,解释重叠调度器为什么要”多算一个 token”。
0.2 先修与本讲范围
| 默认你已经会 | 本讲只给直觉,后面再展开 |
|---|---|
| 第 5 讲:注意力、在线 softmax 的分块合并、Flash-Decoding | 推测解码(第 17 讲) |
| 第 1 讲:roofline、算术强度 | 预填充 / 解码分离部署、分块预填充 |
| 第 13 讲:显存账、碎片 | 量化 KV 缓存、MQA / GQA 对 KV 大小的影响(第 18、21 讲涉及) |
| 操作系统的虚拟内存、分页、写时复制(概念即可) | SGLang 的前端语言、压缩有限状态机(§7.2 简介) |
0.3 本讲地图
§1 自回归解码:预填充、解码、KV 缓存 ← 代码 01
§2 批处理:静态 vs 连续(迭代级调度) ← 代码 02
§3 PagedAttention:分页管理 KV 缓存 ← 代码 03
§4 RadixAttention:在请求之间共享前缀 ← 代码 04
§5 藏住 CPU 开销:重叠调度器 ← 代码 05
§6 把它们放在一起
§7 论文精读:vLLM;SGLang
§8 小结 §9 常见误区 §10 练习 §11 延伸阅读 §12 下一讲预告
0.4 关于 serving.py
模拟器按”迭代”推进:
- 每次迭代处理若干个新请求的预填充和若干个正在生成的请求的一步解码;
- 时间 = max(读权重 + 读 KV 的字节 / 显存带宽, 计算量 / 可达算力) + 1.5 ms 固定开销(A100-40GB:1.555 TB/s,FP16 峰值的 60%);
- 模型默认是 OPT-13B(40 层、hidden 5120,FP16 权重 26 GB),和 vLLM 论文的主要实验一样;
- 显存里除去权重和 10% 的工作空间,KV 缓存可以放 12,207 个 token。
请求是泊松到达的,长度服从截断的对数正态分布:ShareGPT 式(中位数约 170 / 220 token 的输入 / 输出)和 Alpaca 式(约 20 / 50 token),只是”形状相似”,不是真实数据。
1. 自回归解码:预填充、解码、KV 缓存 · Autoregressive decoding
1.1 两个阶段
slides 用”Accelerating LLM requires machine → learning systems optimizations [EOS]“举例:
- 预填充(prefill,第 0 次迭代):一次处理 prompt 里所有的 token,输出第一个新 token;
- 解码(decode,之后的每次迭代):只处理上一次生成的一个 token,但它的注意力要用到之前所有 token 的 K、V。
KV 缓存:之前每个 token 在每一层的 K、V 只取决于它前面的 token,算过一次就不会变,所以存起来,后面的迭代直接用(slides:“No need to recompute”)。代码 01 演示 1 用一个单层注意力模型逐 token 生成 32 个 token:用不用缓存生成的序列完全相同,计算量差 17.1 倍;不用缓存时总计算量随长度平方增长。
1.2 预填充是计算受限的,解码是访存受限的(代码 01 演示 2)
OPT-13B、A100-40GB、batch 1:
| 时间 | 吞吐 | 算术强度(FLOPs / 字节) | |
|---|---|---|---|
| 一次预填充 512 个 token | 73.2 ms | 6,996 token/s | 约 504 |
| 一次解码 1 个 token(上下文 512) | 18.5 ms | 54 token/s | 约 0.98 |
A100 的拐点(算力 / 带宽)约 120。预填充远在拐点之上(计算受限),解码远在拐点之下:每生成一个 token 都要把 26 GB 的权重从显存里读一遍,算力利用率不到 1%。逐个解码 512 个 token 要 9.47 秒,是预填充的 129 倍。
1.3 批处理:把读一次权重摊到更多 token 上(代码 01 演示 3)
解码时把很多请求放在一起算,读一次权重就能为每个请求各生成一个 token:
| batch | 每步 | 吞吐(token/s) | 读权重占每步的比例 |
|---|---|---|---|
| 1 | 18.5 ms | 54 | 98% |
| 4 | 19.3 ms | 207 | 94% |
| 16 | 22.5 ms | 710 | 79% |
| 23 | 24.4 ms | 942 | 73% |
吞吐几乎随批大小线性增长,每个 token 的延迟只增加一点。批大小的上限不是算力,而是 KV 缓存放不放得下:这块卡只放得下 23 个 512 token 的请求。KV 缓存管理的好坏直接决定吞吐——这是 §3 的出发点。
1.4 slides 的数字从哪里来(代码 01 演示 4)
- 每个 token 的 KV = 2(K 和 V)× 层数 × hidden × 2 字节:OPT-13B 0.82 MB(vLLM 论文:约 800 KB),GPT-3 175B 4.72 MB;
- GPT-3 175B 的 FP16 权重 350 GB,A100-40GB 光放权重就要 9 块,再给 KV 留空间 → slides 的”至少 10 块”;
- 一个请求 3 GB 的 KV ≈ 636 个 token 的上下文(上下文 2048 时是 9.7 GB);
- 10 块卡张量并行、batch 1,按显存带宽算每个 token 至少 24 ms,256 个 token 约 6 秒;slides 说约 20 秒——真实系统还有卡间通信、kernel 效率、调度开销,slides 的数字可能来自更早的系统。
结论相同:一次只服务一个请求,又慢又贵;而 slides 说”不能并行处理很多请求”的根源是 KV 缓存太大。
2. 批处理:静态 vs 连续 · Static vs continuous batching
2.1 静态批处理的问题
slides 列出静态(请求级)批处理的三个问题:
- 同一批的请求在不同的迭代结束;
- 先结束的请求留下空闲的 GPU 计算;
- 新请求不能马上开始,要等整批做完。
2.2 连续批处理(Orca)
连续批处理(continuous batching,Orca 叫”迭代级调度”)在每一次迭代而不是每一个请求的粒度上重新组批:
- 生成完的请求立刻离开;
- 新到的请求在下一次迭代就加入(先做它的预填充)。
代码 02 演示 1 复现了 slides 的逐步演示(最大批大小 3):
连续批处理:
t=0.018s 批:R1(1/5) R2(1/2) 新加入:R1 R2
t=0.036s 批:R1(2/5) R2(2/2) R3(1/4) 新加入:R3 ← R2 完成
t=0.055s 批:R1(3/5) R3(2/4) R4(1/3) 新加入:R4 ← R2 的位置给了 R4
…
静态批处理:
t=0.028s 批:R1(1/5) R2(1/2) R3(1/4) 新加入:R1 R2 R3
t=0.046s 批:R1(2/5) R2(2/2) R3(2/4)
t=0.065s 批:R1(3/5) R3(3/4) ← R2 的位置空着
…
t=0.119s 批:R4(1/3) R5(1/3) 新加入:R4 R5 ← 等 R1 做完才开始
2.3 有多大差别(代码 02 演示 2–3)
同样的显存(分页 KV)、400 个 ShareGPT 式请求:
| 请求率 | 调度 | 吞吐(token/s) | 归一化延迟(s / 输出 token) | 平均批大小 | 静态批里的空位 |
|---|---|---|---|---|---|
| 1/s | 静态 8 | 145 | 1.527 | 2.8 | 65% |
| 1/s | 连续 | 308 | 0.021 | 6.4 | — |
| 4/s | 静态 8 | 146 | 2.563 | 2.8 | 65% |
| 4/s | 连续 | 664 | 0.231 | 19.0 | — |
请求率高时连续批处理的吞吐是静态批的 4.5 倍:静态批里 65% 的位置被”已经生成完、在等别人”的请求占着。如果所有请求一样长,差距缩小到 2.0 倍(剩下的来自批大小:静态批固定为 8,连续批处理能开到显存允许的大小)。真实请求的输出长度差几十倍,这正是 Orca 提出迭代级调度的出发点。
(“归一化延迟”是 vLLM 论文的指标:每个请求的端到端延迟除以它的输出长度,再对请求取平均。)
3. PagedAttention:分页管理 KV 缓存 · PagedAttention
3.1 KV 缓存会动态增长
每次迭代每个请求的 KV 多一个 token,而最终长度事先不知道。之前的系统(FasterTransformer、Orca)为了让 KV 在显存里连续,按请求的最大可能长度预先分配一整段。slides 的图:请求 A 的 prompt “Artificial Intelligence is” 占 3 个槽位,后面跟着为输出预留的一长串 <resv>,再后面是没法用的空洞,然后才是请求 B。
3.2 三种浪费(代码 03 演示 1)
slides 和 vLLM 论文 §3.1:
- 预留(reservation):为将来的 token 预留、还没用到的槽位——最终会用上,但在整个请求期间都占着,挤掉了别的请求;
- 内部碎片(internal fragmentation):预留得比最终长度多的部分(只有请求结束才知道);
- 外部碎片(external fragmentation):分配器里零碎的空洞,凑不出一个请求要的连续空间。
代码在同一个请求流上统计了四种管理方式下 KV 显存的组成(时间平均):
| KV 管理 | 存 token | 预留未用 | 内部碎片 | 外部碎片 | 空闲 | 已占用里存 token 的比例 |
|---|---|---|---|---|---|---|
| Orca (Max):按 2048 预留 | 22.1% | 11.9% | 48.8% | 16.0% | 1.3% | 26.7% |
| Orca (Pow2):按 2 的幂预留 | 37.9% | 20.6% | 23.8% | 2.6% | 15.1% | 46.1% |
| Orca (Oracle):恰好预留 | 49.3% | 26.8% | 0 | 7.4% | 16.5% | 64.8% |
| vLLM:分页,块大小 16 | 50.3% | 0 | 0.8% | 0 | 48.9% | 98.5% |
(vLLM 论文图 2 的对应数字:20.4% / 26.8% / 38.2% / 96.3%;slides:“Only 20–40% of KV cache is utilized to store actual token states”。)
- 即使事先知道每个请求的确切长度(Oracle,现实中做不到),“预留”仍然很大:请求一开始就要占住它最终的全部长度。
- 分页只在需要时分配块,浪费只剩每个请求最后一个块里没填满的部分,没有外部碎片。
- 分页那一行”空闲”很多,说明同样的请求流用分页根本用不完显存——可以放更多的请求(§3.6)。
3.3 KV 块与块表(代码 03 演示 2)
PagedAttention 借用操作系统的虚拟内存(slides 的对照图:进程的页 ↔ 请求的 KV 块,物理内存 ↔ KV 缓存):
- KV 块:固定大小(例如 16 个 token)的一段连续显存,从左到右存 KV;
- 块表:每个请求一张,把逻辑块(序列里的第几段)映射到物理块(显存里的哪一块),并记录每块已经填了几个 token。块里连续,块和块之间不必连续;
- 按需分配:只有上一个块填满了才分配新的物理块。
代码复现了 slides 的例子(块大小 4):
- “Alan Turing is a computer scientist” 处理完,逻辑块 0 → 物理块 3(已填 4),逻辑块 1 → 物理块 2(已填 2);
- 生成 “and”、“mathematician” 时只往物理块 2 里填;
- 生成 “renowned” 时才分配物理块 0。
按块计算的注意力:解码时 kernel 按块表取出不连续的 KV 块,逐块计算注意力再合并(slides:“attention is associative and commutative”——第 5 讲在线 softmax 的合并规则)。代码把 45 个 token 放在 3 个不连续的物理块里,逐块计算的结果和先拼成连续的再算差 2.2e-16。代价是 kernel 要查块表、处理变长:vLLM 论文测得注意力 kernel 比 FasterTransformer 慢 20–26%,但整个模型只有注意力这一个算子受影响。
3.4 块大小(代码 03 演示 3)
- 块越大,每个请求最后一块的浪费(< 块大小)越多。ShareGPT 式在显存受限时,块 256 让利用率降到 82%、批变小、吞吐下降约 7%;Alpaca 式的短请求每个都浪费大半个块(利用率 37%),只是那个负载下显存不是瓶颈,吞吐没变。
- 块越小碎片越少,但代码没有计入小块的代价(块表更大、kernel 每次读的连续数据更少)。vLLM 论文的实验里块大小 16–128 时最好,默认 16。
另外,分页时只要 prompt 放得下就接纳请求,生成到后面显存不够就要抢占:vLLM 把一个请求的所有块一起换出到 CPU 内存,或者丢掉、以后重新计算(“全有或全无”,因为一个请求要它所有的 KV 才能继续)。代码只实现了重新计算;ShareGPT 式的高负载下抢占了几百次。论文比较了两者:块小时换出要做大量小的 PCIe 传输,重新计算更好;块大时换出更好;16–64 之间两者相当。
3.5 共享与写时复制(代码 03 演示 4)
分页还让共享变得自然:
- 多个逻辑块可以映射到同一个物理块,每个物理块有一个引用计数;
- 要往一个被共享(引用计数 > 1)的块里写时,先复制一份——操作系统的写时复制(copy-on-write)。
两个典型场景:
- 并行采样:同一个 prompt 生成几个不同的回答,prompt 的 KV 只存一份;
- 束搜索:候选束之间共享 prompt 和它们共同的祖先,被淘汰的束的块在引用计数归零时立刻释放。
| 并行采样(n = 2 / 4 / 6) | 束搜索(宽度 2 / 4 / 6) | |
|---|---|---|
| Alpaca 式(代码) | 3.7% / 5.5% / 6.1% | 42.9% / 64.2% / 71.4% |
| ShareGPT 式(代码) | 13.6% / 20.5% / 22.7% | 48.6% / 72.9% / 81.0% |
| 论文(Alpaca / ShareGPT) | 6.1–9.8% / 16.2–30.5% | 37.6–55.2% / 44.3–66.3% |
并行采样的数字和论文同一量级:prompt 越长、输出越短,省得越多。束搜索的数字取决于代码里人为设定的”父束选择”过程(以 0.6 的概率继承最好的束),只看趋势:束搜索的共享比并行采样多得多。论文:vLLM 相对 Orca (Oracle) 的提升从普通采样的 1.3 倍增加到宽度 6 的束搜索的 2.3 倍。
还有第三种:共享前缀——服务商预先定义的系统提示词或 few-shot 示例,所有请求共用它的物理块(论文的翻译实验:1-shot 前缀时吞吐是 Orca (Oracle) 的 1.67 倍,5-shot 时 3.58 倍)。§4 的 RadixAttention 把这件事做成了自动的。
3.6 端到端(代码 03 演示 5)
同样的显存,归一化延迟不超过 0.1 秒 / token 时能撑住的最高请求率:
| 请求 | Orca (Max) | Orca (Pow2) | Orca (Oracle) | vLLM | vLLM / Oracle | vLLM / Max |
|---|---|---|---|---|---|---|
| ShareGPT 式 | 0.75/s | 1.25/s | 1.5/s | 3/s | 2.0× | 4.0× |
| Alpaca 式 | 4/s | 32/s | 48/s | 64/s | 1.3× | 16× |
(论文:ShareGPT 上 vLLM 比 Orca (Oracle) 高 1.7–2.7 倍、比 Orca (Max) 高 2.7–8 倍,比 FasterTransformer 最多高 22 倍;Alpaca 上优势较小。)
- Alpaca 式的请求短,按 2 的幂或恰好预留都浪费不多,vLLM 相对 Oracle 的优势变小——和论文的观察一致(短序列、显存充足时系统变成计算受限)。
- 但按最大长度 2048 预留时,每个短请求都占 2048 个槽位,这块卡同时只能放 5 个请求,差距反而更大。
4. RadixAttention:在请求之间共享前缀 · RadixAttention
4.1 机会:KV 缓存复用
slides:“Existing systems: discard KV cache after a request finishes”。但很多请求共享前缀:
- 系统提示词;
- few-shot 示例;
- 多轮对话的历史(第 k 轮的 prompt 包含前 k − 1 轮);
- 同一个问题的多次采样(自洽、树状思考);
- Agent 的模板。
KV 只依赖前面的 token,前缀相同 KV 就相同。
4.2 基数树 + LRU(代码 04 演示 1)
SGLang 的 RadixAttention 不在请求结束后丢掉 KV,而是把所有请求的 token 序列(prompt 和生成的结果)放进一棵基数树(radix tree,压缩的前缀树):
- 边的标签是一段 token(而不是一个),树很紧凑;
- KV 存成分页的布局,页大小为 1 个 token;
- 新请求沿树往下匹配,能匹配多长的前缀就复用多少 KV,只需对剩下的部分做预填充;
- 新序列插入时,必要时把一条边拆成两段(例如两段对话只共享系统提示词时,原来的边被拆成”系统提示词”和两个分支);
- 显存满了按 LRU 淘汰叶子——先淘汰叶子,共同的祖先就能留得更久;
- 运行中的请求用到的节点有引用计数,不能淘汰;
- 不单独划一块缓存区:缓存的 token 和运行中的请求共用同一个显存池,等待的请求多了就把缓存全部让给更大的批。
代码仿照 SGLang 论文图 3 回放了两段对话、两个 few-shot 查询,缓存最多 40 个词:最久没被访问的分支先被淘汰,系统提示词和 few-shot 示例因为常被访问而留下。
4.3 命中率与收益(代码 04 演示 2)
| 工作负载 | 命中率 | 预填充时间(不缓存 → 缓存) |
|---|---|---|
| few-shot(10 个任务 × 20 个问题,600 token 的示例) | 89.8% | 19.4 → 4.1 s |
| 多轮对话(20 段 × 6 轮) | 73.8% | 9.7 → 2.7 s |
| 同一问题采样 8 次 | 87.5% | 3.3 → 0.2 s |
| 互不相关的请求 | 0% | 4.2 → 4.2 s |
(SGLang 论文:在它的各个基准上命中率 50%–99%。)
- 命中不只省预填充的计算(降低首 token 延迟),还省显存:共享的 KV 只存一份,批可以更大。
- 多轮对话如果每轮都丢掉 KV(vLLM 论文的聊天实验就是这样做的),预填充的量随轮数平方增长;缓存把它变成线性的。
- 没有可复用前缀时 RadixAttention 不带来收益,但开销可以忽略:树放在 CPU 上,代码里每个请求的查找 + 插入只要几十微秒,而一次解码迭代约 22 ms(代码 04 演示 4)。
4.4 缓存感知调度(代码 04 演示 3)
等待的请求很多时,执行顺序决定命中率。如果调度器在不相关的请求之间来回切换,刚缓存的前缀还没被用上就被挤掉(缓存抖动)。SGLang 按匹配的前缀长度排序,优先处理匹配最长的请求(“最长共享前缀优先”)。
论文的定理 3.1:对一批请求,缓存不小于最长的请求时,按请求基数树的深度优先序访问能达到最优命中率;最长共享前缀优先等价于深度优先序。
代码把 30 个任务的 300 个 few-shot 请求打乱顺序:
| 缓存容量(token) | 先来先服务 | 最长共享前缀优先 | 深度优先序 |
|---|---|---|---|
| 500 | 7.8% | 83.0% | 83.0% |
| 5,000 | 30.8% | 83.0% | 83.0% |
| 20,000 | 83.0% | 83.0% | 83.0% |
理论最优(每个不同的 token 只算一次)是 83.0%。缓存只有 500 个 token(刚好放得下最长的请求)时,缓存感知调度就达到了最优,和定理一致。
代价是公平性:一直优先处理能命中缓存的请求,别的请求可能饿死——论文说贪心的缓存感知调度”可能导致饥饿”,和公平调度的结合留作以后的工作。在线场景下新请求会打乱深度优先序,论文测得它平均达到最优命中率的 96%。
5. 藏住 CPU 开销:重叠调度器 · Overlapping the scheduler
slides:“An unoptimized inference engine can waste more than 50% time on CPU scheduling”。每一次迭代,CPU 调度器要:
- 接收用户的新请求;
- 处理模型 worker 返回的结果;
- 检查停止条件;
- 做前缀匹配和请求重排;
- 为下一批分配显存。
这期间 GPU 闲着。
重叠调度器:让 CPU 调度和 GPU 计算重叠——run_batch 是非阻塞的、返回一个”引用”,CPU 在 GPU 跑第 i + 1 次迭代的同时处理第 i 次的结果。代码 05 演示 1 的时间线(GPU 每次 10 个单位,CPU 调度 4、处理结果 3):
不重叠:总时间 102,GPU 忙碌 59%
CPU |S0 P0S1S P1S2S P2PS3 P3S4S P4S5S P5P|
GPU | R0RRRRR R1RRRRR R2RRRR R3RRRRR R4RRRRR R5RRRR |
重叠:总时间 67,GPU 忙碌 90%
CPU |S0SSS1SS P0PS2SS P1PS3SS P2PS4SS P3PPS5SS P4P P5PP|
GPU | R0RRRRRRRRR1RRRRRRRRRR2RRRRRRRRR3RRRRRRRRR4RRRRRRRRRR5RRRRRRRR |
依赖问题:调度第 i + 1 批时还不知道第 i 次迭代里谁生成完了。slides 的解决办法是”推迟停止条件的检查”:
- 假设谁都没完,所有请求都进入下一批;
- 真正已经完成的请求在下一次迭代多生成一个没用的 token,然后被丢弃;
- 用”每个请求多解码一个 token”的代价换来 CPU 和 GPU 完全解耦。
代价有多大(代码 05 演示 2):ShareGPT 式的输出长度(中位数约 220 token)下,多算的 token 占 0.3–0.7%;只有极短的输出(例如只生成 8 个 token)时才明显(12.5%)。
收益(代码 05 演示 3):CPU 开销占不重叠时间的 10% / 25% / 42% 时,重叠把吞吐提高约 1.1 / 1.3 / 1.7 倍。CPU 时间超过 GPU 时间后瓶颈变成 CPU,重叠的提升封顶 2 倍。CPU 开销还随批大小增长,所以推理引擎要把调度逻辑写得尽量快,并和 GPU 计算重叠。
6. 把它们放在一起 · Putting it together
slides 的总结:连续批处理、PagedAttention、RadixAttention 分别对准三个瓶颈:
| 技术 | 对准的瓶颈 | 核心想法 | 代价 |
|---|---|---|---|
| 连续批处理 | 请求长度不一,静态批里有空位,新请求要等 | 每次迭代重新组批 | 预填充和解码混在一批里,调度更复杂 |
| PagedAttention | KV 缓存连续预留,只有 20–40% 的显存存了 token | 固定大小的块 + 块表,按需分配;引用计数 + 写时复制 | 注意力 kernel 要查块表(慢 20–26%);可能要抢占 |
| RadixAttention | 请求之间的公共前缀被重复计算、重复存储 | 基数树缓存所有 KV,LRU 淘汰叶子,缓存感知调度 | CPU 上的树维护(很小);调度的公平性 |
| 重叠调度器 | CPU 调度时 GPU 闲着 | CPU 处理第 i 次的结果时 GPU 已经在跑第 i + 1 次 | 每个请求多算一个 token |
它们是正交、可以组合的:SGLang 的运行时同时使用连续批处理、分页的 KV(页大小 1)、RadixAttention、张量并行和重叠调度。一个共同点:都是把操作系统、编译器、数据库里的经典想法(分页、写时复制、前缀树、LRU、流水线)搬到 LLM 服务这个新的工作负载上——它的特点是显存受限、请求长度不可预测、请求之间有大量重复。
7. 论文精读 · Paper reading
7.1 Efficient Memory Management for Large Language Model Serving with PagedAttention(vLLM,Kwon et al., SOSP 2023)
1. 问题:LLM 服务要靠大批量才能有高吞吐,而批大小被 KV 缓存的显存限制。KV 缓存很大、动态增长、长度不可预测,现有系统的管理方式浪费了大部分显存。
2. 为什么重要又困难:
- 一个 LLM 请求比传统的关键词查询贵约 10 倍;
- 解码是访存受限的,批大小几乎直接决定吞吐;
- 深度学习框架要求张量连续存放,而 KV 的最终长度事先不知道;
- 并行采样、束搜索等解码算法的共享模式在运行时动态变化。
3. 以前的工作为什么不行:
- FasterTransformer 没有细粒度调度,按最大长度预留;
- Orca 有迭代级调度,但仍然连续预留 KV(论文实现了 Max、Pow2、Oracle 三个版本作为基线);
- 实测只有 20.4%–38.2% 的 KV 显存存了 token;
- 共享 KV 需要复杂的手工管理。
4. 新意:
- PagedAttention:KV 按固定大小的块存放,块表把逻辑块映射到物理块,注意力 kernel 按块表取数据——把操作系统的虚拟内存和分页搬到 KV 缓存上;
- 块级的内存管理:按需分配,浪费限制在每个序列最后一块之内;
- 共享:引用计数 + 写时复制,支持并行采样、束搜索、共享前缀,以及它们的混合;
- 调度与抢占:先来先服务;显存不够时以请求(序列组)为单位”全有或全无”地换出到 CPU 或丢弃后重算;
- 分布式:张量并行时各 worker 共用同一份块表。
5. 结果:
- 在相同的延迟水平下,吞吐比 FasterTransformer、Orca 高 2–4 倍;ShareGPT 上比 Orca (Oracle) 高 1.7–2.7 倍、比 Orca (Max) 高 2.7–8 倍、比 FasterTransformer 最多高 22 倍;
- 序列越长、模型越大、解码算法越复杂,提升越明显;
- 共享在并行采样上省 6.1–9.8%(Alpaca)/ 16.2–30.5%(ShareGPT)的显存,束搜索上省 37.6–55.2% / 44.3–66.3%;
- 共享前缀的翻译任务上是 Orca (Oracle) 的 1.67 倍(1-shot)、3.58 倍(5-shot);
- 聊天任务上 2 倍。
本讲的验证:在合成的请求流上,连续预留的 KV 显存里存 token 的比例 27% / 46% / 65%,分页 98.5%;按块计算的注意力和连续存放一致;可承受请求率比 Oracle 高 2.0 倍、比 Max 高 4.0 倍(ShareGPT 式),都落在论文报告的范围内。
局限:
- 注意力 kernel 要查块表,比专门优化的连续 kernel 慢 20–26%;
- 抢占(尤其是重新计算)在高负载下会浪费计算;
- 论文没有在请求之间保留 KV(聊天实验每轮都重算历史),跨请求的自动复用留给了 SGLang;
- 论文自己讨论了适用性:只有”需要动态分配、性能受显存容量限制”的负载才适合分页,训练(形状静态)、计算受限的模型服务用了反而可能变慢。
7.2 SGLang: Efficient Execution of Structured Language Model Programs(Zheng et al., NeurIPS 2024)
1. 问题:LLM 越来越多地以”程序”的形式被使用:
- 多次生成调用夹着控制流(Agent、树状思考、自洽、LLM 评审);
- 有结构化的输入输出(JSON)。
缺少既方便编写、又能高效执行这类”语言模型程序”的系统。
2. 为什么困难:
- 多次调用之间有大量共享的前缀,但现有引擎在每个请求结束后丢掉 KV;
- 共享模式(树状、动态的分支)在运行时才出现;
- 结构化输出的约束解码通常一次只能解码一个 token;
- 前端的字符串拼接、并行控制要手写,而且引擎看不到程序的结构。
3. 以前的工作为什么不行:
- vLLM 只支持请求内部的共享和人工配置的共享前缀;
- LMQL、Guidance 有编程接口,但运行时没有针对多调用程序的优化;
- 其他前缀缓存的工作需要手工配置,处理不了动态的树结构。
4. 新意:
- 前端:嵌在 Python 里的语言,原语 gen、select、extend(+=)、fork、join、image、video,异步执行,程序内部的并行由流执行器自动处理;
- RadixAttention:基数树 + LRU 自动复用 KV 缓存,缓存感知调度(最长共享前缀优先),并证明它在离线、缓存足够时最优;
- 压缩有限状态机:把正则表达式约束编译成状态机,把只有一条出边的连续状态压缩起来,约束确定的那一段可以一次解码多个 token;
- API 推测执行:对只能通过 API 访问的模型(如 GPT-4),推测性地多生成一些、匹配后续的原语,减少调用次数。
5. 结果:
- 在 Llama-7B、Mixtral-8x7B 等模型上,相比 vLLM、Guidance、LMQL,吞吐最多提高 6.4 倍、延迟最多降低 3.7 倍;
- 基准涵盖 MMLU、HellaSwag、ReAct / 生成式 Agent、树状 / 骨架思考、LLM 评审、JSON 解码、多轮对话、DSPy RAG;
- 缓存命中率 50%–99%,缓存感知调度平均达到最优命中率的 96%;
- 长输出的多轮对话几乎没有加速(解码时间占主导,会话之间共享少)——这是论文自己的观察。
本讲的验证:基数树的拆分、LRU 淘汰、引用计数;四种合成负载的命中率(0%–90%);打乱顺序后先来先服务的命中率只有 8%–31%,最长共享前缀优先达到最优,与定理 3.1 一致。
局限:
- 贪心的缓存感知调度可能导致饥饿;
- 在线场景下深度优先序会被打乱;
- 收益依赖工作负载里有没有共享前缀;
- 前端语言要求用户用它的原语改写程序(也可以只用它的运行时,通过普通的 API 调用享受 RadixAttention)。
7.3 两篇论文之间的联系
- 从”请求内”到”请求间”:vLLM 用分页让一个请求内部的多个序列(并行采样、束搜索)共享 KV;SGLang 把 KV 的生命周期延长到请求结束之后,在请求之间自动共享。两者的底层都是”KV 不必连续存放”——SGLang 的页大小是 1 个 token,可以看成 PagedAttention 的极端情况。
- 同一个设计哲学:都把操作系统的经典机制搬过来——vLLM 是虚拟内存、分页、写时复制、换出;SGLang 是前缀树(像页缓存)、LRU 淘汰、引用计数。
- 调度和内存管理互相影响:vLLM 的批大小受内存管理决定;SGLang 的命中率受调度顺序决定(演示 3 里先来先服务只有 8%,排序后 83%)。
- 一个可以讨论的问题:vLLM 的聊天实验选择”每轮都丢掉 KV”,因为保留它会占住别的请求的空间;SGLang 的回答是”缓存和运行中的请求共用一个池,按 LRU 让出”。在显存很紧、请求很多的场景下,保留历史 KV 的收益(省预填充)和代价(批变小)怎样权衡?这引出了后来把 KV 换出到 CPU / SSD 分层缓存的工作。
8. 小结 · Summary
- 自回归解码分预填充(计算受限)和解码(访存受限)。KV 缓存避免了重复计算,代价是显存:OPT-13B 每个 token 0.82 MB,GPT-3 175B 每个 token 4.72 MB。
- 批处理把读一次权重摊到更多 token 上;批大小的上限是 KV 缓存放不放得下。
- 连续批处理在每次迭代重新组批:生成完的立刻离开,新到的马上加入。长度不一的请求流上,吞吐比静态批高几倍。
- PagedAttention 用固定大小的块 + 块表管理 KV:按需分配,浪费限制在最后一块之内(存 token 的比例从 20–40% 提到 96% 以上);按块计算的注意力与连续存放等价;引用计数 + 写时复制支持并行采样、束搜索的共享。
- RadixAttention 用基数树在请求之间共享前缀 KV,LRU 淘汰叶子;缓存感知调度(最长共享前缀优先 = 深度优先序)在缓存够大时达到最优命中率。
- 重叠调度器让 CPU 处理结果和 GPU 计算重叠,用”每个请求多算一个 token”解开依赖。
- 共同的主题:LLM 服务是一个显存受限、长度不可预测、充满重复的新负载,经典的系统思想(分页、写时复制、前缀树、LRU、流水线)在这里重新发挥作用。
9. 常见误区 · Common misconceptions
- “解码慢是因为算力不够。” 解码是访存受限的:batch 1 时算术强度约 1,算力利用率不到 1%。瓶颈是每一步都要读一遍权重和 KV。
- “批越大越好,可以无限加大。” 批大小受 KV 缓存的显存限制;而且批越大,每一步读的 KV 越多,每个 token 的延迟也会上升。
- “知道输出长度就能解决 KV 浪费。” 不能完全解决:即使恰好预留(Oracle),请求一开始就要占住它最终的全部长度,代码里仍有约 27% 的”预留未用”和 7% 的外部碎片。
- “PagedAttention 是一种新的注意力算法,会改变结果。” 它不改变数学,只改变 KV 的存放方式;按块计算再用在线 softmax 合并,结果和连续存放相同(代码里差 2.2e-16)。
- “块越小越好。” 碎片更少,但块表更大、kernel 的访存更碎、效率更低;vLLM 默认 16。
- “前缀缓存只对系统提示词有用。” 它对 few-shot、多轮对话、同一问题的多次采样、Agent 模板都有用;但对互不相关的请求没有收益。
- “有了前缀缓存,调度顺序无所谓。” 顺序决定命中率:代码里打乱顺序后先来先服务的命中率只有 8%,排序后 83%。
10. 练习 · Exercises(含参考答案)
练习 1 LLaMA-2-7B:32 层,hidden 4096,FP16。每个 token 的 KV 多大?一个 4096 token 的请求呢?一块 80 GB 的卡(权重 13.5 GB,留 10% 工作空间)最多同时放多少个这样的请求?
答案:
- 每个 token 2 × 32 × 4096 × 2 = 524,288 字节 = 0.5 MB;4096 个 token 2 GB;
- 可用 80 × 0.9 − 13.5 = 58.5 GB,约 29 个请求。
- (LLaMA-2-70B 用了 GQA,K、V 只有 8 个头,KV 小 8 倍——这是 GQA 的主要动机之一。)
练习 2 为什么预填充是计算受限、解码是访存受限?用算术强度说明,并估算一个 13B 模型在 A100(拐点约 120 FLOPs / 字节)上解码时,批大小至少多大才可能变成计算受限(忽略 KV 的读取)。
答案:
- 一次迭代读一遍权重(2Ψ 字节),每个 token 做约 2Ψ 次运算,b 个 token 的算术强度约 b;
- 预填充一次几百上千个 token,强度远高于 120;解码 batch 1 时强度约 1;
- 要到拐点,b 约 120——但实际上 KV 的读取会随 b 增长,批还没到 120 显存就先满了(代码里只放得下 23 个 512 token 的请求)。
练习 3 静态批处理(批大小 8),8 个请求的输出长度分别是 10、10、10、10、10、10、10、200。这一批的”空位”比例是多少?连续批处理能怎样改进?
答案:
- 这一批要跑 200 次迭代,总槽位 8 × 200 = 1600,实际用到 7 × 10 + 200 = 270,空位 83%;
- 连续批处理在第 10 次迭代后就让 7 个短请求离开、换进新请求,长请求继续跑,槽位几乎一直是满的。
练习 4 块大小 16,一个请求最终长度 1000 个 token。分页时内部碎片最多多少?按 2048 预留呢?按 2 的幂预留呢?
答案:
- 分页:1000 = 62 × 16 + 8,最后一块用了 8 个,浪费 8 个槽位(< 16);
- 按 2048 预留:浪费 1048 个;
- 按 2 的幂(1024):浪费 24 个,但整个生成期间都要占着 1024 个槽位(“预留”的浪费),而且事先要能预测长度。
练习 5 并行采样 n = 4,prompt 100 个 token,每个样本生成 50 个 token,块大小 16。不共享和共享时最后各需要多少个物理块?共享时发生几次写时复制?
答案:
- 不共享:每个样本 150 个 token → 10 块,共 40 块。
- 共享:prompt 的 100 个 token 占 7 块(前 6 块满、第 7 块只有 4 个 token)。前 6 块 4 个样本共用;第 7 块没填满,每个样本第一次往里写时:前 3 个各复制一份,最后一个写时引用计数已降为 1,可以直接写——3 次写时复制。
- 之后每个样本还要 ⌈(150 − 112)/16⌉ = 3 个新块。合计 6 + 4 × 1(各自的第 7 块)+ 4 × 3 = 22 块,省 45%。
练习 6 说明为什么 RadixAttention 淘汰时”先淘汰叶子”,而不是直接淘汰最久没用的任意节点。
答案:
- 一个内部节点是它所有后代的公共前缀,后代要用它的 KV 才能被复用;
- 先淘汰内部节点会让它下面所有还在缓存里的后代都失去意义(前缀断了,后面的 KV 不能单独使用);
- 淘汰叶子则不影响其他序列。叶子被淘汰后它的父节点才可能变成叶子,于是常被访问的公共前缀(系统提示词、few-shot 示例)留得最久。
练习 7 SGLang 的定理 3.1 说,缓存不小于最长请求时,按深度优先序处理一批请求能达到最优命中率。用代码 04 演示 3 的例子解释:为什么缓存只有 500 个 token(只够放一个请求)也能达到最优?
答案:
- 按深度优先序,同一个任务的 10 个请求连续处理,它们共享”系统提示词 + 400 token 的示例”;
- 处理完一个请求后,下一个请求只需要它和上一个请求的公共前缀还在缓存里,而公共前缀加上自己的问题不超过最长请求的长度(470 < 500)。LRU 淘汰的总是上一个请求独有的问题部分,正好是后面不会再用的;
- 换到下一个任务时,只有系统提示词能复用。这样每个不同的 token 只算一次——就是最优(83.0%)。先来先服务时相邻请求属于不同任务,示例刚算完就被别的任务挤掉了。
练习 8 (开放)重叠调度器让每个请求多生成一个没用的 token。在什么样的工作负载下这个代价不可接受?有没有不浪费的办法?
参考思路:
- 输出极短的负载(分类、打分、只要 1–4 个 token 的选择题)浪费 25%–100%;
- 这种负载下预填充占主导、解码迭代很少,本来重叠的收益也小,可以按负载关闭重叠;
- 或者在 GPU 端判断停止条件(例如遇到 EOS 时把输出标成无效),只有确定继续的请求才进入下一批;
- 或者让第 i + 1 批只包含”肯定还没完”的请求(例如离最大长度还远、上一步不是候选的停止 token 的),代价是调度更复杂。
11. 延伸阅读 · Further reading
- G.-I. Yu et al., Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI 2022)——迭代级调度、选择性批处理。
- A. Agrawal et al., Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve(OSDI 2024)——分块预填充,减少预填充对解码的干扰。
- Y. Zhong et al., DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving(OSDI 2024)——把预填充和解码放到不同的 GPU 上。
- Y. Sheng et al., FlexGen: High-Throughput Generative Inference of Large Language Models with a Single GPU(ICML 2023)——把 KV 和权重卸载到 CPU / 磁盘。
- R. Prabhu et al., vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention(ASPLOS 2025)——用 CUDA 的虚拟内存 API 让 KV 在虚拟地址上连续,避免改写 kernel。
- SGLang 团队的博客(2024 年 12 月):SGLang v0.4: Zero-Overhead Batch Scheduler, Cache-Aware Load Balancer, Faster Structured Outputs——slides 里重叠调度器的来源。
12. 下一讲预告 · Next lecture
这一讲的方法都是在提高吞吐:批越大,读一次权重摊到的 token 越多。但对单个请求来说,解码仍然是一步一个 token,每一步都要读一遍权重——延迟的问题没有解决。第 17 讲《推测解码》换一个思路:
- 让一个小模型(或者别的便宜的方法)一次猜好几个 token,大模型一次并行地验证它们;
- 用拒绝采样保证输出的分布和大模型逐个生成完全一样;
- SpecInfer 进一步用一棵”令牌树”同时猜很多条候选,用树形注意力掩码一次验证整棵树。
我们会在一个概率完全已知的玩具语言模型上验证推测采样的无偏性,并估算接受率和加速比的关系。