第21章:PagedAttention 与 vLLM
当我们把训练好的 LLM 部署到生产环境时,很快会遭遇一个让人沮丧的现实:一块 A100 80GB 的显卡,理论上可以同时服务几十路请求,但实际上往往只能跑十几路,GPU 利用率徘徊在 40% 左右。钱花出去了,算力却大半被浪费。
这个问题的根源,在于 KV Cache 的内存管理方式。本章从这个问题出发,介绍 vLLM 团队提出的 PagedAttention 方案——它借鉴了操作系统虚拟内存的思想,将显存利用率提升到 96% 以上,并将推理吞吐量提升 10-20 倍。
21.1 显存碎片化问题
传统 KV Cache 管理
在 Transformer 推理的 decode 阶段,每一步生成新 token 时,都需要访问之前所有 token 的 Key 和 Value 矩阵。为了避免重复计算,我们把这些矩阵缓存下来,这就是 KV Cache。
传统做法是:为每个请求预先分配一块连续的、大小等于最大序列长度的显存。
请求 A(最大长度 2048):[KKKK...KKKK | VVVV...VVVV] → 分配 2048 token 的空间
请求 B(最大长度 2048):[KKKK...KKKK | VVVV...VVVV] → 再分配 2048 token 的空间
请求 C(最大长度 2048):[KKKK...KKKK | VVVV...VVVV] → 再分配 2048 token 的空间
这种方式简单直接,但存在两个严重问题。
问题一:内部碎片(Internal Fragmentation)
预分配是按最大长度来的,但实际请求的长度千差万别。用户问"今天天气怎么样?",模型回答"今天晴,25度",整个对话加起来不超过 20 个 token——但显存中已经为它预留了 2048 个 token 的空间。
浪费比例:
即使我们取一个更现实的数字,假设平均实际长度是 200 token,则内部碎片率仍高达:
问题二:外部碎片(External Fragmentation)
不同请求的生命周期不同。请求 A 生成了 500 个 token 后完成,释放了它的显存块;这时来了请求 D,需要 600 个 token 的空间——但 A 留下的空洞只有 500,装不下,于是这块空间被闲置,形成碎片空洞。
显存布局示意:
[ 请求B(占用) ] [ 空洞(500) ] [ 请求C(占用) ] [ 空洞(300) ]
↑ 请求D需要600,这里装不下
:::warning 实际影响有多严重? 以 A100 80GB 为例:
- 理论显存容量:可支持约 50 路并发(按平均 1.6GB/请求估算)
- 实际因碎片:通常只能稳定运行 10-15 路并发
- GPU 计算单元的实际利用率:约 30-40%
等于花了旗舰显卡的价格,只用了入门显卡的算力。 :::
21.2 虚拟内存分页思想
操作系统的启示
面对同样的内存碎片问题,操作系统几十年前就给出了答案:虚拟内存分页(Virtual Memory Paging)。
OS 的做法是:把物理内存切成固定大小的页(Page),进程看到的是连续的虚拟地址空间,背后由**页表(Page Table)**映射到实际的物理页——这些物理页可以分散在内存的任何地方。
vLLM 把这个思想搬进了显存管理,称之为 PagedAttention。
Block 化:KV Cache 的"分页"
PagedAttention 的核心操作:
- 把 KV Cache 切成固定大小的 Block,每个 Block 存储固定数量(如 16 个)token 的 K/V 向量。
- 为每个请求维护一个 Block Table(逻辑块 → 物理块的映射表)。
- 请求在逻辑上看到连续的 KV Cache,实际物理块可以分散在显存任意位置。
逻辑视图(请求 A 看到的):
Block 0 → Block 1 → Block 2 → Block 3
物理视图(实际显存布局):
物理块 #7 物理块 #2 物理块 #15 物理块 #4
↑ ↑ ↑ ↑
逻辑块0 逻辑块1 逻辑块2 逻辑块3
Block Table(请求 A):
逻辑块 | 物理块
0 | 7
1 | 2
2 | 15
3 | 4
数学描述
设序列当前长度为 ,Block 大小为 (每块存 个 token),则所需物理块数为:
最坏情况下,最后一个 Block 的内部碎片最多为 个 token。当 时,每个请求最多浪费 15 个 token 的空间。
对比传统方案(最多浪费 个 token, 通常为 2048),Block 化将内部碎片的绝对上界从 2047 降低到了 15,降低了 99.3%。
:::info 为什么 Block 大小通常选 16? 这是一个工程权衡:
- 太小(如 1):Block Table 条目数过多,管理开销大
- 太大(如 256):内部碎片增加,灵活性降低
- 16:恰好是现代 GPU warp 大小的整数倍,内存访问对齐高效,管理开销合理 :::
Attention 计算的适配
在 Attention 计算时,Query 向量需要与所有历史 token 的 K/V 向量做点积。传统实现假设 K/V 存储在连续内存中;PagedAttention 需要根据 Block Table 收集分散的物理块。
在 PagedAttention 中, 和 是从多个非连续物理块中"拼接"读取的。vLLM 使用自定义 CUDA kernel 实现这一操作,使访问分散物理块的开销接近访问连续内存。
显存利用率的提升
| 指标 | 传统方案 | PagedAttention |
|---|---|---|
| 内部碎片(最坏) | token | token |
| 外部碎片 | 高(大块不可分) | 极低(细粒度块可重用) |
| 实测显存利用率 | ~40% | ~96% |
| A100 80GB 并发数 | ~15 路 | ~50 路 |
21.3 Continuous Batching(连续批处理)
解决了显存碎片问题,还需要解决另一个低效:如何让 GPU 始终保持忙碌?
传统静态 Batching 的木桶效应
传统推理服务使用静态 Batching:收集一批请求,等这批请求全部完成后,再处理下一批。
时间线:
批次1:请求A(10 token)、请求B(100 token)、请求C(50 token)
|--A--| B 和 C 还在生成,GPU 空跑
|----------C-----------|
|----------------------------B-------------------------------|
↑ 等 B 完成,整批才结束,然后才开始批次2
请求 A 在第 10 步就完成了,但它的 GPU 计算槽一直空着,等待请求 B 生成完毕才能释放。这就是木桶效应:最慢的请求决定整批的延迟。
:::warning 短请求是受害者 在静态 Batching 下,一个只需回答"是"的请求,可能要等待同批次中一个生成 1000 token 长文的请求完成。用户体验极差,GPU 利用率也低。 :::
Continuous Batching 的解法
Continuous Batching(也称 Iteration-level Scheduling)的核心思想:
每生成一个 token 后,立即检查哪些请求已完成;完成的请求立刻退出 batch,空出的槽位立即填入等待队列中的新请求。
时间线(Continuous Batching):
Step 1: [请求A, 请求B, 请求C]
Step 2: [请求A, 请求B, 请求C]
...
Step 10: [请求A完成! → 请求D进入, 请求B, 请求C]
Step 11: [请求D, 请求B, 请求C]
...
Step 50: [请求D, 请求B, 请求C完成! → 请求E进入]
GPU 的 batch 大小始终维持在最大值附近,计算资源得到充分利用。
与 PagedAttention 的协同
Continuous Batching 和 PagedAttention 是天然的搭档:
- 请求完成时,其 Block Table 中的所有物理块立即被标记为可用,无需等待
- 新进入 batch 的请求立即能从空闲物理块池中获取显存
- 整个过程细粒度、动态,没有大块显存的预留和释放延迟
| 特性 | 静态 Batching | Continuous Batching |
|---|---|---|
| 调度粒度 | 整批完成后 | 每个 token 生成后 |
| GPU 利用率 | 低(等待最慢请求) | 高(槽位持续填充) |
| 首 token 延迟 | 高(等待当前批次) | 低(有空位立即进入) |
| 吞吐量提升 | 基准 | 3-5× |
21.4 vLLM 系统架构
PagedAttention + Continuous Batching 的工程实现,就是 vLLM(加州大学伯克利分校开发,2023 年开源)。
整体架构
┌─────────────────────────────────────────────────┐
│ LLM Engine │ ← 中央控制器
│ │
│ ┌──────────────┐ ┌─────────────────────┐ │
│ │ Scheduler │ │ Block Manager │ │
│ │ │◄──►│ │ │
│ │ • 请求入队 │ │ • 物理块分配/释放 │ │
│ │ • batch 决策 │ │ • Block Table 维护 │ │
│ │ • swap 决策 │ │ • CPU-GPU swap │ │
│ └──────┬───────┘ └─────────────────────┘ │
│ │ │
└─────────┼───────────────────────────────────────┘
│ 下发执行计划
▼
┌─────────────────────────────────────────────────┐
│ Worker (GPU) │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Worker 0 │ │ Worker 1 │ ... │
│ │ (GPU 0) │ │ (GPU 1) │ │
│ │ │ │ │ │
│ │ 张量并行 │ │ 张量并行 │ │
│ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────┘
核心组件详解
LLM Engine(推理引擎)
整个系统的入口和协调者。接收外部的推理请求,维护全局状态,协调 Scheduler 和 Block Manager 的工作。
Scheduler(调度器)
Scheduler 在每个 step(每次生成一个 token)前做决策:
- 哪些等待队列中的请求可以进入 batch?(取决于是否有足够的空闲物理块)
- 哪些运行中的请求需要 swap 到 CPU?(当显存不足时,将低优先级请求的 KV Cache 换页到 CPU 内存)
- 哪些 swap 出去的请求可以换回 GPU?(有空闲块时重新激活)
这类似于 OS 调度器在 RAM 不足时将进程换页到磁盘的操作。
Block Manager(块管理器)
维护一个空闲物理块链表,负责:
- 为新请求分配物理块
- 在请求完成时回收物理块
- 实现 Copy-on-Write:当多个请求共享同一个 prompt 前缀时,它们可以共享相同的物理块,只有在需要写入时才复制(对 batch prompt 共享前缀特别有用)
Worker(执行器)
在 GPU 上实际执行模型计算。支持张量并行(Tensor Parallelism):将模型的权重矩阵按列/行切分到多个 GPU 上,适合超大模型的多卡推理。
性能基准
vLLM 与主流框架的吞吐量对比(LLaMA-13B,A100 80GB):
| 框架 | 吞吐量(token/s) | 相对提升 |
|---|---|---|
| HuggingFace Transformers | ~500 | 1× |
| HuggingFace TGI | ~2,000 | ~4× |
| vLLM | ~6,000-10,000 | 12-20× |
:::tip 何时选择 vLLM?
- 长文本生成:序列越长,PagedAttention 的优势越明显
- 高并发场景:Continuous Batching 的收益随并发数增加而增大
- 多种请求长度混杂:碎片问题最严重,PagedAttention 最有效
- 不适合:延迟极度敏感的单请求场景(batch size=1 时 vLLM 优势不明显) :::
本章小结
| 问题 | 解决方案 | 效果 |
|---|---|---|
| KV Cache 内部碎片(预分配浪费) | PagedAttention:Block 化管理,按需分配 | 内部碎片从 ~95% 降至 ~1% |
| KV Cache 外部碎片(空洞无法利用) | 细粒度物理块,任意请求可用 | 显存利用率从 ~40% 升至 ~96% |
| 静态 Batching 的木桶效应 | Continuous Batching:每 token 后重调度 | 吞吐量提升 3-5× |
| 系统集成复杂 | vLLM 引擎:Scheduler + Block Manager + Worker | 端到端提升 10-20× |
PagedAttention 解决的是显存利用率问题,Continuous Batching 解决的是GPU 计算利用率问题,两者合力让同一块硬件能服务更多用户——这是模型服务从学术走向生产的关键一步。
然而,提升吞吐量只是推理优化的一个维度。当用户追求的是更低的首 token 延迟,或者需要在边缘设备上运行模型时,我们需要另一套工具:模型量化与压缩。下一章将介绍如何在几乎不损失精度的前提下,将模型体积缩小 4-8 倍。