2.2 PagedAttention 的分页式 KV Cache:像操作系统管内存一样管显存


文档摘要

2.2 PagedAttention 的分页式 KV Cache:像操作系统管内存一样管显存 读者读完本节应带走的一句话:PagedAttention 把 KV Cache 切成固定大小的「块」,用一张块表(block table)把逻辑序列映射到离散的物理块,像操作系统虚拟内存分页那样消灭碎片,并顺带让多个请求共享同一前缀,从而用同样的显存塞下更大的 batch、拿到更高吞吐。 2.1 解决了「注意力怎么算得快」,但推理系统还有一个绕不开的问题:KV Cache 存在哪、怎么分配。第一章 1.2 已经算过,KV Cache 总额是 ,它会随序列长度、并发请求数线性甚至超线性增长。真正让工程团队头疼的,往往不是「总量」,而是「分配策略」带来的浪费。

2.2 PagedAttention 的分页式 KV Cache:像操作系统管内存一样管显存

读者读完本节应带走的一句话:PagedAttention 把 KV Cache 切成固定大小的「块」,用一张块表(block table)把逻辑序列映射到离散的物理块,像操作系统虚拟内存分页那样消灭碎片,并顺带让多个请求共享同一前缀,从而用同样的显存塞下更大的 batch、拿到更高吞吐。

2.1 解决了「注意力怎么算得快」,但推理系统还有一个绕不开的问题:KV Cache 存在哪、怎么分配。第一章 1.2 已经算过,KV Cache 总额是 2 * n * L * h * d * 字节数,它会随序列长度、并发请求数线性甚至超线性增长。真正让工程团队头疼的,往往不是「总量」,而是「分配策略」带来的浪费。本节就讲清传统方案的浪费从哪来,以及 PagedAttention(vLLM 的核心创新)怎么用分页思路根治它。

2.2.1 传统连续预分配的两类浪费

最常见的朴素做法是:给每个请求一上来就按 max_length(比如 4096 或 8192)预分配一整段连续的 KV Cache 空间。

听起来省事,实际埋了两个坑:

  • 内部碎片(internal fragmentation):你预留了 4096 个 token 的 KV 空间,但这个请求实际只生成了 200 个 token,剩下 3896 个位置全程空置,谁也用不了。平均生成长度越短,浪费比例越高。
  • 外部碎片(external fragmentation):即使整体还有空闲显存,它们往往是「零散小块」。比如剩三个空块分别能放 200、300、150 个 token,但新来的请求需要一个连续 512 的块——凑不出,于是调度器只能拒绝或等待,尽管显存总量其实够。
传统连续预分配的碎片问题

这两种碎片叠加,真实场景下 KV Cache 的利用率可能只有 20%–40%。换句话说,你花大价钱买的显存,一大半在「占着茅坑不拉屎」。而显存利用率直接决定了能开多大的并发 batch,进而决定了吞吐和成本——所以这不是小问题。

2.2.2 核心灵感:操作系统的虚拟内存分页

PagedAttention 的解法非常漂亮,因为它直接借用了操作系统里被验证了几十年的一套思想:虚拟内存分页

操作系统怎么管内存?它不要求一个进程占用的物理内存是连续的。它把物理内存切成固定大小的「页(page)」,用一张「页表(page table)」把进程的「虚拟地址」映射到「物理页框」。进程看到的是一段连续、整齐的虚拟地址空间;物理上,这些页可以散落在内存各处。需要更多内存时,操作系统就分配一个新空闲页、把映射写进页表,进程毫无感知。

PagedAttention 把这套映射照搬到了 KV Cache 上:

  • 块(block):把 KV Cache 切成固定大小的块,每个块存固定数量 token 的 K 和 V(典型如每块 16 个 token)。注意块是 K/V 一起存的,一块对应序列里一小段连续 token 的 KV。
  • 块表(block table):每个序列维护一张块表,记录「我的逻辑块 0 对应哪个物理块、逻辑块 1 对应哪个物理块……」。
  • 按需分配:请求开始只分配需要的块;每新生成一个 token,就把它的 KV 写进「当前块」,当前块满了就向空闲池要一个新物理块、登记进块表。全程不需要连续大块。
PagedAttention 块表映射示意

2.2.3 为什么这样几乎消灭了碎片

道理和操作系统分页一样:

  • 内部碎片被压到极小:每个序列最多浪费「最后一个未满块」里的那点空间,上限就是一个块的大小(如 16 个 token),相比动辄预留几千的连续段,浪费可忽略。
  • 外部碎片基本消失:物理块都是统一大小,任何一个空闲块都能满足任何请求的「再来一块」需求。不存在「大小不匹配凑不出连续块」的问题——因为根本不需要连续。

结果就是 KV Cache 利用率可以从两三成提升到接近 100%(仅剩块内尾部极小浪费)。同样一张卡,能同时服务的请求数显著变多,吞吐随之上升,单位 token 成本下降。这正是 vLLM 在发布时能把吞吐相对当时主流方案提升数倍的关键机制(具体倍数随负载与配置而变,请以你自己的压测为准)。

2.2.4 注意力计算时怎么「按块取 K/V」

分页之后,注意力计算就不能像连续存储那样一个切片搞定,而是要「按块」去取 K/V。等价伪代码(用于理解原理,不是逐行源码)如下:

# block_table: 该序列的逻辑块 -> 物理块 的映射 # 对每个 query 位置 i,需要 attend 到它之前的所有 key/value for logical_block in range(num_logical_blocks_of_seq): phys = block_table[logical_block] # 查块表,拿到物理块 K_blk = kv_cache_K[phys] # 只取这一块的 K V_blk = kv_cache_V[phys] # 只取这一块的 V scores = Q[i] @ K_blk.T / sqrt(d) # 局部分数 # 与已累积的 running max / sum 合并(呼应 2.1 的在线归一化思想) accumulate_output(O[i], scores, V_blk)

注意这里和 2.1 的在线归一化是同一种精神:注意力本来就要遍历所有 key,分页只是把「所有 key」拆成「分布在各物理块的若干块」,逐块取、逐块合并即可,数学上完全等价。差别只在于取数方式从「连续切片」变成「查块表 + 离散取块」。

2.2.5 意外的礼物:前缀共享(copy-on-write)

分页带来的第二大收益,是让「共享」变得自然。很多推理负载里,多个请求其实共享同一段前缀:

  • 并行采样(parallel sampling):同一个 prompt 采样出 k 个回复,前缀完全相同;
  • beam search:多个候选序列在分叉前共享前缀;
  • 多轮对话的 system prompt:同一系统提示被很多用户共用。

在传统连续分配下,每个请求各自复制一份前缀 KV,白白占用 k 倍显存。在 PagedAttention 里,多个序列的块表可以指向同一个物理块来表示共享前缀。只有当其中某个序列要改写(继续生成出不同于他人的 token)时,才对该块做写时复制(copy-on-write):复制一份物理块、让这个序列指向新副本,其余序列仍共享原块。

PagedAttention 前缀共享与写时复制

这一机制对「共享前缀比例高」的场景(例如固定系统提示、少样本示例、并行采样)格外划算,KV Cache 占用可以再降一大截。但也要诚实地说:它只节省「共享部分」,每个序列各自生成的后缀仍各占其块,且写时复制本身有一次复制开销,所以收益大小取决于实际负载的共享程度。

2.2.6 它解决了什么、没解决什么

明确边界,避免误用:

  • 解决:KV Cache 的显存碎片(内/外部碎片)、并发容量受限、前缀冗余存储。
  • 不直接解决:单个注意力计算本身的带宽瓶颈——那是 FlashAttention-2 的活(见 2.1)。PagedAttention 负责「KV 存在哪、怎么分页取」,FA-2 负责「取到一块 K/V 后怎么高效算」。两者正交、互补。
  • 代价与注意:分页引入了块表查询与离散访存,单步取数比连续访问略复杂;块大小是重要超参——块太小则块表膨胀、管理开销上升,块太大则内部碎片回升,常见取 16。另外,跨请求共享会带来并发安全的边界处理(写时复制的正确性),这是框架要替你保证的。

2.2.7 PagedAttention 的代价:块表查询与访存模式

前面几节把分页的好处讲得很充分,但一个负责任的作者必须也告诉你它「不是免费的」。理解代价,你才能在性能排查时知道该往哪看。

分页引入的第一类开销是块表查询(block table lookup)。传统连续分配下,第 i 个 token 的 KV 就躺在地址 base + i * block_stride 处,一次指针加法就能取到;而分页后,要取第 i 个 token,得先算它属于第几个逻辑块(i // block_size),再用块表查到对应的物理块编号,最后在物理块内取偏移(i % block_size)。这一步多一次「查表 + 间接寻址」,访问模式也从连续变成随机。在注意力计算的热点循环里,每个 query 都要零散地跳到不同物理块取 K/V,对缓存(cache)不友好。

好消息是,单次取数的额外复杂度只是「一次查表」,而换来的是几乎零碎片的显存利用。工程上这通常是划算的:你用一点点取数开销,换来了能开更大 batch、GPU 更「吃饱」的收益(呼应 2.3 的「倍乘」逻辑)。但要注意两点:

  • 块表本身要常驻显存并被频繁读取,块表过大(块太小导致逻辑块数多)会反噬这部分收益;
  • 跨请求共享前缀(2.2.5 的 copy-on-write)时,引用计数与写时复制的正确性边界,是这类系统最容易出 bug 的地方,自己实现分页时务必重点测试。

一句话:分页用「取数多了一步查表」换「显存几乎不浪费」,对推理服务几乎总是正收益;但它不是零成本,排查性能时要记得把块表查询算进延迟里。

2.2.8 块大小(block size)怎么选:一个被低估的关键超参

分页效果的好坏,很大程度上取决于「每块放多少 token」。这是工程上最容易踩、也最值得调的一个旋钮:

  • 块太小(如每块 4 个 token):内部碎片上限小、利用率高,但块表会迅速膨胀——每个序列逻辑块数变多,调度器维护的元数据开销和访存时的块表查询次数都上升,反而拖慢。
  • 块太大(如每块 128 个 token):块表轻,但内部碎片回升——一个只用了 1 个 token 的尾部块也要占满整块空间,整体利用率下降。
  • 经验取值:vLLM 等实现常用 16。它是对「碎片 / 元数据开销」的折中,但并非对所有负载都最优。如果你的平均生成长度很短,稍小的块能压碎片;如果序列普遍很长且批内均匀,稍大的块能降开销。调它时看两个指标:KV Cache 实际利用率、以及 PagedAttention 取块带来的额外延迟。

一个重要提醒:块大小还会影响注意力内核的分块粒度。它和 FlashAttention-2 的分块是两套概念,但都围绕「怎么高效切数据」——理解其一,另一半也容易举一反三。

2.2.9 连续批处理(continuous batching)为什么离不开分页

很多人把「吞吐高」归功于 PagedAttention,但严格说,让它真正发挥的是和连续批处理的配合。传统静态 batch 必须等整个 batch 里最长的请求生成完才能释放显存、接纳新请求,导致短请求被长请求拖住、GPU 空转。

连续批处理的核心思想是「以 token 为单位调度」:哪个序列这一步生成了一个 token,就把它新产生的 KV 写进新块、登记块表;哪个序列结束了,就立刻回收它的块给其他请求用。而能这么做的前提,正是分页——因为只有块是统一大小、可随用随还的,才谈得上「结束一个序列立刻腾出零散块给新序列」。如果还是连续预分配,回收的显存是「一段大连续块」,很难立刻匹配下一个大小不同的请求。

所以说,PagedAttention 提供了「细粒度、可随时回收的显存单元」,连续批处理则利用这些单元做高吞吐调度——两者是「 ammunition(弹药)与扳机」的关系。单独做分页而不改调度,收益有限;单独做连续批处理而不分页,显存管理仍会卡脖子。

2.2.10 写时复制的边界:什么情况它帮不上忙

2.2.5 讲了前缀共享(copy-on-write)的甜头,但要诚实指出它的适用边界,免得你高估收益:

  • 共享比例决定收益:只有「多序列确实共用同一前缀」时才省。如果每次请求都是完全不同的长 prompt,几乎没有共享,收益趋近于零。
  • 写时复制有复制成本:一旦某个共享序列要生成与他人不同的 token,就要复制整块(典型 16 个 token 的 KV)。若大量序列在很浅的位置就分叉,复制开销会吃掉部分共享红利。
  • 并发正确性:多序列指向同一物理块时,框架必须保证「谁先写谁先复制」,且复制前读到的仍是共享内容。这是引擎要保证的正确性细节,自己实现分页时极易在这里出 bug。
  • 不是压缩:它节省的是「重复存储」,不是「单份 KV 的体积」。要压单份体积,得靠量化(如 INT8/FP8 的 KV Cache 量化),那是另一条线。

2.2.11 一个直觉化的容量对比

用第 1 章的 KV 公式做道估算,感受分页的价值。假设某模型每 token 的 KV 约 0.5 MB(bf16、较大量级),单卡显存 80 GB 中留 40 GB 给 KV:

  • 连续预分配、平均利用率 30%:实际可用约 12 GB → 约 24000 个 token 的并发容量(所有请求按预留 max 占用)。
  • 分页、利用率约 95%:实际可用约 38 GB → 约 76000 个 token 的并发容量。

差距是数量级的并发空间,直接等价于「同样硬件多扛几倍流量」。请注意这只是一个示意数量级,真实数字随模型、精度、批内长度分布剧烈变化——但「分页把可用容量从两三成拉到接近满」这个趋势是稳健的。

2.2.12 小结:读者现在应该能回答的三个问题

  1. PagedAttention 用什么思想治碎片?——操作系统虚拟内存分页:固定大小块 + 块表映射 + 按需分配。
  2. 它为什么能把利用率拉到接近满分?——物理块统一大小,任意空闲块都能满足「再来一块」,不再有大小不匹配的外部碎片;内部碎片上限仅一个块。
  3. 它还顺手给了什么?——前缀共享(copy-on-write),并行采样/beam search/共用系统提示下显著省显存。

下一节 2.3,我们把两个模块放到一起,看它们在真实推理引擎里如何协同,以及最常见的搭配误区。


作者与出处
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 搬砖请按F5的小龙虾 转发
评论区 (0)
U