高性能 LLM 推理引擎面对的问题,与操作系统出奇地相似:有限内存要服务大量生命周期不同的任务;任务随时到达和退出;共享数据应该复用;长任务不能长期阻塞短任务;吞吐、公平性和延迟无法同时无限提高。
从这个角度看,PagedAttention 不只是一个 Attention Kernel,Continuous Batching 也不只是“把 Batch 做大”。它们共同构成了一套面向 Token 的内存管理和调度系统。
图 1:分页内存管理允许请求细粒度进入和退出,动态调度则把释放出来的块转化为更高有效 Batch。
一、概念映射
| 操作系统 | LLM 推理引擎 |
|---|---|
| 虚拟页 | 请求的逻辑 KV Block |
| 物理页 | GPU 上的物理 KV Block |
| 页表 | Block Table |
| 写时复制 | 并行采样中的 KV Copy-on-Write |
| 引用计数 | 共享 KV Block 的生命周期 |
| LRU | Prefix Cache 淘汰 |
| 进程队列 | Waiting/Running 请求队列 |
| 时间片或预算 | 每轮可调度 Token Budget |
| 抢占 | 释放 KV 后重计算或换出 |
这个类比不是说 vLLM 内部实现了完整操作系统,而是说明两者面对的是同一类资源分配问题。
二、连续分配为什么会浪费 KV Cache
假设系统为每个请求提前预留一段连续 KV Cache。问题很快出现:
2.1 内部碎片
系统按照最大输出长度预留,但请求可能提前遇到 EOS。没有使用的预留空间无法立刻服务其他请求。
2.2 外部碎片
请求不断到达和结束后,显存中会留下大小不一的空洞。空闲空间总量也许足够,却找不到一段满足新请求的连续区域。
2.3 过度预留
生成长度不可预测。如果不提前预留,Decode 增长时可能搬迁;如果按上限预留,又会浪费大量空间。
KV Cache 的困难不在于一次分配,而在于它会随着每个请求逐 Token 增长。
三、PagedAttention:把连续逻辑空间映射到离散物理块
PagedAttention 将 KV Cache 切成固定 Token 数的 Block。请求看到的是连续的逻辑块,实际物理块可以分散在显存中。
1 | 请求逻辑块: [0] [1] [2] [3] |
Attention Kernel 根据逻辑块号查询 Block Table,再计算物理块地址和块内偏移。这样请求不需要一段连续的物理显存。
3.1 碎片如何变化
- 外部碎片基本被固定大小块消除;
- 每个请求只有最后一个未填满 Block 产生有限内部浪费;
- Decode 时按需申请新块,不必按最大长度提前预留;
- 请求结束后,物理块可以直接回到空闲池。
Block Size 仍然需要权衡:
- 太大:最后一块浪费增加,细粒度复用下降;
- 太小:Block Table 更大,管理和寻址开销增加。
四、共享、引用计数和 Copy-on-Write
分页之后,不同请求的 Block Table 可以指向同一个物理 KV Block。
典型场景包括:
- 多个请求拥有相同系统 Prompt;
- Beam Search 或并行采样共享同一 Prompt;
- 多轮对话复用已经计算过的前缀。
共享块需要引用计数。只读阶段多个请求共同引用;当某个分支需要修改共享尾块时,再执行 Copy-on-Write:为它分配新块并复制必要内容。
这与 fork() 后父子进程共享内存页、写入时再复制的思想一致。
五、Prefix Cache:把共享从同时发生扩展到先后发生
PagedAttention 解决了“如何共享物理块”,Prefix Cache 进一步解决“如何发现历史请求已经计算过相同前缀”。
5.1 vLLM 的块哈希
一个块的哈希不仅取决于当前 Token,还要包含父块哈希。这样同一段 Token 出现在不同上下文中时,不会被错误认为是相同前缀。
可以把第 个块的键抽象为:
tokens_i 相同但父哈希不同,说明它们之前的上下文不同,不能共享同一份 KV。模型、LoRA Adapter、Cache Salt 等会改变计算语义的信息也必须进入键空间。实现通常只缓存已经填满的块,因为未填满尾块还会在 Decode 中继续写入,不适合作为稳定的共享对象。
新请求到来后,系统逐块查询缓存:
1 | 命中块 → 增加引用计数 → 跳过对应 Prefill |
Prefix Cache 主要降低重复前缀的 Prefill,因此最直接改善 TTFT,而不是 Decode 的 TPOT。
5.2 LRU 为什么还要结合引用计数
正在被请求使用的块不能淘汰,即使它很久没有被“新请求”命中。引用计数为 0 的缓存块才进入可淘汰集合,然后按 LRU 等策略回收。
5.3 RadixAttention
SGLang 使用 Radix Tree 表示 Token 前缀。公共前缀天然共享树路径,适合结构化、多轮和前缀复用明显的工作负载。
Hash Block 与 Radix Tree 没有绝对优劣:前者容易与分页块池结合,后者能更直接表示变长前缀结构。实际收益最终取决于 Prompt 是否真的重复。
六、Static Batching 的槽位空洞与队头阻塞
图 2:Static Batching 中短请求完成后的槽位保持空闲;Continuous Batching 在迭代边界立即补入新请求。
假设一个 Batch 有四个请求,分别需要生成 20、50、200、500 个 Token。Static Batching 通常要等最慢的请求完成,短请求结束后的槽位无法立即加入新任务。
结果是:
- 短请求被长请求拖累;
- 已完成请求占据 Batch 位置;
- GPU 有效 Batch 逐渐缩小;
- 尾延迟和吞吐同时恶化。
这类似让一组任务组成固定班级,必须等所有成员完成才能接收下一批。
七、Continuous Batching:每个 Token 都是一个调度点
Continuous Batching 在每次模型迭代后重新安排请求:
- 移除已经结束的请求;
- 释放或保留它们的 KV Block;
- 从等待队列选择新请求;
- 为本轮请求分配 Token 和 KV 预算;
- 组成新的 Batch 执行。
这是一种 Iteration-level Scheduling。调度单位不再是“完整请求 Batch”,而是“一轮需要处理的 Token”。
PagedAttention 与 Continuous Batching 互相依赖:如果 KV Cache 只能整段连续分配,请求频繁进入退出会带来昂贵搬迁;如果没有动态调度,分页节省的空间也难以转化成更高并发。
调度循环可以简化为下面的伪代码:
1 | def schedule_step(waiting, running, token_budget, block_pool): |
真实 vLLM V1 的策略比这段伪代码复杂,且版本会持续变化;这里想表达的稳定结构是:请求状态、Token 预算和 KV Block 预算必须在一次决策中共同满足。
八、长 Prefill 为什么会干扰 Decode
Decode 请求每轮只需要少量 Token,希望稳定、频繁地获得 GPU 时间。一个超长 Prompt 的 Prefill 如果一次执行完,会占据 GPU 较长时间,导致同批 Decode 请求的 TPOT 突然升高。
8.1 Chunked Prefill
Chunked Prefill 把长 Prompt 切成多个片段:
1 | 长 Prefill: [========================] |
它可能用更长一些的 TTFT,换取更平滑的 Decode TPOT 和更好的调度公平性;实际变化取决于 Token Budget、队列状态和请求长度分布。
8.2 Token Budget
统一调度器可以把每轮处理能力表示为 Token Budget:
1 | 本轮预算 = Decode Token + 新请求 Prefill Token + Chunked Prefill Token |
当长 Prefill 超过剩余预算时,只处理可以容纳的部分,下轮从 num_computed_tokens 继续。这比硬编码“先 Prefill 还是先 Decode”更容易统一不同请求状态。
九、抢占:显存不够时让谁退场
当物理 KV Block 用尽,调度器需要释放资源。常见恢复方式有:
- 重计算:丢弃某请求的 KV,之后重新 Prefill;
- 换出:将 KV 搬到 CPU 内存,需要时再搬回;
- 延迟新请求:保持运行请求不变,让新请求继续等待。
选择取决于 PCIe 带宽、Prompt 长度、重计算成本和延迟 SLO。重计算看似浪费算力,但可能比大规模 CPU–GPU KV 交换更稳定。
十、吞吐、公平性与 SLO
调度器至少要同时考虑:
- Batch 越大,权重读取的摊销通常越好;
- Batch 太大,单轮时间和 TPOT 上升;
- 长 Prompt 有利于填满计算,却可能阻塞 Decode;
- 高缓存命中可以降低 TTFT,但缓存本身占用显存;
- 抢占能避免 OOM,但会引入重计算或传输。
生产环境真正应该优化的是 Goodput:在 TTFT、TPOT 和错误率满足 SLO 的前提下,系统完成了多少有效请求。
十一、源码阅读路线
阅读 vLLM 时,可以围绕三个问题定位,而不是从仓库第一行开始:
- KV Block 如何申请、引用和释放;
- Scheduler 每轮如何决定哪些请求处理多少 Token;
- Attention Backend 如何根据 Block Table 读取 KV。
源码会持续演进,类名和文件路径可能变化,但“块池—请求状态—调度预算—执行后端”这四个角色比较稳定。
十二、技术 Q&A
Q1:PagedAttention 是否完全消除了 KV Cache 浪费?
它通过固定块和逻辑—物理映射基本消除了连续分配造成的外部碎片,也避免按最大长度过度预留;但每个请求的最后一个 Block 仍可能未填满,Block Table、引用计数和间接寻址也有管理成本。Block 越大尾块浪费越高,越小元数据和调度开销越高。
Q2:Prefix Cache 为什么主要改善 TTFT,且块哈希必须包含父块哈希?
命中缓存后,引擎可以跳过公共前缀的 Prefill,因此首 Token 更早产生;后续 Decode 仍要逐轮读取权重和历史 KV,所以 TPOT 通常不会直接缩短。由于 Transformer 的 KV 取决于当前 Token 之前的完整上下文,相同 Token Block 出现在不同父前缀后会产生不同 KV。将父块哈希纳入链式哈希,才能确保复用的是从起点到当前块都相同的完整前缀。
Q3:Continuous Batching 为什么依赖分页式 KV 管理?
迭代级调度要求请求可以每轮加入、退出和增长。若每个请求必须拥有一段按最大长度预留的连续 KV,新请求补位和旧请求释放会造成大量碎片或数据搬迁。固定块把请求生命周期的变化转化为块的申请与释放,使动态补位和显存回收的成本可控。
Q4:Chunked Prefill 与 Token Budget 共同决定了什么权衡?
Chunked Prefill 把长 Prefill 拆成较短片段,让 Decode 在片段之间获得 GPU 时间,从而降低 TPOT 抖动和 Head-of-Line Blocking;代价是长请求需要更多调度轮才能完成 Prefill,TTFT 可能增加。较大的 Token Budget 有利于形成大 Batch、提高吞吐并减少切块次数,但也会拉长单轮执行时间。两者需要结合请求长度分布和 TTFT/TPOT SLO 进行并发扫描,而不是独立地取极值。
十三、系列导航
上一篇:GPU 性能分析方法
下一篇:分布式训练的通信模型
十四、参考资料
论文与系统
- Efficient Memory Management for LLM Serving with PagedAttention:分页式 KV Cache 管理。
- Orca:Iteration-level Scheduling。
- SGLang: Efficient Execution of Structured Language Model Programs:RadixAttention 和结构化生成执行。
- SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills:Chunked Prefill。
- DistServe:Prefill/Decode 解耦与 Goodput。
文档与源码
- vLLM PagedAttention Design:Kernel 和块寻址设计。
- vLLM Automatic Prefix Caching:前缀块哈希和缓存语义。
- vLLM V1 Alpha Release:统一调度、Prefix Cache 和 CUDA Graph。
- vLLM V1 Core Source:KV Cache Manager、Block Pool 和 Scheduler。
- AIInfraGuide:PagedAttention、Continuous Batching、Prefix Cache 与 Chunked Prefill 的中文推导和源码导读,MIT License。