对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026),#3 Mon Apr 6 Serving Engines。必读:W. Kwon, Z. Li, S. Zhuang, Y. Sheng, L. Zheng, C. H. Yu, J. E. Gonzalez, H. Zhang, I. Stoica, Efficient Memory Management for Large Language Model Serving with PagedAttention(SOSP 2023);L. Zheng, L. Yin, Z. Xie, C. Sun, J. Huang, C. H. Yu, S. Cao, C. Kozyrakis, I. Stoica, J. E. Gonzalez, C. Barrett, Y. Sheng, SGLang: Efficient Execution of Structured Language Model Programs(NeurIPS 2024)。 依据说明:原课没有公开 slides。本讲依据两篇必读论文的全文来写。顺带一提:SGLang 论文的作者里有本课的授课教师 Kozyrakis、助教 Zhiqiang Xie,以及第 16 讲(原课第 8 次课)的客座讲者 Ying Sheng,她也是 vLLM 论文的作者之一。§8”之后的发展”是我补充的。 配套代码 Code:
code/lecture03/(numpy + 标准库;6 个脚本 + 3 个模块:paged_kv.py是真实的分页 KV 存储,paged_memory.py是给模拟器用的分页显存管理器,radix_cache.py是 RadixAttention 的基数树;最慢的02_paged_vs_reserved_engine.py约 35 秒) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 说出连续批处理之后推理系统的主要瓶颈为什么是 KV cache 的显存,以及 Orca 式的连续存储有哪三种浪费(预留、内部碎片、外部碎片),为什么还没法共享。
- 用操作系统的虚拟内存与分页解释 PagedAttention:块、块表、逻辑块与物理块、按需分配;说清楚它为什么把浪费限制在不到一个块,并且消除了外部碎片。
- 解释引用计数和写时复制怎样让并行采样、beam search、共享前缀的多个序列共享 KV,并能估算节省的比例。
- 说明分页带来的新问题(半路显存不够)和 vLLM 的解法:FCFS 加上抢占最晚到达的请求、全有或全无,以及两种恢复方式(换出 / 重算)各自的代价。
- 解释 SGLang 的 RadixAttention:怎样用基数树管理跨请求的 KV 复用、为什么只淘汰叶子、引用计数的作用;缓存感知调度为什么能提高命中率,代价是什么。
- 解释压缩有限状态机怎样加速约束解码,以及它为什么不改变输出。
- 用迷你引擎复现两篇论文的核心结论,并说明我们的数字和论文的数字为什么不完全一样。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,后面再展开 Previewed only |
|---|---|
| 第 1 讲:KV cache 有多大、为什么显存决定并发数 | 注意力内核怎样高效地读散落的块(第 4 讲 FlashInfer) |
第 2 讲:连续批处理、Orca 的 KV 预留、cb_engine 的显存管理器接口 | 把 KV 放到 CPU 内存、SSD、别的机器上(第 9 讲) |
| 建议先读前置讲 P3:虚拟内存、分页、写时复制、LRU、基数树 | 集群里按前缀把请求路由到”已经有它的 KV”的机器(第 11 讲) |
| 投机解码时的树形注意力(第 5 讲) |
0.3 本讲地图 Map
§1 显存:连续批处理之后的瓶颈(三种浪费、无法共享) ← 代码 01 演示 3、4
§2 借来的想法:操作系统的虚拟内存与分页
§3 PagedAttention:块与块表、注意力怎样读散落的块、在迷你引擎上、块大小 ← 代码 01、02
§4 共享:并行采样、beam search、写时复制、共享前缀 ← 代码 03
§5 调度与抢占:FCFS、全有或全无、换出 vs 重算、水位线 ← 代码 04
§6 vLLM:系统架构、评测、论文卡片
§7 SGLang:LM 程序、RadixAttention、缓存感知调度、压缩有限状态机、评测、论文卡片 ← 代码 05、06
§8 之后的发展
§9–13 小结、常见误区、练习、延伸阅读、下一讲预告
0.4 记号 Notation
| 符号 / 术语 | 含义 |
|---|---|
| block_size() | 每个 KV 块存几个 token(vLLM 默认 16;SGLang 的页大小是 1) |
| 逻辑块 / 物理块 | 一个序列的第 0、1、2…块 / 显存里真正存数据的块 |
| 块表(block table) | 逻辑块号 → 物理块号的映射,每个序列一张 |
| 引用计数(ref count) | 有几个序列(或缓存)在使用一个物理块 / 树节点 |
| 命中率(hit rate) | 命中缓存的提示 token 数 ÷ 全部提示 token 数(SGLang 的定义) |
| LSPF | longest-shared-prefix-first:等待队列里能命中最长前缀的请求先处理 |
| FSM | 有限状态机(finite state machine) |
0.5 关于数字 · About the numbers
本讲的引擎实验都在第 1 讲的纸上 GPU 上模拟,用了两个场景:
- 场景 A(显存紧张,模仿 vLLM 论文的设定):一个 OPT-13B 规模的模型(40 层、40 个 KV 头、没有 GQA,每个 token 的 KV 是 800 KiB)放在一块 A100-40GB 上。放完 26 GB 权重后,只能放约 1.2 万个 token 的 KV。我们的
LLMSpec假设 MLP 有三个矩阵(SwiGLU),OPT 只有两个,所以把d_ff设成 13653,让参数量与 OPT-13B 一致;每 token 的 KV 大小与 OPT-13B 完全相同。 - 场景 B(显存较宽松):Llama-3-8B(GQA,每 token 128 KiB)+ H100,长上下文负载(与第 2 讲代码 04 相同)。
真实计算的演示(代码 01、03、05、06)在迷你模型上运行,检查的是”结果对不对”和”用了多少块”,每次运行完全相同。论文里的数字写成”论文报告”。
1. 显存:连续批处理之后的瓶颈 · Memory is the bottleneck
1.1 从第 2 讲接着说
第 2 讲把调度问题解决得差不多了:连续批处理让请求随时进出,在同样的 SLO 下能承受的流量是静态批处理的几百倍。可它的结尾留下了一个尴尬的数字:Orca 按最大长度为每个请求预留 KV,显存被占了近九成,真正写了 KV 的只有约四分之一(第 2 讲代码 04 演示 1)。
第 1 讲 §3.5 说过,decode 阶段能同时放进显存的请求越多,吞吐就越高。所以显存利用率低,直接意味着吞吐低。
1.2 vLLM 的测量
vLLM 论文从一个具体的例子出发:一个 130 亿参数的模型跑在一块 40 GB 的 A100 上,约 65% 的显存是权重(固定不变),近 30% 是 KV cache(随请求动态变化),剩下一小部分是激活值。权重和激活都没什么可优化的,KV cache 怎样管理,决定了最大批大小。
论文接着测量了当时系统(他们重新实现了三种版本的 Orca,见 §6.2)的 KV 显存里,真正存放 token 状态的只有 20.4%–38.2%,而 vLLM 达到 96.3%(论文报告)。
1.3 三种浪费
为什么这么低?因为当时的深度学习框架要求张量存放在连续的显存里,于是每个请求的 KV cache 也被存成一整块连续空间。可 KV cache 有两个特点:它随着生成动态增长,而且最终长度事先不知道。为了保证放得下,系统只能按”可能的最大长度”(例如 2048)一次性分配。论文把由此产生的浪费分成三类:
| 浪费 | 是什么 | 例子 |
|---|---|---|
| 预留(reserved) | 为将来的 token 预留、暂时还没用上的空间 | 请求刚开始时,后面 1000 个 token 的位置全被占着 |
| 内部碎片(internal fragmentation) | 请求结束时都没用上的预留空间 | 预留 2048,最终只生成到 300 |
| 外部碎片(external fragmentation) | 分配器留下的、太小而用不上的空洞 | 总空闲 3000,但最大的连续空洞只有 1500,放不下一个要 2048 的请求 |
预留的空间最终可能被用上,但它在整个请求期间占着地方,别的请求用不了;内部碎片和外部碎片是纯粹的浪费。
代码 01 演示 3 算了请求结束时的浪费(只算内部碎片,请求进行到一半时的预留浪费更多):
| 负载 | 按最大长度预留(2048) | 按 2 的幂预留 | 分页 block_size = 16 | 分页 block_size = 256 |
|---|---|---|---|---|
| 聊天(提示 512、输出 256) | 66.5% | 12.6% | 1.1% | 15.8% |
| 短问答(提示 20、输出 60) | 96.1% | 24.7% | 8.5% | 69.1% |
代码 01 演示 4 模拟了外部碎片:一段 32768 个 token 的”显存”,请求来来去去,每个请求要一段连续空间(first-fit 分配)。请求平均大小 512、2048、4096 时,分配失败分别有 97、783、1377 次,其中 99%、77%、55% 发生在”总空闲足够”的时候:空间被切成了许多小空洞,凑不出一段连续的。
1.4 第二个问题:没法共享
LLM 服务常用一些”一个请求生成多个序列”的解码方式:
- 并行采样(parallel sampling):同一个提示生成 n 个不同的回答,让用户挑(例如代码补全给出几个候选)。
- beam search:保留 k 条最有希望的候选序列,每一步扩展、淘汰(机器翻译常用)。
这些序列有大段相同的内容(至少是提示,beam search 还有生成出来的公共前缀),它们的 KV 本来可以只存一份。可在连续存储下,每个序列的 KV 是一块独立的连续空间,没法共享。
2. 借来的想法:操作系统的虚拟内存与分页 · Borrowing from the OS
这两个问题,操作系统在几十年前就遇到过。前置讲 P3 会详细讲,这里只说最需要的部分。
一个进程看到的是一段连续的虚拟地址空间,它以为自己的数据是连续存放的。实际上,操作系统把虚拟地址空间切成固定大小的页(page,通常 4 KB),把物理内存切成同样大小的页框(frame)。每个进程有一张页表(page table),记录”第几个虚拟页放在哪个物理页框里”。CPU 访问内存时,硬件按页表把虚拟地址翻译成物理地址。
这样做有三个好处,正好对应 §1 的三个问题:
- 按需分配:进程用到哪一页,才给它分配一个页框。
- 没有外部碎片:所有页框一样大,任何一个空闲页框都能用。
- 共享:两个进程的页表可以指向同一个物理页框(共享库、
fork出来的子进程)。要往共享的页里写数据时,先复制一份再写,这叫写时复制(copy-on-write,CoW)。
vLLM 的类比是:
| 操作系统 | vLLM |
|---|---|
| 页(page) | KV 块(block):存 block_size 个 token 的 K 和 V |
| 进程 | 请求(序列) |
| 字节 | token |
| 虚拟页 | 逻辑块:一个序列的第 0、1、2…块 |
| 物理页框 | 物理块:显存里真正存数据的块 |
| 页表 | 块表(block table) |
3. PagedAttention · PagedAttention
3.1 块与块表
vLLM 把每个序列的 KV cache 切成固定大小的块,每块存 block_size 个 token 的 K 和 V(所有层共用同一个块号,每一层各对应一小块显存)。一个序列有一张块表,把逻辑块 0、1、2… 映射到物理块。物理块可以散落在显存的任何地方。
代码 01 演示 1 展示了两个请求的块表在生成过程中的变化(block_size = 8,物理块打乱分配):
步 请求 A 请求 B 空闲块
prefill 20 token 块表 [8, 11, 4] 末块 4/8 24 token 块表 [7, 5, 0] 末块 8/8 6
decode 1 21 token 块表 [8, 11, 4] 末块 5/8 25 token 块表 [7, 5, 0, 1] 末块 1/8 5
decode 5 25 token 块表 [8, 11, 4, 9] 末块 1/8 29 token 块表 [7, 5, 0, 1] 末块 5/8 4
decode 12 32 token 块表 [8, 11, 4, 9] 末块 8/8 36 token 块表 [7, 5, 0, 1, 2] 末块 4/8 3
三个要点:
- 按需分配:prefill 时 A 有 20 个 token,只分配 3 块;末块写满之后,下一个 token 才让它申请第 4 块(B 在 decode 1 时从 3 块变成 4 块)。
- 浪费不超过一块:每个序列只有末块可能没写满,浪费少于 block_size 个 token。
- 物理块不连续:A 的块是 8、11、4、9,B 的是 7、5、0、1、2。这没有关系,块表记着顺序。
3.2 注意力怎样读散落的块
KV 不再连续了,注意力怎么算?vLLM 论文把 KV 按块切开来写注意力:设 、 是第 个逻辑块里的 key 和 value,对新 token 的 query :
是 对第 块里各个位置的注意力权重(一个行向量),分母对所有块的所有位置求和。PagedAttention 内核按块表逐块取出 、,算分数,最后汇总。数学上它和连续存储的注意力完全一样,只是数据从哪里读不同。
我们的 paged_kv.paged_attention 就是这样做的:逐块取出物理块里的 K(最后一块只取写了的那几行),算分数,拼起来做 softmax,再对各块的 V 加权求和。代码 01 演示 2:6 个请求在不同迭代到达,用分页存储(block_size = 1、4、16,物理块按顺序或打乱分配)跑第 2 讲的连续批处理,生成的 token 在所有设置下都和逐个生成完全相同。
而且模型一行没改:PagedStore 实现了和第 2 讲 ContiguousStore 相同的四个方法(length、write、attend、advance),forward_batch 照用不误。vLLM 也是这样设计的:模型和注意力内核只看到”每个序列的物理块号列表”,共享、换出这些管理细节都不需要关心。
在真实的 GPU 上,按块表间接访问是有代价的。vLLM 写了几个专门的内核:把”拆成块、重排、按块表写入”融合成一个内核;在注意力内核里按块表读 KV,一个 warp 读一个块以保证合并访存;把写时复制要做的许多小块拷贝合并成一次内核启动。即便如此,论文报告它的注意力内核比高度优化的 FasterTransformer 慢 20%–26%(多了查块表、分支和变长序列的开销)。但注意力只是模型的一部分,端到端看,能多放进来的请求远远抵消了这点开销。第 4 讲会看到 FlashInfer 怎样把这种”散落的 KV”做得更高效。
3.3 在迷你引擎上:分页 vs 预留
paged_memory.PagedMemory 是第 2 讲 cb_engine 的一个新的显存管理器:请求被接纳时只为它当前的 token 分配块,decode 时写满一块才申请下一块,块不够时 grow 返回 False,引擎就抢占(§5)。
代码 02 演示 1,场景 A(显存紧张,模仿 vLLM 论文的设定),SLO 为 TTFT ≤ 2 s 且 TPOT ≤ 100 ms:
| 到达率 | 显存管理 | 吞吐 tok/s | 平均批 | 占用 | 实际存储 | 抢占 | SLO 达成 |
|---|---|---|---|---|---|---|---|
| 2 | 按最大长度预留 | 217 | 5.0 | 85% | 20% | 0 | 1% |
| 2 | 按 2 的幂预留 | 507 | 13.2 | 92% | 52% | 0 | 10% |
| 2 | 神谕预留 | 545 | 14.5 | 85% | 57% | 0 | 64% |
| 2 | 分页(块 16) | 546 | 14.6 | 59% | 58% | 8 | 98% |
| 3 | 神谕预留 | 581 | 15.8 | 93% | 62% | 0 | 5% |
| 3 | 分页(块 16) | 722 | 22.3 | 89% | 88% | 323 | 35% |
| 6(过载) | 按最大长度预留 | 217 | 5.0 | 85% | 20% | 0 | 1% |
| 6(过载) | 神谕预留 | 585 | 15.8 | 93% | 63% | 0 | 4% |
| 6(过载) | 分页(块 16) | 729 | 23.0 | 92% | 90% | 398 | 7% |
(“占用”是被分配或预留的显存比例,“实际存储”是真正写了 KV 的比例,都按时间加权。)
- 按最大长度预留时,一块卡只够同时放 5 个请求,真正写了 KV 的只有约 20%,吞吐被死死卡在 217 tok/s。
- 预留得越接近真实需要越好,但神谕预留的实际利用率也只有六成左右:它预留的是最终长度,而请求在大部分时间里还没长到那么长。
- 分页把实际利用率提到九成左右,平均批更大,同样负载下 SLO 达成率高得多。代价是负载高时会频繁抢占:分页把”预留的浪费”换成了”偶尔重算的浪费”,在这个场景里很划算。
代码 02 演示 2 用二分查找求出”SLO 达成率 ≥ 90% 时能承受的最大到达率”:
| 显存管理 | 最大到达率(请求/秒) | 分页是它的几倍 |
|---|---|---|
| 按最大长度预留 | 0.45 | 5.2× |
| 按 2 的幂预留 | 1.42 | 1.6× |
| 神谕预留 | 1.67 | 1.4× |
| 分页(块 16) | 2.31 | — |
vLLM 论文在 ShareGPT 负载上报告的是:比 Orca (Max) 高 2.7–8 倍,比 Orca (Oracle) 高 1.7–2.7 倍。我们的倍数偏小,可能的原因有三个:论文的 Orca 实现还用伙伴分配器(buddy allocator)管理显存,有外部碎片(§1.3);负载的长度分布不同;我们的模拟器没有建模内核等细节。方向一致,具体倍数不能直接对比。
场景 B(显存较宽松) 的结果很不一样(代码 02 演示 3):按最大长度预留依然很糟,平均批只有四十多个;但分页相对神谕预留只多出两三个百分点的吞吐(例如到达率 12 时 4,178 vs 4,089 tok/s),SLO 达成率高七八个百分点。原因是 GQA 让每个 token 的 KV 只有 128 KiB(场景 A 是 800 KiB),一块 H100 能放四十多万个 token,显存没那么紧张,算力也开始成为瓶颈。
显存管理的收益取决于系统有多受显存限制:模型结构(GQA、MLA)、硬件显存、请求长度都会改变答案。2023 年 vLLM 论文用的 OPT 系列没有 GQA,显存非常紧张,分页的收益格外大。今天的模型普遍用 GQA,单卡显存也更大,分页依然是标配(它几乎没有坏处),但”吞吐提高几倍”的说法要放在当时的条件下理解。
3.4 块大小
块越小,内部碎片越少;块越大,注意力内核一次能并行处理的位置越多,块表越短,换出时的小块传输也越少。vLLM 论文的实验是:ShareGPT 负载上块大小 16–128 表现最好;Alpaca 负载(请求很短)上 16 和 32 好,更大的块明显变差,因为序列比块还短。它最终把默认值设为 16:大到能有效利用 GPU,小到在大多数负载下内部碎片可以忽略。
我们的模拟只看得到前一半(显存)。代码 02 演示 4(场景 A,到达率 3):块大小 1、16、64、256 的吞吐分别是 725、722、703、628 tok/s,SLO 达成率 39%、35%、22%、9%。在模拟里块越小越好;真实系统里块太小会让内核和换出变慢(代码 04 演示 1),所以最优值在中间。
4. 共享:并行采样、beam search 与写时复制 · Sharing
4.1 并行采样
分页之后,共享就很自然了:多个序列的块表可以指向同一个物理块。每个物理块记一个引用计数,表示有几个序列在用它。
并行采样(同一个提示生成 n 个回答):
- 用一个序列做 prefill,得到提示的 KV;
- fork 出 n − 1 个序列:它们的块表复制原序列的块表,所有物理块的引用计数变成 n;
- 各序列从同一份 logits 里用各自的随机数采样,之后各自 decode。
提示的最后一块通常没写满。第一个序列要往这个被共享的末块里写新 token 时,发现引用计数大于 1,于是先申请一个新块、把内容复制过去、把自己块表里的那一项换成新块(原块的引用计数 −1),再写——这就是写时复制。前 n − 1 个序列各复制一次;到最后一个序列时,原块的引用计数已经降到 1,它直接写,不用复制。之后各序列写入的都是自己的新块。
代码 03 演示 1(提示 62 个 token,每个回答 40 个 token,block_size = 8):
| n | 逻辑块(不共享) | 物理块(共享) | 写时复制 | 节省的显存 | 与单独生成相同 |
|---|---|---|---|---|---|
| 1 | 13 | 13 | 0 | 0% | True |
| 2 | 26 | 19 | 1 | 27% | True |
| 4 | 52 | 31 | 3 | 40% | True |
| 8 | 104 | 55 | 7 | 47% | True |
不考虑块的粒度时,节省比例约为 ( 份提示不用再存)。n = 4 时公式给出 45.6%,实测 40%,差别来自块的粒度(练习 2)。每个回答都和”用同一个种子单独生成”完全相同:共享只是少存了重复的数据。
4.2 beam search
beam search 的共享更复杂:几条候选序列不仅共享提示,还共享一大段生成出来的公共前缀,而且这个共享关系每一步都在变。有的分支被淘汰,有的候选分叉成几个。引用计数让这一切自动完成:新候选 fork 自它的父候选(共享父亲的全部块);被淘汰的候选释放它的块,引用计数降到 0 的块才回到空闲列表。
代码 03 演示 2(提示约 40 个 token,搜索 30 步,block_size = 4):beam 宽度 2、4、8 时,平均节省 47%、70%、80% 的显存,结果与”每一步从头重算”的朴素 beam search 完全相同。论文报告的节省比例是:并行采样 6.1%–9.8%(Alpaca)/ 16.2%–30.5%(ShareGPT),beam search 37.6%–55.2% / 44.3%–66.3%。具体数字取决于提示与输出的长度比例;在我们的迷你实验里提示相对输出更长,随机模型的 beam 又容易收敛到相同的前缀,所以节省得更多。
论文还报告了这对吞吐的影响:OPT-13B、Alpaca 负载上,vLLM 相对 Orca (Oracle) 的提升从普通采样的 1.3 倍增加到宽度为 6 的 beam search 的 2.3 倍。
4.3 共享前缀与混合解码
不同的请求也可以共享:很多请求以同样的系统提示或 few-shot 示例开头。vLLM 论文的做法是让服务提供方预先把常用前缀的 KV 算好、存在一些保留的块里,请求带着这个前缀时直接映射到这些块上(末块写时复制)。论文报告,在 WMT16 英译德任务上,共享一个单样本前缀时吞吐是 Orca (Oracle) 的 1.67 倍,共享五样本前缀时是 3.58 倍。
这种做法要求事先知道哪些前缀值得缓存。§7 的 SGLang 把它推广成自动的:所有请求的 KV 都留在一棵树里,自动发现并复用共同前缀。
分页的另一个好处是让混合解码变得简单:一批里可以同时有普通采样、并行采样、beam search 的请求,各种共享关系都由块表和引用计数表达,注意力内核只看到一列物理块号。
5. 调度与抢占 · Scheduling & preemption
5.1 半路显存不够怎么办
分页不预留,于是可能出现:一个请求已经跑了一半,下一个 token 却申请不到新块。vLLM 的回答是抢占(preemption),并且做了几个具体的选择:
- 调度策略是 FCFS:先到的先服务;需要抢占时,抢占最晚到达的请求。这样保证公平、防止饥饿。
- 全有或全无(all-or-nothing):一个序列的所有块总是一起被访问,所以要么全部踢出,要么一个不动,不会只踢一部分。
- 序列组一起调度(gang scheduling):一个请求的多个序列(例如 beam search 的几个候选)可能共享块,所以总是一起被抢占、一起恢复。
- 抢占发生后,vLLM 暂停接纳新请求,直到被抢占的请求恢复。
我们的引擎(第 2 讲 cb_engine)里早就写好了这条路径:grow 失败时,踢出最晚到达的运行请求,释放它的 KV,把它放回等待队列。它到达得早,按 FCFS 会排在队首,下一次有空间时最先被恢复。第 2 讲的预留式管理器从来不会触发它,本讲的分页管理器会。
5.2 恢复:换出还是重算
被抢占的请求怎样恢复?vLLM 支持两种办法:
- 换出(swapping):像操作系统把页换到磁盘一样,把它的 KV 块拷到 CPU 内存(vLLM 有一个 CPU 块分配器);恢复时再拷回来。换出的总量不会超过 GPU 上 KV 显存的大小,所以 CPU 上需要的交换空间是有界的。
- 重算(recomputation):直接扔掉 KV,恢复时把”提示 + 已经生成的 token”当成一个新提示,一次 prefill 全部算回来。这比当初逐个 token 生成快得多:prefill 是并行的、compute-bound 的。
代码 04 演示 1 用纸上 GPU 估算恢复一个有 512 个 token 的请求要多久(CPU↔GPU 带宽 A100 取 25 GB/s、H100 取 50 GB/s,每拷贝一个块有 10 µs 固定开销,都是假设值):
| 场景 | 重算 | 换出 + 换入(块 1) | (块 16) | (块 256) |
|---|---|---|---|---|
| OPT-13B 规模 + A100-40GB(512 token = 419 MB) | 60.0 ms | 43.8 ms | 34.2 ms | 33.6 ms |
| Llama-3-8B + H100(512 token = 67 MB) | 10.9 ms | 12.9 ms | 3.3 ms | 2.7 ms |
重算的时间与块大小无关;换出在块很小时被”每块的固定开销”主导,块越大越接近纯带宽时间。vLLM 论文实测的曲线是同样的形状,但结论是”块大小 16–64 时两者端到端表现相当”,而在我们的假设下换出只要重算的三到六成。真实系统里的有效带宽、拷贝的实现方式、重算能否和别的请求合在同一步里算,都会改变这个比例。这里的数字只说明机制。
代码 04 演示 2 在引擎里比较两种方式(场景 A):
| 到达率 | 恢复方式 | 抢占次数 | 重算的 token | 换入换出时间 | 吞吐 tok/s | SLO 达成 |
|---|---|---|---|---|---|---|
| 3 | recompute | 323 | 86,855 | 0 | 722 | 35% |
| 3 | swap | 324 | 0 | 5.7 s | 724 | 36% |
| 4 | recompute | 398 | 107,354 | 0 | 725 | 8% |
| 4 | swap | 390 | 0 | 6.9 s | 729 | 9% |
负载越高,显存越紧,抢占越频繁:到达率 3 以上有几百次抢占、八万到十万多个 token 被重算。两种恢复方式的端到端结果相差无几,与论文的结论一致。
5.3 水位线
还有一个小设计:vLLM 接纳新请求时会留一点余量(水位线 watermark),分配之后至少还要剩下一小部分空闲块。否则新请求刚进来,正在 decode 的请求下一步就申请不到块,立刻触发抢占。练习 3 会看到,水位线从 0 提到 5%,抢占次数从五百多次降到八十多次,吞吐几乎不变;可水位线太高(20%)就等于又在”预留”了,吞吐和 SLO 都变差。
6. vLLM:系统架构与评测 · vLLM: system & evaluation
6.1 架构
vLLM 是一个集中式调度器 + 分布式 GPU worker 的结构:
- 调度器(CPU 上)负责连续批处理的每一步决策,并管理 KV cache 管理器:GPU 块分配器、CPU 块分配器(换出用)和每个序列的块表。
- 每个 GPU worker 有一个缓存引擎(存放物理块)和一份模型分片。多卡时用 Megatron-LM 式的张量并行(第 6 讲):每块卡只存每个注意力头的一部分 KV,但所有 worker 共用同一张块表。调度器每一步把这一步的 token 和块表广播给所有 worker,worker 按 SPMD 方式各算各的部分,通过 all-reduce 同步中间结果。
- 模型执行器只看到一列物理块号。并行采样、beam search、共享前缀这些共享关系,都只在块表里体现。
6.2 评测
- 模型与硬件:OPT-13B(1 块 A100)、OPT-66B(4 块)、OPT-175B(8 块)、LLaMA-13B 等。
- 负载:用 ShareGPT(用户分享的 ChatGPT 对话)和 Alpaca(GPT-3.5 自指令生成的指令数据)的输入输出长度合成请求,泊松到达。ShareGPT 的提示平均是 Alpaca 的 8.4 倍长,输出是 5.8 倍长,方差也更大。
- 基线:FasterTransformer(配一个动态批处理调度器);作者自己实现的三种 Orca(论文代码未公开),区别在于为输出预留多少:Orca (Max) 预留到最大长度 2048,Orca (Pow2) 最多多预留一倍,Orca (Oracle) 假设知道真实输出长度(做不到,是上限)。Orca 用伙伴分配器管理显存地址。
- 指标:归一化延迟(每个请求的端到端延迟除以输出长度,再取平均),看系统在多高的请求速率下还能维持低延迟。
- 结果(论文报告):
- ShareGPT 上,同样延迟下 vLLM 能承受的请求速率是 Orca (Oracle) 的 1.7–2.7 倍、Orca (Max) 的 2.7–8 倍、FasterTransformer 的最多 22 倍。OPT-13B 上,vLLM 同时处理的请求数是 Orca (Oracle) 的 2.2 倍、Orca (Max) 的 4.3 倍。Alpaca 负载请求短,显存没那么紧张,提升较小。
- 并行采样、beam search、共享前缀的提升见 §4。
- 聊天负载(拼接对话历史,最多 1024 个 token 的提示):比三种 Orca 都能多承受约 2 倍的请求速率。
- 块大小:默认 16;换出 vs 重算:中等块大小下相当。
6.3 论文卡片:vLLM
| 项目 | 内容 |
|---|---|
| 论文 | Efficient Memory Management for Large Language Model Serving with PagedAttention,Kwon 等,SOSP 2023(UC Berkeley 等) |
| 一句话 | 借用虚拟内存与分页管理 KV cache,让显存浪费接近零,并让序列之间可以共享 KV |
| 问题 | 连续批处理之后,吞吐受限于能同时放进显存的请求数;KV cache 动态增长、长度未知 |
| 现有方案的缺陷 | 每个请求的 KV 存成按最大长度预留的连续空间:预留、内部碎片、外部碎片,有效利用率只有 20.4%–38.2%;无法共享 |
| 关键洞察 | KV cache 的管理问题和操作系统的内存管理问题同构:块 ↔ 页,序列 ↔ 进程,token ↔ 字节 |
| 机制 | 块与块表、按需分配;PagedAttention 内核按块表读 KV;引用计数 + 写时复制支持并行采样、beam search、共享前缀;FCFS + 全有或全无的抢占,换出或重算;集中式调度器 + 共用块表的张量并行 worker |
| 评测 | OPT-13B/66B/175B、LLaMA-13B,A100;ShareGPT / Alpaca 合成负载;同样延迟下请求速率是 Orca 的 1.7–8 倍、FasterTransformer 的最多 22 倍(论文报告) |
| 局限与假设 | 注意力内核多了 20%–26% 的开销;收益大小取决于显存有多紧张(没有 GQA 的模型、小显存的卡收益最大);共享前缀需要事先指定;抢占会带来重算或换出的开销 |
| 与本课的关系 | 分页 KV 是今天所有推理引擎的默认做法;第 4 讲的 FlashInfer 进一步优化读散落 KV 的内核;第 9 讲把块放到更多层级的存储上;第 12 讲的 Prism 借用”虚拟内存”的思路在多个模型之间共享显存 |
7. SGLang:结构化的 LM 程序 · SGLang
7.1 从”一个请求”到”一个程序”
vLLM 优化的是”一个个独立的请求”。可 LLM 应用越来越多地是程序:一次任务里调用模型很多次,中间有控制流(根据上一次的输出决定下一步)、有并行(同时问几个问题)、要求结构化的输入输出(JSON)。例如:
- agent:反复”思考 → 调用工具 → 看结果 → 再思考”,每一步的提示都包含前面所有的历史;
- few-shot 评测:几千个问题共享同一组示例;
- 思维树、自洽性(self-consistency):同一个提示采样很多次;
- 多轮对话:每一轮的提示 = 之前所有轮 + 新问题;
- RAG:同一段检索到的文档后面跟不同的问题。
这些程序有大量的跨调用共享前缀和格式约束。SGLang 由两部分组成:一个嵌入 Python 的前端语言,让写这类程序变简单;一个运行时(SGLang Runtime),用 RadixAttention、压缩有限状态机等技术把它们跑得更快。
前端提供的原语包括:gen(调用模型生成,可以带正则约束)、select(从几个选项里选概率最高的)、extend(往提示里追加内容)、fork(把当前提示复制成几份并行执行)、join、image / video(多模态输入)。程序的状态 s 像一个不断变长的提示,原语被异步地提交给运行时执行。前端和运行时可以配合:执行 fork 时,前端先把公共前缀发给运行时作为”提示”(frontend hint),确保它被正确地插入缓存,再发送各分支。
本讲聚焦运行时的两项技术:RadixAttention 和压缩有限状态机。
7.2 RadixAttention
动机:KV cache 只依赖前缀。因果注意力里,位置 的 K、V 只依赖 (第 1 讲 §2.2)。所以两个请求只要开头相同,开头部分的 KV 就完全相同,后来的请求可以直接复用。当时的大多数系统在请求结束后就丢掉它的 KV,下一个请求即使有相同的开头也要重算;vLLM 的共享前缀需要人工指定,而且处理不了动态的树形结构(例如 agent 的分支)。
设计:SGLang 在请求结束后不丢弃 KV,而是把”token 序列 → KV cache”的映射保存在一棵基数树里:
- 基数树(radix tree):前缀树(trie)的压缩版。trie 的每条边是一个 token;基数树把只有一个孩子的链合并成一条边,边上是一段 token 序列。查找、插入、匹配最长前缀都很快,也省空间。
- 页大小是 1 个 token:KV 存在一个按 token 分页的显存池里(和 vLLM 的分页是同一个思想,只是块大小为 1),树的每个节点记着它那段 token 的 KV 在池子里的位置。
- LRU 淘汰叶子:显存满了就淘汰最久没用的叶子。先淘汰叶子,公共的祖先(例如系统提示)就能继续被复用,直到它们自己也变成叶子。
- 引用计数:正在运行的请求用到的节点不能淘汰。每个节点记着有几个运行中的请求在用它,计数为 0 才可以淘汰。
- 缓存和运行中的请求共用一个显存池:不预先划出一块固定大小的缓存。等待的请求多了,系统会淘汰缓存,把空间让给更大的批。
- 张量并行:每块卡存自己那一份 KV,树的操作在所有卡上一样,不需要额外同步。
代码 05 演示 1 在迷你模型上做了一遍(radix_cache.RadixCache + 页大小为 1 的 PagedStore):
| 请求 | 提示长度 | 命中 | 实际 prefill | 与从头生成相同 |
|---|---|---|---|---|
| r0(系统提示 + 示例 + 问题 1) | 98 | 0 | 98 | True |
| r1(同一前缀 + 问题 2) | 99 | 89 | 10 | True |
| r2(同一前缀 + 问题 3) | 97 | 83 | 14 | True |
| r3(r1 的下一轮:r1 的提示 + r1 的回答 + 新问题) | 125 | 110 | 15 | True |
后来的请求都命中了共享前缀,只 prefill 自己不同的部分。r3 是 r1 的下一轮对话,它还命中了 r1 的问题和回答,多轮对话的历史被自动复用。所有输出都和从头生成完全相同。打印出来的树把共享关系画得很清楚:共享前缀是公共的祖先,各请求不同的部分是它下面的分支。
7.3 缓存感知调度
缓存的容量有限时,请求的处理顺序会影响命中率。如果调度器在不相关的请求之间来回切换,刚缓存的前缀很快就被别的请求挤掉,这叫缓存抖动(thrashing)。SGLang 的缓存感知调度是:等待队列里的请求按”能在当前缓存里匹配多长的前缀”排序,最长共享前缀优先(longest-shared-prefix-first,LSPF),而不是先来先服务。
论文还证明了一个定理(定理 3.1):一批请求全部已知时,只要缓存容量不小于最长的请求,按请求前缀树的深度优先顺序处理能达到最优的命中率,而 LSPF 等价于深度优先顺序。在线的情况下(请求陆续到达),深度优先的顺序会被打乱,但调度仍然近似它。
我们的模拟(05_radix_attention.simulate):服务器一次处理一个请求,等待队列最多 32 个;处理一个请求时先在树里匹配,再把”提示 + 输出”插进树,超出容量就按 LRU 淘汰。
代码 05 演示 2:few-shot 负载(8 个任务,每个任务的提示 = 100 token 的公共系统提示 + 500 token 的任务示例 + 独有的问题;400 个请求随机交错,一开始全部提交):
| 缓存容量(token) | FCFS | 最长前缀优先 | 离线最优(深度优先) | LSPF 的最长等待 |
|---|---|---|---|---|
| 1000 | 24.0% | 81.9% | 89.5% | 66 步 |
| 2000 | 42.1% | 82.8% | 89.5% | 70 步 |
| 4000 | 66.5% | 86.3% | 89.5% | 94 步 |
| 8000 | 89.3% | 88.9% | 89.5% | 206 步 |
- 缓存装不下所有任务的前缀时,FCFS 下请求在任务之间来回切换,命中率很低;LSPF 把同一任务的请求排在一起,命中率高得多,达到离线最优的九成以上(它只能在队列里的 32 个请求中挑选)。论文报告,在各个基准上缓存感知调度平均达到最优命中率的 96%。
- 代价是公平性:请求可能在队列里被不断插队,最长等了 66–206 步,而 FCFS 最多等 31 步(队列长度减 1)。论文也指出贪心的缓存感知调度可能导致饥饿,与公平调度的结合留作未来工作。
- 缓存足够大时,顺序就不重要了。
代码 05 演示 3:多轮对话负载(60 个会话 × 5 轮;后一轮在前一轮处理完 5–40 步之后才到达):
| 缓存容量 | FCFS | 最长前缀优先 | LSPF 的最长等待 |
|---|---|---|---|
| 2,000 | 18.4% | 19.0% | 31 步 |
| 8,000 | 20.1% | 36.8% | 53 步 |
| 32,000 | 49.3% | 89.8% | 192 步 |
| 128,000 | 89.8% | 89.8% | 192 步 |
多轮对话的命中主要来自同一个会话的历史,缓存越大,越多会话的历史能留到下一轮。缓存居中时 LSPF 的作用很大:它优先处理”历史还在缓存里”的后续轮次,而不是刚开始的新会话(新会话只能匹配 100 token 的系统提示)。于是同时活跃的会话变少,它们的历史不容易被挤掉,相当于自动做了”会话亲和”。代价同样是新会话的等待。这个结果和我编写前的预想不一样:我原以为后续轮次要等用户回复,调度器排不了多少顺序。
代码 05 演示 4 把命中换算成 prefill 时间(纸上 GPU,Llama-3-8B @ H100,few-shot 负载,缓存 2000):不缓存 5.58 秒,FCFS(命中 42.1%)3.95 秒,LSPF(命中 82.8%)2.71 秒。省下的时间比例明显小于命中率(命中 83% 只省下约一半),原因有两个:未命中的问题部分仍要和命中的前缀做注意力;几百个 token 以下的 prefill 是 memory-bound 的,有一个”读一遍权重”的下限(第 1 讲 §3.3)。反过来,对输出很长、提示几乎不共享的负载,前缀缓存几乎没用,时间都花在 decode 上。论文也提到,多轮对话里输出很长时几乎没有加速。
7.4 压缩有限状态机
很多 LM 程序要求输出符合固定格式(JSON schema、正则表达式)。SGLang 的 gen 可以带一个 regex 参数。当时的常见做法是把正则表达式转成一个有限状态机(FSM):解码时记住当前状态,只允许”能让 FSM 走下去”的 token,把其他 token 的概率设成 0,一个 token 一个 token地走。
问题在于,格式里有大量固定的部分。例如 {"summary": " 只有一种写法,可普通的约束解码仍然要一个 token 一次 forward 地”生成”它们。SGLang 的压缩有限状态机把 FSM 里”只有一条出边”的相邻边合并成一条边:解码时,一旦走到这样的链上,就一次 forward 把整条链都送进去(相当于一次小 prefill,SGLang 称为 jump-forward),然后继续正常解码。
代码 06 演示 1(迷你模型是字符级的,格式是 name is [a-z]{3,8}, age is [0-9]{1,2}, city is [a-z]{3,8}.):
| 种子 | 输出(压缩 FSM) | 合法 | 普通 FSM 的 forward | 压缩 FSM 的 forward | 两者相同 |
|---|---|---|---|---|---|
| 0 | name is qfabrxwq, age is 68, city is xazavevl. | True | 46 | 18 | True |
| 3 | name is bduqbonb, age is 70, city is hklru. | True | 43 | 16 | True |
5 个样本合计,forward 次数从 222 次降到 85 次(少 62%)。不加约束时,随机权重的模型写出的是乱码。注意几点:
- 约束解码保证的是格式,不是内容的质量:随机模型也能写出合法的表单。
- 两种 FSM 的输出完全相同:在固定部分,普通 FSM 的一次 forward 也只是”选出唯一合法的那个 token”;压缩 FSM 把这些必然的选择合并成一步。有一个实现细节:只有一个合法 token 时不能消耗随机数,否则两种方式的随机数序列会错开。
- 省下的 forward 次数正好等于固定字符的个数(演示 2:一个键名更长的”记录”格式,固定字符占 74%,forward 从 88 次降到 23 次)。
真实模型用 BPE 一类的子词词表,事情更复杂:一段固定字符串可能有好几种切分方式,模型训练时见过的切法和”逐字符拼出来”的切法可能不同;跳过一段之后,下一个 token 可能和前面的字符合并成一个 token。SGLang 论文的附录讨论了怎样在 jump-forward 之后重新分词来处理这些边界问题。我们的字符级模型没有这个问题。
7.5 API 推测执行
对只能通过 API 调用的模型(例如 GPT-4),运行时没法修改推理过程。SGLang 的做法是推测执行:一个程序里连续的两次 gen(例如先生成”名字”、再生成”生日”),第一次调用时让模型多生成一些(忽略第一个停止条件),如果多出来的部分恰好符合后面的格式,就直接用它填满第二个 gen,省掉一次 API 调用(和它的输入 token 费用)。这和第 5 讲的投机解码思路相似:多做一点,赌它有用。
7.6 评测与论文卡片
- 设置:开源模型(Llama 系列、Mixtral 等)在 A10G、A100 上;任务包括 MMLU(5-shot)、HellaSwag、ReAct 和 generative agents 两种 agent、思维树、skeleton-of-thought、JSON 解码、多轮对话、DSPy 的 RAG 流水线、LLaVA 多模态。基线:vLLM、Guidance、LMQL。
- 结果(论文报告):吞吐最高提高 6.4 倍、延迟最多降低 3.7 倍;各基准上的缓存命中率在 50%–99% 之间,缓存感知调度平均达到最优命中率的 96%;多模态模型上吞吐最高 6 倍;在 Chatbot Arena 部署一个月,LLaVA-Next-34B 的命中率 52.4%、Vicuna-33B 的命中率 74.1%(来自公共的系统消息、反复出现的示例图片和多轮对话历史),Vicuna-33B 的首 token 延迟平均降低 1.7 倍。
| 项目 | 内容 |
|---|---|
| 论文 | SGLang: Efficient Execution of Structured Language Model Programs,Zheng 等,NeurIPS 2024(Stanford、UC Berkeley 等) |
| 一句话 | 一门写 LM 程序的前端语言,加上一个自动复用跨调用 KV、加速结构化输出的运行时 |
| 问题 | LLM 应用变成多次调用、带控制流和结构化输入输出的程序,有大量共享前缀和格式约束 |
| 现有方案的缺陷 | 推理系统按独立请求处理,请求结束就丢掉 KV;前缀共享要人工配置;约束解码逐 token 进行 |
| 关键洞察 | 把所有请求的 KV 组织成一棵按 token 序列索引的基数树,前缀复用就成了自动的、通用的;FSM 里只有一条出边的链可以一次走完 |
| 机制 | 前端原语(gen / select / extend / fork / join)与前端提示;RadixAttention(页大小 1 的显存池、LRU 淘汰叶子、引用计数、缓存与运行中的请求共用显存);缓存感知调度(最长共享前缀优先,离线下深度优先最优);压缩有限状态机(jump-forward);API 推测执行 |
| 评测 | 多种 LM 程序基准,吞吐最高 6.4 倍、延迟最多降低 3.7 倍;命中率 50%–99%;线上部署命中率 52.4% / 74.1%(论文报告) |
| 局限与假设 | 缓存感知调度可能导致饥饿;收益取决于前缀共享的多少,输出很长的负载收益很小;树的维护在 CPU 上 |
| 与本课的关系 | 前缀缓存成了主流引擎的标配;第 9 讲把缓存扩展到 CPU 内存和 SSD;第 11 讲在集群层面按前缀路由;第 13 讲以”程序”为单位调度 agent;第 16 讲是 SGLang 的工程全景 |
7.7 vLLM 与 SGLang 的两种前缀复用
两篇论文都在做”让 KV 被共享”,但切入点不同:
| vLLM(论文版本) | SGLang | |
|---|---|---|
| 共享的范围 | 一个请求内的多个序列;预先指定的共享前缀 | 任意请求之间,自动发现 |
| 数据结构 | 块表 + 引用计数 | 基数树 + 引用计数(底层也是分页的显存池) |
| 粒度 | 块(16 个 token) | 1 个 token |
| 请求结束后的 KV | 释放 | 留在树里,直到被 LRU 淘汰 |
| 调度 | FCFS | 可以按前缀匹配长度排序(缓存感知) |
后来两者互相借鉴:vLLM 加入了自动前缀缓存(automatic prefix caching),用”块内的 token + 前一块的哈希”给每个满块算哈希值,相同前缀的块自然就有相同的哈希,复用粒度是完整的块(练习 6);SGLang 的运行时也用分页的显存池。今天这已经是推理引擎的标准能力。
8. 之后的发展 · Since then
(这一节是我的补充,不是原课内容。)
- 分页 KV 成为标准:vLLM、SGLang、TensorRT-LLM、TGI 等主流引擎都用分页的 KV cache。注意力内核也随之演化:第 4 讲的 FlashInfer 把分页、基数树等各种 KV 布局统一成块稀疏矩阵来处理。
- 不改内核的分页:既然 GPU 也有虚拟内存,能不能让 KV 在虚拟地址上保持连续、只在物理上按需分配?2024 年的 vAttention 就用 CUDA 的虚拟内存管理接口这样做:注意力内核完全不用改。第 12 讲的 Prism 用类似的机制在多个模型之间弹性地分配显存(kvcached)。
- 前缀缓存默认开启:vLLM 在 2025 年重写的 V1 架构里默认开启自动前缀缓存;SGLang 的基数树缓存一直是默认的。
- 更大的缓存:显存装不下的 KV 可以放到 CPU 内存、本地 SSD 甚至别的机器上(SGLang 的分层缓存、LMCache、Mooncake,第 9 讲)。
- 集群层面的复用:多副本部署时,要把请求路由到”已经缓存了它的前缀”的那台机器上,同时兼顾负载均衡(第 11 讲 Preble、第 16 讲)。
- 结构化输出:约束解码已经成为推理引擎的常规功能。XGrammar 等专门的语法引擎支持上下文无关文法(例如完整的 JSON schema),并把”允许的 token 集合”预先算好、缓存起来,开销很小。
9. 小结 · Summary
- 连续批处理之后,吞吐受限于能同时放进显存的请求数。连续存储的 KV cache 有三种浪费:预留、内部碎片、外部碎片(vLLM 测得当时系统的有效利用率只有 20.4%–38.2%),而且无法在序列之间共享。
- PagedAttention 借用虚拟内存与分页:KV 切成固定大小的块,每个序列一张块表,按需分配。浪费限制在每个序列不到一块,没有外部碎片;注意力按块表读散落的块,结果与连续存储相同(代码 01)。
- 引用计数 + 写时复制让并行采样、beam search、共享前缀的序列共享 KV(代码 03:并行采样省 27%–47%,beam search 省 47%–80%,结果不变)。
- 分页不预留,可能半路显存不够:vLLM 用 FCFS + 抢占最晚到达的请求、全有或全无,恢复时换出或重算;水位线减少抢占(代码 04、练习 3)。
- 在显存紧张的场景(模仿论文:没有 GQA 的 13B 模型 + 40 GB 显存),分页能承受的流量是按最大长度预留的 5.2 倍、神谕预留的 1.4 倍(代码 02);显存宽松时(GQA + H100)收益小得多。显存管理的收益取决于系统有多受显存限制。
- SGLang 把 LLM 应用看成程序。RadixAttention 用基数树自动复用跨请求的前缀 KV:LRU 淘汰叶子、引用计数保护运行中的请求、缓存与运行中的请求共用显存。缓存感知调度(最长共享前缀优先)在缓存紧张时大幅提高命中率,接近深度优先顺序的最优值,代价是公平性(代码 05)。
- 压缩有限状态机把格式里的固定部分一次 forward 写完,输出不变,forward 次数减少的量等于固定字符数(代码 06)。
10. 常见误区 · Common pitfalls
- “分页让注意力变快了。” 不是。分页的注意力内核反而略慢(vLLM 报告比 FasterTransformer 慢 20%–26%)。分页的收益来自能放进更多请求,从而批更大、吞吐更高。
- “块越小越好。” 块小内部碎片少,但内核读 KV 的并行度低、块表长、换出时小块传输多。vLLM 选 16 是折中;我们的模拟只建模了显存,所以看起来块越小越好(代码 02 演示 4),这是模拟的局限。
- “分页消除了一切浪费。” 它消除了预留和外部碎片,把内部碎片限制在每个序列不到一块;代价是要处理抢占(重算或换出也是浪费,代码 04 里有八到十万个 token 被重算)。
- “写时复制每写一个 token 都要复制。” 只在第一次往被共享的块里写时复制,而且只复制那一个块(通常是提示的末块);之后写的都是自己的新块。
- “前缀缓存的命中率就是省下的时间比例。” 省下的明显更少:未命中部分仍要对命中的前缀做注意力,短 prefill 有”读一遍权重”的下限,而 decode 时间完全不受影响(代码 05 演示 4)。
- “缓存感知调度总是更好。” 它提高命中率,代价是一些请求被反复插队(饥饿);缓存足够大时它没有收益。
- “约束解码会让模型变聪明。” 它只保证格式合法;内容由模型决定(随机模型照样写出合法的乱码)。在 BPE 词表下,强行拼出的切分方式还可能和模型训练时见过的不同,影响质量。
- “vLLM 比 Orca 快 2–4 倍,所以换成分页总能快这么多。” 倍数取决于显存有多紧张、请求有多长、模型有没有 GQA。用今天的模型和硬件,收益可能小得多(代码 02 演示 3)。
11. 练习 · Exercises
练习 3、5 的参考答案里的数字,是我把代码复制到一个临时目录、按题目修改后实际运行得到的(没有改动课程目录里的代码)。
练习 1(块的账本)。场景 A 的模型每个 token 的 KV 是 800 KiB(40 层 × 40 个 KV 头 × 128 维 × 2(K、V)× 2 字节)。(a) block_size = 16 时一个块多大?(b) 一块 A100-40GB 放完权重后能放 11,968 个 token 的 KV,一共多少个块?(c) 20 个请求同时运行时,内部碎片最多浪费多少显存?和”按最大长度 2048 预留”相比呢?
参考答案
(a) KiB = 12.5 MiB。 (b) 个块。 (c) 每个序列最多浪费 15 个 token(末块只写了 1 个),20 个请求最多 300 个 token ≈ 234 MiB,约占 KV 显存的 2.5%。按最大长度预留时,一块卡只能放 个请求,根本放不下 20 个;就算放得下,一个最终长度 500 的请求也要浪费 1548 个 token 的预留空间。
练习 2(并行采样的节省)。代码 03 演示 1:提示 62 个 token,n = 4 个回答,每个 40 个 token,block_size = 8。(a) 不考虑块的粒度,用公式 估算节省比例。(b) 按块精确地数一数,为什么实测是 40%?
参考答案
(a) 。 (b) 不共享时每个回答需要 块,共 52 块。共享时:提示占 8 块,前 7 块是满的(56 个 token),第 8 块只写了 6 个。4 个序列共享前 7 块。第 8 块(提示的末块)要写入各自的新 token,于是 3 个序列各写时复制一次,第 4 个直接用原块,所以每个序列各有 1 个”末块”。之后 token 62–101 的剩余部分(每个序列 40 − 2 = 38 个 token)各需要 个新块。合计 块,节省 。差别来自提示末块:它被每个序列各复制了一份,没有共享成功。块越大,这部分损失越大。
练习 3(水位线)。把场景 A(到达率 3)的 PagedMemory 水位线分别设为 0、1%、5%、10%、20%,比较抢占次数、重算的 token 数、吞吐和 SLO 达成率。
参考答案
| 水位线 | 抢占 | 重算 token | 吞吐 tok/s | SLO 达成 |
|---|---|---|---|---|
| 0 | 524 | 145,240 | 709 | 32% |
| 1%(默认) | 323 | 86,855 | 722 | 35% |
| 5% | 81 | 22,707 | 727 | 34% |
| 10% | 18 | 5,596 | 714 | 22% |
| 20% | 0 | 0 | 671 | 9% |
没有水位线时,新请求一进来就把显存用满,正在 decode 的请求马上申请不到块,抢占最多,重算最多。适度的水位线(1%–5%)大幅减少抢占,吞吐还略有提高。水位线太高就等于又在”预留”:空着的显存放不进新请求,平均批变小,吞吐和 SLO 都变差。这是”预留”和”抢占”之间的一个连续旋钮。
练习 4(换出还是重算)。用 hw_model 算一算:Llama-3-8B + H100,一个有 4096 个 token 的请求被抢占,重算要多久?换出 + 换入要多久(50 GB/s,不计每块的固定开销)?哪个更划算?换成 70B 模型(假设能放进一块卡)呢?
参考答案(要点)
重算 = 4096 个 token 的 prefill,prefill_cost(LLAMA3_8B, H100_SXM, 4096) 给出 89.4 ms。换出 + 换入: KiB ≈ 537 MB,两个方向共 21.5 ms,约是重算的四分之一。70B 模型(假设放得下):重算 842.3 ms(prefill 的计算量 ∝ 参数量,约是 8B 的 9 倍),KV 是 1342 MB(每 token 320 KiB),换出 + 换入 53.7 ms,只有重算的约 6%。模型越大,重算越贵;而 KV 的大小 ∝ 层数 × KV 头数,GQA 让它比参数量增长得慢,所以换出越来越划算。(这里没算每块的固定开销,也没考虑重算可以和其他请求合在同一步里。)
练习 5(缓存比一个请求还小)。SGLang 定理 3.1 要求”缓存容量不小于最长的请求”。把代码 05 演示 2 的缓存容量设成 300、500、650、800(最长的请求是 739 个 token,共享前缀是 600 个 token),看看三种顺序的命中率。
参考答案
容量 300、500、650 时,三种顺序的命中率全部是 0;容量 800 时恢复为 24.0%(FCFS)、81.9%(LSPF)、89.5%(深度优先)。容量 650 虽然装得下 600 个 token 的共享前缀,但每处理一个请求都要插入它完整的提示和输出(最长 739 个 token),超出容量就得淘汰;被淘汰的叶子最终连同共享前缀一起被清掉,下一个请求什么也匹配不到。真实系统里,缓存和运行中的请求共用显存,一个请求自己都放不下的情况本来就不会发生;这个练习说明了定理的前提为什么必要。
练习 6(两种前缀缓存的实现)。vLLM 的自动前缀缓存给每个满块算一个哈希:,用哈希表找可复用的块;SGLang 用基数树,粒度是 1 个 token。(a) 为什么哈希要包含前一块的哈希 ?(b) 两种实现在复用粒度、匹配开销、淘汰策略上各有什么特点?
参考答案(要点)
(a) KV 依赖整个前缀,而不只是这一块的 token。两个序列的第 块 token 相同、但前面不同,它们的 KV 就不同,不能复用。把前一块的哈希链进来, 就唯一地代表了”从开头到第 块”的整个前缀(类似区块链或 Merkle 链)。 (b) 粒度:哈希方案只能复用完整的块,最后一个不满的块(以及前缀里不满一块的尾巴)要重算;基数树能精确到每个 token。匹配:哈希方案逐块查表,每块一次 查找,实现简单,与分页的块管理天然统一;基数树要沿树走、可能拆分节点,维护在 CPU 上,但能直接给出”最长匹配”,也方便做缓存感知调度。淘汰:都可以对引用计数为 0 的单元做 LRU;哈希方案按块淘汰(通常从链的尾部开始),基数树按叶子淘汰。两者在功能上已经趋同。
练习 7(共用显存池)。SGLang 让缓存的 KV 和运行中请求的 KV 共用一个显存池,而不是预先划出一块固定大小的缓存。这样做的好处和风险各是什么?
参考答案(要点)
好处:负载低时,空闲的显存全部用来缓存,命中率最高;负载高时,等待的请求需要空间,系统淘汰缓存,把显存让给更大的批,不会因为”缓存占着地方”而降低吞吐。容量在两种用途之间自动调节。风险:缓存的价值和批大小的价值不在同一个尺度上。淘汰一段马上就会被大量请求复用的前缀,可能比少放一个请求更亏。系统需要某种策略来衡量”这段缓存值多少”(第 10 讲的 Marconi 会在混合架构下讨论按”节省的计算量”淘汰)。另外,负载在高低之间快速波动时,缓存会被反复清空、重建。
练习 8(FSM 的歧义)。如果格式是 [a-z]{1,8}and[0-9](字符类后面紧跟固定字符串 “and”),我们的 TemplateFSM 会出什么问题?真实系统怎么处理?
参考答案(要点)
在字符类段里读到字母 a 时,FSM 不知道它是”字符类里的又一个字母”,还是”固定字符串 and 的开头”。下一步该走哪个状态不唯一,所以这个 FSM 是不确定的(NFA)。我们的简化实现假设字符类和下一段的首字符不重叠,处理不了这种格式。真实系统先把正则表达式转成 NFA,再用子集构造法(subset construction)转成确定的 FSM(DFA):DFA 的一个状态代表”NFA 可能处于的一组状态”。在这个例子里,读到 a 之后 DFA 处于”既可能还在字母段、也可能刚读完 and 的第一个字符”的状态,它允许的下一个字符是”字母”和”n”的并集。压缩 FSM 只压缩 DFA 里真正只有一条出边的链;这种有歧义的地方不会被压缩。
练习 9(思考:兼顾命中率和公平)。§7.3 看到最长前缀优先会让一些请求长时间等待。设计一个兼顾两者的排序规则,并说明它需要哪些参数。
参考答案(要点)
一个简单的做法是”老化”(aging):优先级 = 匹配长度 + α × 已等待时间(或步数)。等得越久的请求优先级越高,最终总会被调度;α 控制公平与命中率之间的权衡。另一种做法是给等待时间设上限:等待超过 T 的请求按 FCFS 强制调度,其余请求之间按匹配长度排序。还可以只在一个”时间窗口”(例如最近到达的 W 个请求)内按前缀排序。参数(α、T、W)都要根据 SLO(例如 TTFT 的 P99 目标)来调。这和第 2 讲短作业优先的饥饿问题是同一类问题。
12. 延伸阅读 · Further reading
本讲的必读论文
- W. Kwon et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023.
- L. Zheng et al. SGLang: Efficient Execution of Structured Language Model Programs. NeurIPS 2024.
相关工作
- R. Prabhu et al. vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention(2024):用 CUDA 虚拟内存实现”虚拟连续、物理按需”。
- B. T. Willard, R. Louf. Efficient Guided Generation for Large Language Models(2023,Outlines):把正则表达式转成 FSM 做约束解码,SGLang 的压缩 FSM 建立在这类方法之上。
- Y. Dong et al. XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models(2024):上下文无关文法的高效约束解码。
- vLLM 文档中关于 Automatic Prefix Caching 的设计说明(按块哈希的前缀缓存)。
背景
- 任何一本操作系统教材里讲虚拟内存、分页、写时复制、页面置换的章节(例如 Arpaci-Dusseau, Operating Systems: Three Easy Pieces 的 Virtualization 部分,免费在线阅读)。
13. 下一讲预告 · Next lecture
前三讲一直把”注意力怎样算”当成理所当然:第 2 讲的选择性批处理让每个请求各自算注意力,本讲的 PagedAttention 让注意力从散落的块里读 KV。可注意力恰恰是推理里最难做快的算子:prefill 时它的计算量随上下文长度平方增长,朴素实现还要把 的分数矩阵写回显存;decode 时它要把每个请求整段 KV 读一遍,第 1 讲看到这部分读取在批大、上下文长时占了搬运量的大头。第 4 讲:高效注意力内核读两篇论文:FlashAttention 用分块和在线 softmax,让注意力不再把中间结果写回显存;FlashInfer 把分页、基数树、树形注意力等各种 KV 布局统一成块稀疏格式,并用负载均衡的调度处理一批长短不一的请求。我们会在 numpy 里实现分块注意力和在线 softmax,验证它与标准注意力的结果一致,并数一数它省下了多少显存读写。
建议先读前置讲 P1(GPU 与性能的第一性原理):SRAM 与 HBM 的层级、算子融合,是第 4 讲的全部基础。