2.2 PagedAttention 的分页式 KV Cache:像操作系统管内存一样管显存 读者读完本节应带走的一句话:PagedAttention 把 KV Cache 切成固定大小的「块」,用一张块表(block table)把逻辑序列映射到离散的物理块,像操作系统虚拟内存分页那样消灭碎片,并顺带让多个请求共享同一前缀,从而用同样的显存塞下更大的 batch、拿到更高吞吐。 2.1 解决了「注意力怎么算得快」,但推理系统还有一个绕不开的问题:KV Cache 存在哪、怎么分配。第一章 1.2 已经算过,KV Cache 总额是 ,它会随序列长度、并发请求数线性甚至超线性增长。真正让工程团队头疼的,往往不是「总量」,而是「分配策略」带来的浪费。
读者读完本节应带走的一句话:PagedAttention 把 KV Cache 切成固定大小的「块」,用一张块表(block table)把逻辑序列映射到离散的物理块,像操作系统虚拟内存分页那样消灭碎片,并顺带让多个请求共享同一前缀,从而用同样的显存塞下更大的 batch、拿到更高吞吐。
2.1 解决了「注意力怎么算得快」,但推理系统还有一个绕不开的问题:KV Cache 存在哪、怎么分配。第一章 1.2 已经算过,KV Cache 总额是 2 * n * L * h * d * 字节数,它会随序列长度、并发请求数线性甚至超线性增长。真正让工程团队头疼的,往往不是「总量」,而是「分配策略」带来的浪费。本节就讲清传统方案的浪费从哪来,以及 PagedAttention(vLLM 的核心创新)怎么用分页思路根治它。
最常见的朴素做法是:给每个请求一上来就按 max_length(比如 4096 或 8192)预分配一整段连续的 KV Cache 空间。
听起来省事,实际埋了两个坑:
这两种碎片叠加,真实场景下 KV Cache 的利用率可能只有 20%–40%。换句话说,你花大价钱买的显存,一大半在「占着茅坑不拉屎」。而显存利用率直接决定了能开多大的并发 batch,进而决定了吞吐和成本——所以这不是小问题。
PagedAttention 的解法非常漂亮,因为它直接借用了操作系统里被验证了几十年的一套思想:虚拟内存分页。
操作系统怎么管内存?它不要求一个进程占用的物理内存是连续的。它把物理内存切成固定大小的「页(page)」,用一张「页表(page table)」把进程的「虚拟地址」映射到「物理页框」。进程看到的是一段连续、整齐的虚拟地址空间;物理上,这些页可以散落在内存各处。需要更多内存时,操作系统就分配一个新空闲页、把映射写进页表,进程毫无感知。
PagedAttention 把这套映射照搬到了 KV Cache 上:
道理和操作系统分页一样:
结果就是 KV Cache 利用率可以从两三成提升到接近 100%(仅剩块内尾部极小浪费)。同样一张卡,能同时服务的请求数显著变多,吞吐随之上升,单位 token 成本下降。这正是 vLLM 在发布时能把吞吐相对当时主流方案提升数倍的关键机制(具体倍数随负载与配置而变,请以你自己的压测为准)。
分页之后,注意力计算就不能像连续存储那样一个切片搞定,而是要「按块」去取 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」拆成「分布在各物理块的若干块」,逐块取、逐块合并即可,数学上完全等价。差别只在于取数方式从「连续切片」变成「查块表 + 离散取块」。
分页带来的第二大收益,是让「共享」变得自然。很多推理负载里,多个请求其实共享同一段前缀:
在传统连续分配下,每个请求各自复制一份前缀 KV,白白占用 k 倍显存。在 PagedAttention 里,多个序列的块表可以指向同一个物理块来表示共享前缀。只有当其中某个序列要改写(继续生成出不同于他人的 token)时,才对该块做写时复制(copy-on-write):复制一份物理块、让这个序列指向新副本,其余序列仍共享原块。
这一机制对「共享前缀比例高」的场景(例如固定系统提示、少样本示例、并行采样)格外划算,KV Cache 占用可以再降一大截。但也要诚实地说:它只节省「共享部分」,每个序列各自生成的后缀仍各占其块,且写时复制本身有一次复制开销,所以收益大小取决于实际负载的共享程度。
明确边界,避免误用:
前面几节把分页的好处讲得很充分,但一个负责任的作者必须也告诉你它「不是免费的」。理解代价,你才能在性能排查时知道该往哪看。
分页引入的第一类开销是块表查询(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 的「倍乘」逻辑)。但要注意两点:
一句话:分页用「取数多了一步查表」换「显存几乎不浪费」,对推理服务几乎总是正收益;但它不是零成本,排查性能时要记得把块表查询算进延迟里。
分页效果的好坏,很大程度上取决于「每块放多少 token」。这是工程上最容易踩、也最值得调的一个旋钮:
一个重要提醒:块大小还会影响注意力内核的分块粒度。它和 FlashAttention-2 的分块是两套概念,但都围绕「怎么高效切数据」——理解其一,另一半也容易举一反三。
很多人把「吞吐高」归功于 PagedAttention,但严格说,让它真正发挥的是和连续批处理的配合。传统静态 batch 必须等整个 batch 里最长的请求生成完才能释放显存、接纳新请求,导致短请求被长请求拖住、GPU 空转。
连续批处理的核心思想是「以 token 为单位调度」:哪个序列这一步生成了一个 token,就把它新产生的 KV 写进新块、登记块表;哪个序列结束了,就立刻回收它的块给其他请求用。而能这么做的前提,正是分页——因为只有块是统一大小、可随用随还的,才谈得上「结束一个序列立刻腾出零散块给新序列」。如果还是连续预分配,回收的显存是「一段大连续块」,很难立刻匹配下一个大小不同的请求。
所以说,PagedAttention 提供了「细粒度、可随时回收的显存单元」,连续批处理则利用这些单元做高吞吐调度——两者是「 ammunition(弹药)与扳机」的关系。单独做分页而不改调度,收益有限;单独做连续批处理而不分页,显存管理仍会卡脖子。
2.2.5 讲了前缀共享(copy-on-write)的甜头,但要诚实指出它的适用边界,免得你高估收益:
用第 1 章的 KV 公式做道估算,感受分页的价值。假设某模型每 token 的 KV 约 0.5 MB(bf16、较大量级),单卡显存 80 GB 中留 40 GB 给 KV:
差距是数量级的并发空间,直接等价于「同样硬件多扛几倍流量」。请注意这只是一个示意数量级,真实数字随模型、精度、批内长度分布剧烈变化——但「分页把可用容量从两三成拉到接近满」这个趋势是稳健的。
下一节 2.3,我们把两个模块放到一起,看它们在真实推理引擎里如何协同,以及最常见的搭配误区。