跳到主要内容

第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 的空间。

浪费比例(204820)/204899%(2048 - 20) / 2048 \approx 99\%

即使我们取一个更现实的数字,假设平均实际长度是 200 token,则内部碎片率仍高达:

内部碎片率=1实际使用长度预分配长度=1200204890%\text{内部碎片率} = 1 - \frac{\text{实际使用长度}}{\text{预分配长度}} = 1 - \frac{200}{2048} \approx 90\%

问题二:外部碎片(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 的核心操作:

  1. 把 KV Cache 切成固定大小的 Block,每个 Block 存储固定数量(如 16 个)token 的 K/V 向量。
  2. 为每个请求维护一个 Block Table(逻辑块 → 物理块的映射表)。
  3. 请求在逻辑上看到连续的 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

数学描述

设序列当前长度为 ss,Block 大小为 BB(每块存 BB 个 token),则所需物理块数为:

Nblocks=sBN_{\text{blocks}} = \left\lceil \frac{s}{B} \right\rceil

最坏情况下,最后一个 Block 的内部碎片最多为 B1B-1 个 token。当 B=16B=16 时,每个请求最多浪费 15 个 token 的空间。

对比传统方案(最多浪费 Lmax1L_{\max} - 1 个 token,LmaxL_{\max} 通常为 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 收集分散的物理块。

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V

在 PagedAttention 中,KKVV 是从多个非连续物理块中"拼接"读取的。vLLM 使用自定义 CUDA kernel 实现这一操作,使访问分散物理块的开销接近访问连续内存。

显存利用率的提升

指标传统方案PagedAttention
内部碎片(最坏)Lmax12047L_{\max} - 1 \approx 2047 tokenB1=15B - 1 = 15 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 的请求立即能从空闲物理块池中获取显存
  • 整个过程细粒度、动态,没有大块显存的预留和释放延迟
特性静态 BatchingContinuous 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)前做决策:

  1. 哪些等待队列中的请求可以进入 batch?(取决于是否有足够的空闲物理块)
  2. 哪些运行中的请求需要 swap 到 CPU?(当显存不足时,将低优先级请求的 KV Cache 换页到 CPU 内存)
  3. 哪些 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
HuggingFace TGI~2,000~4×
vLLM~6,000-10,00012-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 倍。