跳到主要内容
推理优化

2.1 PagedAttention:像操作系统管理内存一样管理 KV Cache

深入 vLLM 的核心技术 PagedAttention:借鉴虚拟内存分页思想,用 Block Table 消除 KV Cache 碎片化,并支持前缀共享与 Copy-on-Write

PagedAttention KV Cache vLLM 虚拟内存 分页

上一章的 KV Cache 显存账本留下了一个尖锐的问题:传统的连续显存分配方式,会因为”预留即浪费”产生大量碎片,实际利用率可能只有两三成。PagedAttention 就是 vLLM 用来解决这个问题的看家本领——它把操作系统管理内存的经典智慧搬到了 GPU 显存上。这一节我们把它彻底讲透:它借鉴了什么思想、Block Table 怎么工作、为什么能消灭碎片,以及它顺带解锁的前缀共享能力。

📑 目录


1. 问题回顾:连续分配的两宗罪

在理解解法之前,先把问题钉死。传统推理框架(PagedAttention 出现之前)给每个请求的 KV Cache 分配的是一整块连续显存,而且要按”这个请求可能生成的最大长度”来预留。这带来两宗罪:

  • 内部碎片(Internal Fragmentation):一个请求最大可能生成 2048 个 Token,就先占好 2048 个 Token 的空间,但它实际可能只生成了 100 个就遇到 EOS 停了。剩下 1948 个 Token 的空间被这个请求”锁着”却没用上,其它请求也进不来。
  • 外部碎片(External Fragmentation):不同请求预留的大块之间会留下大小不一的空隙,这些零碎空间加起来可能不小,但因为每一块都不够放下一个完整请求,等于全废了。

用一个类比:这就像餐厅给每桌客人都按”最多可能来 10 个人”预留一张十人桌。结果大部分桌子只坐了两三个人,大量座位空着;而新来的一桌 4 人客人,却因为没有一张完整的空桌而只能在门口排队——明明加起来的空座位远够坐下他们。

📌 关键点:碎片的根源在于连续 + 预留这两个约束叠加。只要打破”必须连续”和”必须按最大长度预留”,碎片问题就迎刃而解。这正是 PagedAttention 的切入点。


2. 核心思想:借鉴操作系统的虚拟内存分页

操作系统几十年前就遇到过一模一样的问题:进程需要连续的内存地址空间,但物理内存被各种进程切得七零八落。操作系统的解法是虚拟内存分页——进程看到的是连续的”虚拟地址”,操作系统在背后通过页表(Page Table)把它们映射到任意分散的”物理页”上。进程感觉自己占着一整块连续内存,实际上物理上是东一块西一块拼起来的。

PagedAttention 把这套机制原样搬到了 KV Cache 上:

  • 把 KV Cache 切成固定大小的 KV Block(块),每个块存固定数量 Token 的 Key 和 Value(这个数量由 block_size 决定,vLLM 中常见取值为 16)。
  • 一个请求的 KV Cache 在逻辑上是连续的”逻辑块”序列,物理上却可以散落在显存的任意位置。
  • 用一张 Block Table(块表) 记录”这个请求的第几个逻辑块,对应物理显存里的哪一个物理块”。

🔑 核心概念PagedAttention = KV Cache 的虚拟内存分页。 Token 序列被切成块、按需分配、用块表映射,从此显存不再要求连续,也不再需要提前按最大长度预留。

💡 提示:vLLM 官方文档特别提醒,这里说的 KV Block 是 vLLM 自己的显存管理单位,和 CUDA 里的 “thread block(线程块)” 完全是两回事,不要混淆。


3. Block Table:逻辑块到物理块的映射

Block Table 是整个机制的枢纽。我们用一个具体的例子走一遍。

假设 block_size = 4(每块存 4 个 Token 的 KV,真实场景常用 16,这里为了好画取 4)。一个请求 Prefill 了 9 个 Token,那么它需要 9/4=3\lceil 9/4 \rceil = 3 个逻辑块:

  • 逻辑块 0:Token 0~3
  • 逻辑块 1:Token 4~7
  • 逻辑块 2:Token 8(只用了 1 格,还剩 3 格)

vLLM 从空闲物理块池里分配 3 个物理块给它——注意物理块编号可以完全不连续,比如物理块 #7#2#5。Block Table 就长这样:

逻辑块物理块状态
0#7已满(4/4)
1#2已满(4/4)
2#5部分填充(1/4)
graph LR
    subgraph 逻辑视图["请求看到的逻辑块(连续)"]
        L0["逻辑块0<br/>tok 0-3"] --- L1["逻辑块1<br/>tok 4-7"] --- L2["逻辑块2<br/>tok 8"]
    end
    L0 -.Block Table.-> P7["物理块 #7"]
    L1 -.Block Table.-> P2["物理块 #2"]
    L2 -.Block Table.-> P5["物理块 #5"]

进入 Decode 阶段后,每生成一个新 Token 就往当前逻辑块的空位里填。逻辑块 2 还剩 3 格,能再接 3 个 Token;填满后(生成到第 12 个 Token),vLLM 才按需再申请一个新物理块挂到逻辑块 3——用多少申请多少,绝不提前预留

Attention 计算时,CUDA Kernel 通过 Block Table 拿到每个逻辑位置对应的 physical_block_number(物理块号)和 physical_block_offset(块内偏移),就能从分散的物理块里正确读到所有历史 KV。


4. 分页如何消灭碎片

回到第 1 节的两宗罪,看分页是怎么逐一破解的:

  • 消灭内部碎片:不再按最大长度预留,而是每次只分配一个块,填满了再要下一个。浪费被限制在”最后一个块内没填满的部分”,最多不到一个块(比如 block_size=16 时最多浪费 15 个 Token 的空间),相比动辄浪费上千 Token 的旧方式,几乎可以忽略。
  • 消灭外部碎片:所有物理块大小完全相同,任何一个空闲块都能被任何请求使用。不存在”空间够但形状不对”的问题,空闲块池里的每一块都是等价、可复用的。
📊 维度传统连续分配PagedAttention
显存布局每请求一整块连续显存固定大小块,物理上分散
分配时机提前按最大长度预留按需逐块分配
内部碎片严重(预留 >> 实际用量)极小(< 1 个块)
外部碎片存在(空隙大小不一)消除(块大小统一)
显存利用率偏低接近最优

📌 关键点:显存利用率提上去,直接意味着同一张卡能同时塞下更多并发请求。而上一章讲过,Decode 是 Memory Bound,把更多请求拼进一个 Batch 能显著提升系统吞吐——所以 PagedAttention 省下的显存,最终转化成了实打实的吞吐提升。


5. 意外之喜:内存共享与 Copy-on-Write

分页机制还顺带解锁了一个连续分配时代做不到的能力:多个请求共享同一份物理块

因为 KV 现在是以块为单位、通过块表间接引用的,多个请求的块表完全可以指向同一个物理块。典型场景:

  • 共享前缀:一批请求用了相同的 System Prompt,或从同一个 Prompt 并行采样多个回答(n > 1)。它们的前缀 KV 完全相同,就没必要各存一份——让它们的块表都指向同一批物理块即可。这就是下一节(2.3 Prefix Cache)的物理基础。

那如果共享同一个块的两个请求,后来生成的内容分叉了怎么办?答案是操作系统里另一个经典机制——写时复制(Copy-on-Write, CoW)

  • 只读共享时,大家指向同一块,靠**引用计数(ref_cnt)**记录有几个请求在用。
  • 当某个请求要往一个被共享的块里写新内容时,先把这个块复制一份成为它的私有块,在副本上写,再更新自己的块表指向副本。其他请求不受影响。
graph TD
    A["请求A 块表"] --> P["共享物理块<br/>ref_cnt=2"]
    B["请求B 块表"] --> P
    P -.请求B 要写入.-> C["触发 Copy-on-Write<br/>复制出副本"]
    C --> P2["请求B 私有块"]

💡 提示:这套”引用计数 + 写时复制”和 Linux fork() 之后父子进程共享内存页的机制如出一辙。理解了操作系统的分页与 CoW,PagedAttention 几乎没有新东西——这也是它设计上如此优雅的原因。


6. 代价与权衡

PagedAttention 不是没有成本,工程上要清楚它的取舍:

  • 收益:显存利用率接近最优、支持前缀共享、支持超出显存的请求通过换出(Swapping)/重计算(Recompute)继续跑。
  • 代价:Attention Kernel 要额外做一层块表查找与间址访问,实现比连续内存的朴素 Attention 复杂;块表本身也要占一点管理开销。

关于 block_size 的权衡:块太小,块表变长、管理开销上升;块太大,又会退化出内部碎片(最后一块浪费变多)。vLLM 选择 16 这类中等值作为常见默认,是在管理开销和碎片之间取平衡。

⚠️ 注意:vLLM 官方那篇讲解 PagedAttention CUDA Kernel 的设计文档已明确标注为”基于原始论文的历史文档,不再反映当前代码实现”。也就是说,思想(分页 + 块表 + CoW)依然是 vLLM 的地基,但 V1 引擎里的具体数据结构(比如 KV Cache Manager 的实现)已经过多轮重写演进。学习时抓住不变的核心思想,具体代码以最新版本为准。


📝 总结

  • PagedAttention 把操作系统虚拟内存分页搬到了 KV Cache:切块、按需分配、块表映射。
  • Block Table 把请求看到的连续逻辑块,映射到物理上分散的物理块,从此显存无需连续、无需按最大长度预留。
  • 它同时消灭了内部碎片(不再超额预留)和外部碎片(块大小统一),把显存利用率推到接近最优,进而转化为更高的并发与吞吐。
  • 分页天然支持内存共享 + Copy-on-Write,为前缀复用(Prefix Cache)打下基础。
  • 代价是 Kernel 需要一层间址查找;block_size 需在管理开销与碎片间权衡(vLLM 常用 16)。

🎯 自我检验清单

  • 能说清连续分配下内部碎片和外部碎片各自的成因
  • 能用”逻辑块 → 物理块”的映射解释 Block Table 的作用
  • 能画出一个请求 Prefill 后的块表,并说明 Decode 时块如何按需增长
  • 能解释 PagedAttention 为什么能同时消除内部和外部碎片
  • 能说明内存共享与 Copy-on-Write 如何支持前缀复用与并行采样
  • 能分析 block_size 取值过大或过小分别带来什么问题
  • 能把”省显存”和”提吞吐”通过 Memory Bound 的结论串联起来

📚 参考资料