在做大语言模型推理优化时,很多人一开始会把注意力集中在算力上,比如 FlashAttention、量化、算子融合等。但在真实的在线服务里,一个同样关键、甚至更容易先把系统拖慢的问题其实是 KV cache 的管理

vLLM 的 PagedAttention 之所以重要,不是因为它重新发明了 attention 的数学公式,而是因为它重新设计了 KV cache 在显存里的组织方式。这件事直接影响吞吐、延迟,以及服务能同时容纳多少并发请求。

这篇文章会重点解释:

  • 为什么传统 KV cache 管理方式容易浪费显存
  • PagedAttention 到底“paged”在什么地方
  • 它为什么对 beam search 这种共享前缀、后续分叉的场景特别友好

先看问题:推理阶段真正膨胀的是KV Cache

在自回归生成中,每生成一个新 token,模型都会把这一时刻每一层的 KV 保存下来,后续 token 做 attention 时直接复用这些历史结果,而不是每一步都从头再算一遍。

这就是 KV cache。

KV cache 的几个特点决定了它很难管:

  • 它会随着输出 token 增长而持续变大
  • 不同请求的长度差异很大
  • 请求会动态进入和退出
  • 采样、beam search、并行候选生成等策略会带来“共享前缀,局部新增”的模式

如果用最朴素的方式管理 KV cache,常见做法是:

  • 给每个请求分配一段连续显存
  • 这段空间通常要预留到某个最大长度
  • 请求继续生成时,如果空间不够,要么提前分配很大一块,要么重新搬迁数据

这会导致两个问题。

1. 外部碎片严重

连续内存分配最怕长度不规则。假设有三个请求:

  • 请求A最终生成 128 token
  • 请求B最终生成 2048 token
  • 请求C最终生成 256 token

如果系统为了避免扩容搬迁,往往会给它们保守地预留较大的连续空间。结果就是很多空间虽然已经分配出去了,但实际上没有被有效使用。

2. 共享前缀场景很浪费

比如在 beam search 中,前 100 个 token 可能完全一样,只是在第 101 个 token 开始分叉。如果每个 beam 都复制整份前缀 KV cache,那么显存开销会近似按 beam 数线性膨胀。

这在工程上很亏,因为这些前缀本来就是相同的。

PagedAttention 的核心想法:不要把KV Cache当成一整块连续内存

PagedAttention 的关键思路可以用一句话概括:

把每个序列的 KV cache 拆成固定大小的 block,用逻辑地址到物理 block 的映射来组织,而不是要求整个序列占用一段连续显存。

如果你熟悉操作系统,可以把它类比为“页表 + 物理页框”,但这里只是一个帮助理解的类比,它并不是把 GPU 显存真的交给操作系统做分页。

更具体一点:

  • 一个序列的 token 不再要求连续存放
  • 每若干个 token 组成一个 block,比如 16 个 token 一个 block
  • 模型在计算 attention 时,先根据当前位置找到对应的 block table
  • 再通过 block table 找到真正存放 K/V 的物理 block

这样一来,序列在增长时只需要:

  • 已有 block 继续写
  • 当前 block 写满后,再申请一个新的 block

而不是搬迁整段历史 KV。

一个直观例子:连续内存 vs 分块内存

假设 block 大小是 4 个 token,一个请求已经生成了 10 个 token。

如果按 PagedAttention 管理,它的逻辑布局可能是:

1
2
逻辑token位置:   [0 1 2 3] [4 5 6 7] [8 9]
逻辑block编号: 0 1 2

而物理显存里的 block 可能根本不连续:

1
2
3
4
block table:
logical block 0 -> physical block 21
logical block 1 -> physical block 5
logical block 2 -> physical block 43

对使用者来说,它仍然像是一条完整的历史序列;但对底层内存管理来说,它已经变成了一组离散的、固定大小的块。

这个设计的收益很直接:

  • 只会在最后一个 block 里有少量未用空间
  • 序列增长时追加新 block 即可
  • 请求结束后,回收的是若干标准大小的 block,更容易复用

为什么这能显著减少显存浪费

我们可以把浪费分成两类来看。

1. 预留浪费减少了

传统连续分配常常需要“按最大可能长度预留”,否则后续扩容就可能触发搬迁。PagedAttention 不需要一次把路铺到头,只要按 block 逐步增长。

也就是说,它把“预测未来要多大”这个难题,变成了“当前只多申请一个标准块”。

2. 碎片的上界变小了

对于每个序列,理论上的尾部浪费最多只会接近一个 block。

假设 block 大小是 16 token,那么一个请求哪怕只用了 17 个 token,也只是:

  • 第一个 block 用满 16
  • 第二个 block 用了 1 个

浪费的上界就比较清晰,通常远小于“给每个请求预留一大段连续空间”的策略。

这不是“只换了个存储格式”,attention kernel 也要跟着变

如果 KV 不再连续存放,那么 attention kernel 不能再假设“从某个起始地址线性扫过去”。

它必须做到:

  • 根据请求的 block table 找到历史 token 对应的物理 block
  • 在 block 内定位具体 token
  • 把这些分散的 K/V 组合起来参与 attention 计算

所以 PagedAttention 真正的难点并不只是“分块存”,而是:

在非连续物理布局下,仍然高效地执行 attention。

这也是为什么 vLLM 的价值不只是“提出了一个抽象”,而是把这个抽象做成了能高吞吐跑在 GPU 上的实现。

Beam Search 是理解 PagedAttention 的一个特别好的例子

下面我们用 beam search 来看 PagedAttention 的优势。

先看beam search的行为模式

假设输入 prompt 是:

1
请解释一下为什么分布式系统需要幂等性

模型先生成了一段共同前缀,比如:

1
在分布式系统中,幂等性之所以重要,是因为

此时如果 beam size = 4,那么接下来系统会维护 4 条候选路径。关键点在于:

  • 分叉之前的前缀完全相同
  • 分叉之后,每个 beam 只是在尾部继续增长

也就是说,4 个 beam 的 KV cache 并不是“4 份完全独立的大对象”,而是:

  • 一大段共享前缀
  • 再加上各自少量新增 token

传统做法的问题

如果每个 beam 都复制一整份前缀 KV cache,那么前缀越长,浪费越大。

举个简单的数量级例子:

  • 公共前缀长度:1024 token
  • beam size:4
  • 每个 beam 后续各自新增:64 token

如果完全复制,那么 KV cache 大致对应:

1
4 * (1024 + 64)

而真正有必要独立保存的,其实主要只是分叉后的那 64 token。

PagedAttention 怎么做得更聪明

PagedAttention 的 block 设计下,beam 分叉时可以让多个 beam 共享同一批前缀 block,只在后续写入时追加自己的 block。

可以把它想象成:

1
2
3
4
Beam 0 -> [B0][B1][B2][B3] + [C0][C1]
Beam 1 -> [B0][B1][B2][B3] + [D0][D1]
Beam 2 -> [B0][B1][B2][B3] + [E0][E1]
Beam 3 -> [B0][B1][B2][B3] + [F0][F1]

其中:

  • B0~B3 是共享前缀 block
  • C/D/E/F 才是各个 beam 独有的增量 block

这时显存占用更接近:

1
shared_prefix + 4 * delta

而不是:

1
4 * (shared_prefix + delta)

用一个更具体的block例子说明

假设:

  • block 大小 = 16 token
  • 公共前缀长度 = 48 token
  • 每个 beam 在分叉后再生成 10 token

那么公共前缀会占 3 个 block:

1
[0..15] [16..31] [32..47]

如果 beam size = 4:

  • 传统复制思路:4 个 beam 都各自持有这 3 个前缀 block
  • PagedAttention 思路:这 3 个 block 只存一份,4 个 beam 的 block table 都指向它们

随后每个 beam 追加自己的 token:

  • 第 49 到 58 个 token 写到各自新增的 block 里

这时共享关系大概是:

1
2
3
4
Beam 0 table -> P0 P1 P2 A0
Beam 1 table -> P0 P1 P2 B0
Beam 2 table -> P0 P1 P2 C0
Beam 3 table -> P0 P1 P2 D0

其中 P0/P1/P2 是共享 block。

这类“共享前缀 + 尾部增量”的模式,正是 PagedAttention 特别擅长处理的场景。

为什么这对在线服务吞吐很重要

很多人第一次看 PagedAttention 会觉得,这主要是在“省显存”。但在线推理里,省显存本身就会转化成吞吐。

原因很简单:

  • 显存浪费少了,同一张卡上能同时容纳更多请求
  • 更少的数据搬迁意味着调度更平滑
  • 共享前缀能力让复杂解码策略没有那么“奢侈”

所以 PagedAttention 带来的不是一个孤立的小优化,而是:

更高的显存利用率 -> 更大的有效 batch -> 更高的系统吞吐。

这也是 vLLM 在 serving 场景里表现突出的关键原因之一。

需要注意的几个边界

当然,PagedAttention 也不是没有代价。

1. 间接寻址本身会增加实现复杂度

连续内存最简单,分页块表一定更复杂。为了把这套间接寻址的成本降下来,kernel 实现必须非常讲究访存模式、并行粒度和 block 布局。

2. block 太大或太小都不好

如果 block 太大:

  • 单序列尾部浪费会增加

如果 block 太小:

  • block table 更长
  • 元数据管理更重
  • kernel gather 开销可能更高

所以 block size 是一个工程权衡,不是越小越好。

3. 它优化的是“服务中的注意力执行与KV管理”

PagedAttention 不会改变模型参数量,也不会神奇地把所有推理问题都解决掉。首 token 延迟、采样策略、网络开销、调度策略、量化误差,这些仍然是完整系统里要分别处理的问题。

我对PagedAttention的一个总结

如果只用一句话概括 PagedAttention,我会这样说:

它把 LLM 推理中最难管理的那部分状态,也就是不断增长的 KV cache,从“必须连续摆放的大对象”变成了“可共享、可追加、可复用的固定块集合”。

这个变化听起来像底层实现细节,但它对系统行为的影响非常大:

  • 降低显存碎片
  • 避免频繁搬迁 KV cache
  • 让 beam search 这类共享前缀场景真正高效
  • 提升同卡并发和整体吞吐

从这个角度看,vLLM 的 PagedAttention 更像是一个“推理系统设计”创新,而不只是一个单独的 attention 小技巧。

如果后面继续展开,我觉得还有两个方向很值得写:

  • PagedAttention 和 continuous batching 是怎么配合的
  • prefix sharing / copy-on-write 在实际 serving 框架里是怎么落地的

这两个主题基本就是把“为什么 vLLM 快”继续往里拆开的下一层。