vLLM系统拆解-05-KV Cache与PagedAttention:核心不是Attention公式,而是存放方式

如果要找 vLLM 相比早期推理框架最重要的改变,答案通常指向 PagedAttention。但"PagedAttention"这个名字很容易被误读——它 page 的不是 attention 的计算过程,而是 attention 所依赖的数据的存储方式。

这篇文章从 KV Cache 的基本机制讲起,解释为什么连续内存分配方案在在线服务中会失效,vLLM 的 block/page 化设计解决了什么问题,以及这个设计如何与调度器、Prefix Caching 形成系统合力。最后会区分 PagedAttention 和 FlashAttention,这两个概念在面试里经常被混淆。


KV Cache 是什么,为什么必须有它

在自回归生成里,模型每生成一个新 token,都需要对"之前所有 token"做 attention。以生成第 101 个 token 为例:

  • Query 只针对当前这一步的新 token
  • Key / Value 涉及前面全部 100 个历史 token

如果每一步 decode 都从头把历史 token 的 K/V 重新算一遍,计算量会随序列长度线性增长,累计代价极高。所以实际系统都会把历史 token 对应的 K 和 V 保存下来,后续 decode 时:

  1. 对新 token 只计算当前这一步的 Q/K/V
  2. 把新 K/V 追加进缓存
  3. attention 用当前 Q 去读历史缓存中的 K/V

这个保存下来的历史状态就是 KV Cache

这不是"锦上添花"的优化,而是现代 LLM 服务中几乎默认存在的基础机制。没有它,长序列生成的代价会随步数平方级增长。


为什么 KV Cache 会成为在线服务的主要瓶颈

模型权重是固定的,但 KV Cache 是动态增长的。请求越长、生成越久,KV Cache 越大;而且不是一个请求在涨,是所有并发请求都在涨。

KV Cache 必须驻留在 GPU 上,因为 decode 每一步都要高频读取它。它和模型权重、激活值、中间 buffer 一起争夺显存。在很多在线服务场景里,真正卡住 batch size 的,不是算力,而是 KV Cache 的显存占用与管理方式。

这和 decode 阶段的计算特性密切相关。Decode 是 memory-bound 的:每一步新算的东西不多(只有一个 token),但要频繁访问历史 K/V。所以 KV Cache 的存储结构和访问方式,对 decode 延迟的影响比对 prefill 更直接。vLLM 官方优化文档把 decode 明确描述为 memory-bound,这也是 vLLM 把大量设计重心放在 KV Cache 管理上的原因。


连续分配为什么不适合在线服务

直觉上,最简单的做法是给每个请求分配一段连续显存来存 KV Cache,序列变长时继续往后扩。这个方案在离线批量推理里可以工作,但在高并发在线服务里会暴露四个根本问题。

长度不可预知。 请求进来时,你不知道它最终会生成多长。保守分配(先分小,不够再扩)会带来频繁扩容和数据搬迁;激进预留(一开始多预留)会大量浪费显存。这个矛盾无法通过调参根本解决。

外部碎片。 不同请求的生命周期差异极大。短请求快速结束,释放了一段中间空间;新请求需要更大的连续区,但这段空洞不够用。总空闲显存足够,却因为不连续而无法利用——这是经典的外部碎片问题。

扩容需要搬迁。 某个请求的 KV Cache 继续增长,但原来那段连续区后面已被占用。系统要找一块更大的连续区,把所有 K/V 复制过去,再更新引用。在高并发场景下,这个代价非常高。

前缀共享不自然。 如果两个请求有相同的 system prompt,理想情况下这部分 K/V 可以共用。但在连续大 tensor 模型下,很难把"共享的只读前缀"和"各自新增的部分"分开管理,通常的结果是各自保留一份,甚至重新计算一份。


vLLM 的解法:把 KV Cache 切成固定大小的 block

vLLM 不给每个请求准备一段连续显存,而是:

  • 把 KV Cache 切成很多固定大小的 block(也叫 page)
  • 每个 block 容纳固定数量 token 的 K/V
  • 一个请求的逻辑序列由多个 block 串起来
  • 这些 block 在物理显存上不需要连续

这个思路直接来自操作系统的分页内存管理:

1
2
3
4
5
6
7
8
9
操作系统分页:
进程视图:连续的虚拟地址空间
物理实现:分散在不同位置的物理页
连接方式:页表(虚拟页号 → 物理页帧号)

vLLM KV block:
请求视图:连续的历史 token 序列
物理实现:分散在显存各处的 KV block
连接方式:block table(逻辑块编号 → 物理块位置)

以 block size = 16 为例,一个请求的历史 token 在逻辑上是:

1
2
逻辑序列:[token1...16] [token17...32] [token33...48] ...
逻辑 block 0 逻辑 block 1 逻辑 block 2

但物理上可能是:

1
2
3
4
5
6
7
┌──────────────────────────────────────────────────────┐
│ 请求 R 的 block table │
│ │
│ 逻辑 block 0 → 物理 block 8 (显存某处) │
│ 逻辑 block 1 → 物理 block 21 (显存另一处) │
│ 逻辑 block 2 → 物理 block 4 (显存又一处) │
└──────────────────────────────────────────────────────┘

对请求来说,历史仍然是连续的;对系统来说,只需维护一份"逻辑块编号 → 物理块位置"的映射。


"paged"到底 page 了什么

这是最容易产生混淆的地方。

PagedAttention 中,被 page 化的是 KV Cache 的存储与索引方式,不是 attention 的计算数学。

attention 仍然在做:当前 query 对历史 keys 做相关性计算,用权重加权历史 values。不同的是,历史 K/V 不再假设存在一段连续内存里,而是通过 block table 从多个分散的 block 中取出来参与计算。

变化的是:

  • 数据组织方式
  • 内存管理方式
  • attention kernel 的访存逻辑

不变的是:attention 的数学本身。

当历史 K/V 是连续存储时,kernel 可以直接按偏移量寻址。切换到 paged layout 后,kernel 在处理每个 block 时,需要先查 block table 确定物理位置,再加载对应数据。vLLM 使用专门为 paged KV Cache 设计的 multi-head query attention kernel——官方 Paged Attention 设计文档也特别说明,这里的"block"是 KV cache block,不是 GPU thread block,两者不要混淆。

理解这个设计的关键一句话:逻辑连续,物理离散,计算时按映射取数。


Block 化设计解决了哪些问题

原有问题 block 化如何解决
长度不可预知,难以预留 按需追加新 block,无需初始化时猜测最终长度
外部碎片 分配单位变为固定大小 block,回收后可直接给新请求复用
扩容需要搬迁 增长只需追加新 block,已有 block 原位不动
前缀共享不自然 前缀对应的 full block 可直接共用,分叉后各自追加新 block
并发承载量受限 碎片减少、回收高效,同等显存下能容纳更多活跃序列

前缀共享这一点值得展开。block 化之后,前缀复用变成:

1
2
3
4
5
6
7
8
9
10
请求 A 和请求 B 有相同的 system prompt(前 32 个 token,block size=16):

逻辑 block 0 [token1..16] → 共享物理 block 5 (只读)
逻辑 block 1 [token17..32] → 共享物理 block 12 (只读)

请求 A 之后的内容:
逻辑 block 2 → 物理 block 7 (A 私有)

请求 B 之后的内容:
逻辑 block 2 → 物理 block 19 (B 私有)

共享前缀等于共用旧 block,分叉后各自挂新 block。如果底层仍是连续大 tensor,这种细粒度共享几乎无法实现。


Block Size 的权衡

既然切成 block,block 大小就是一个设计参数:

block 太小 block 太大
优点 分配粒度细,尾部浪费少;前缀复用更灵活 元数据少,映射关系简单;kernel 访存更规整
缺点 block 数量多,元数据管理复杂;kernel 索引开销增加 尾部未用空间增多;细粒度复用能力变弱

本质是在管理粒度显存浪费索引复杂度kernel 实现难度四者之间取平衡。具体默认值会随后端实现和模型配置变化,没有一个通用最优答案。


Prefix Caching 为什么和 paged KV Cache 天然搭档

vLLM 的 Prefix Caching 以 block 为粒度缓存已计算的 KV:

  • 只有 full block(已被填满的 block)会进入缓存
  • 每个 full block 的缓存键由以下内容联合哈希得出:父 block 的 hash、当前 block 的 token 序列,以及 LoRA ID、多模态输入 hash、cache salt 等附加信息
  • 新请求命中时,按 block 逐个匹配,直到某个位置开始不同为止,后续再新建 block
1
2
3
4
5
6
7
Prefix Caching 命中过程:

新请求:[block 0] → hash 命中,复用缓存
[block 1] → hash 命中,复用缓存
[block 2] → hash 命中,复用缓存
[block 3] → hash 未命中(序列从这里开始不同)→ 重新计算
[block 4] → 新内容,分配新 block

如果底层是连续 tensor,“缓存部分前缀"在数据结构上会更笨重;block 化之后,prefix caching 几乎是 block 化设计的自然延伸——前缀复用就是"命中一批旧 block”。


为什么 KV Cache 管理和调度器必须协同

KV Cache 不是独立模块,它和调度器是紧耦合的(第 4 篇中已介绍调度器的工作机制)。

调度器每一轮的决策都依赖 KV Cache 的当前状态:还剩多少 free block、哪些 block 被哪些请求引用、哪些 cached block 可以复用。反过来,调度器的决策也直接影响 KV Cache 的分配与回收节奏。

1
2
3
4
5
6
7
┌────────────────────┐      free block 数量      ┌────────────────────────┐
│ Scheduler │ ◄──────────────────────── │ KV Cache Manager │
│ │ │ │
│ - 哪些请求运行 │ ─── 申请 / 释放 block ───► │ - block 分配 │
│ - token budget │ │ - 引用计数 │
│ - 是否 preempt │ ◄─ cached block 可复用 ─── │ - prefix cache 索引 │
└────────────────────┘ └────────────────────────┘

这也是 vLLM 架构中 scheduler 和 KV cache manager 被统一放在 engine core 里一体化思考的原因。


PagedAttention 的收益:不只是省显存

把 PagedAttention 简单理解成"显存优化"没有错,但只看到了第一层。完整的收益链条是:

显存碎片减少、按需增长

↓ 同等显存下能容纳更多活跃序列

↓ 调度器手里可用候选请求更多,continuous batching 更有效

↓ Prefix caching 的复用粒度更细,命中收益更大

↓ 整体 serving 吞吐更高,延迟波动更小

vLLM 论文(Efficient Memory Management for Large Language Model Serving with PagedAttention)将吞吐提升和 near-zero waste 放在一起讲,就是在强调:显存管理不是附属优化,而是直接影响并发能力和吞吐能力的核心因素。


这种设计的代价

实现复杂度高。 连续 tensor 的思维很直观;paged KV Cache 需要 block 管理、block table、引用计数、回收策略、与调度器协调、定制化 attention kernel。工程复杂度明显更高。

Kernel 无法假设内存连续。 因此需要专门适配 paged layout 的 attention kernel,不能直接复用原有的连续内存实现。官方设计文档专门为此独立成页,说明这不是一个小改动。

元数据管理有开销。 block table、hash 计算、引用计数、free block queue 都不是零成本。在大规模在线服务场景下,这些开销相对收益通常值得;但在小规模或单请求场景下,比值不一定划算。

非标准硬件的适配难度更大。 因为涉及底层访存和 kernel 设计,某些 NPU 或边缘设备要完整复现 vLLM 的性能特性,难点往往集中在 paged attention 这一层。


PagedAttention 和 FlashAttention 不是同一件事

这两个概念经常被混淆,因为都和 attention 的"高效"有关。但它们关注的层次完全不同:

FlashAttention PagedAttention
核心问题 如何在已有 K/V 数据上高效计算 attention 如何组织和管理 KV Cache 数据本身
关注层次 计算 kernel 的 IO 效率(减少 HBM 读写,tile 化) 在线服务的内存管理(block 化、分散存储、映射)
解决场景 单次 attention 计算的 GPU 利用效率 多请求并发服务的显存碎片与复用问题
对内存布局的假设 通常假设 K/V 在连续或特定布局内存中 专门处理分散存储的 paged K/V layout

两者并不冲突。vLLM 官方 README 说明,vLLM 集成了 FlashAttention / FlashInfer 等能力作为计算后端,PagedAttention 负责数据组织层,两层各司其职。


结语

PagedAttention 之所以重要,是因为它把一个"看起来是内存细节"的问题提升成了系统设计核心。KV Cache 的存放方式直接决定:

  • 一张 GPU 上能同时服务多少请求
  • 调度器是否有足够大的批次可以操作
  • Prefix caching 能否有效落地
  • serving 吞吐的上限在哪里

它的局限在于实现复杂度高,且对硬件 kernel 有依赖,适配非标准后端的成本不小。在小规模或对延迟要求极低的场景下,这套机制的工程成本可能超过收益。


参考资料

  1. vLLM 官方 README:https://github.com/vllm-project/vllm
  2. vLLM 论文:Efficient Memory Management for Large Language Model Serving with PagedAttentionhttps://arxiv.org/abs/2309.06180
  3. vLLM 官方设计文档:Paged Attention,https://docs.vllm.ai/en/latest/design/paged_attention/
  4. vLLM 官方设计文档:Automatic Prefix Caching,https://docs.vllm.ai/en/latest/design/prefix_caching/
  5. vLLM 官方文档:Optimization and Tuning,https://docs.vllm.ai/en/stable/configuration/optimization/

系列导航