第 11 章 KV Cache 内存管理:PagedAttention 与前缀缓存¶
开场问题:显存监控显示还剩 20 GB,为什么再来一个请求就 OOM?
11.1 静态预分配为什么浪费¶
最直白的实现是:请求进来时,按它声明的 max_tokens 一次性分配一整块连续显存,形状是 [层数, 2, KV 头数, 最大长度, head_dim]。
两个问题立刻出现。内部浪费:用户声明 max_tokens=4096,实际生成了 80 个就结束了,剩下 98% 的空间白占了一路。外部碎片:不同请求申请和释放不同大小的连续块,久而久之显存被切得七零八落——总量还剩 20 GB,但找不出一块连续的 2 GB,于是新请求分配失败。这就是开场问题的答案。
PagedAttention 论文里测得的数字是:传统实现中 60%–80% 的 KV 显存被浪费掉。而 KV 显存直接决定并发数,也就直接决定吞吐和成本。
11.2 PagedAttention:借用虚拟内存的思路¶
操作系统早就解决过一模一样的问题——进程要连续的地址空间,物理内存却是碎的,办法是分页加页表。KV Cache 照搬这套。
具体是这样做的:把显存里的 KV 区域预先切成大量固定大小的物理块(block),每块存固定数量 token 的 KV,比如 16 个 token(vLLM 的默认值)。每个请求维护一张自己的 block table,记录"我逻辑上的第 0–15 个 token 在物理块 #732,第 16–31 个在物理块 #109,第 32–47 个在物理块 #58"。
于是:请求的 KV 在逻辑上是连续的,在物理上可以散落在显存任何地方;生成到第 17 个 token 时才去申请第二个块,按需分配;请求结束时把它占的块全部还给空闲池,立刻可被别人使用,不产生外部碎片。唯一剩下的浪费是最后一个块里没用满的部分,平均半个块,也就是 8 个 token——从"浪费 4000 个 token 的空间"降到"浪费 8 个"。
为什么它叫 PagedAttention 而不只是分页分配器:因为 attention kernel 必须跟着改。原来的 kernel 假设 K 和 V 在显存里是一整块连续张量,可以直接按步长遍历;现在它必须先读 block table,再按表里的物理块号去一块块地 gather。这是一个真实的 kernel 改写工作,也是这项技术的主要实现成本。
它不减少 KV 的理论字节数——模型该产生多少 KV 还是多少。它消灭的是预留浪费和碎片,让同样的显存能装下几倍的并发请求。这也正是它属于第二册而不是第一册的原因:模型一个字节都没改。
11.3 块大小怎么选¶
块太大(比如 128 token),最后一块的内部浪费回升,短请求尤其吃亏。块太小(比如 4 token),block table 变长、每次 attention 要 gather 的次数变多、访存更零碎,kernel 效率下降。
16 是一个在多数场景下平衡得不错的值,但它和 head_dim、KV 头数、具体 kernel 实现都有耦合。换模型或换硬件时值得重测。
11.4 共享与写时复制¶
分页带来一个额外好处:两个请求如果前缀相同,它们的 block table 可以指向同一批物理块。系统给每个块维护引用计数,只读时共享,某个请求要在共享块上写入新 token 时,先复制一份再写(copy-on-write)。
最直接的受益场景是并行采样:同一个 prompt 要生成 4 个不同的回答,prompt 那部分的 KV 只需要存一份。往下推,就是下一节的前缀缓存。
11.5 Prefix Caching:唯一不损精度还能省一个量级的优化¶
真实负载里重复内容多得惊人:所有请求共享同一段系统提示词;few-shot 例子每次都一样;多轮对话里第 N 轮的前缀就是第 N−1 轮的全文;RAG 反复引用同一批文档;Agent 每次工具调用都把完整轨迹重新带一遍。
既然这些前缀的 KV 已经算过,就别再算。实现上通常有两条路:块级哈希(对每个块覆盖的 token 序列做哈希,查表命中就直接复用那个物理块)和 radix tree(把所有活跃序列组织成前缀树,天然支持最长前缀匹配)。
收益的量级值得强调:一个十轮的 Agent 会话,如果每轮都全量重算 Prefill,总 Prefill 计算量是 O(n²);有了前缀缓存,每轮只算新增的那部分,降到 O(n)。在 Agent 和多轮场景下,这是所有优化里收益最大的一项,而且完全不损失精度。很多团队把它排在量化和 kernel 优化之后,顺序搞反了。
代价在正确性上,缓存键必须包含所有会影响 KV 数值的东西:token 序列本身、模型版本、对话模板、tokenizer 版本、位置编码配置、LoRA 适配器、多模态输入的内容哈希。少一项就会串号——把 A 用户的上下文接到 B 用户的回答里,这是最严重的一类线上事故。跨租户共享还会泄漏"某个前缀是否存在"这一信息,安全边界必须显式设计。
11.6 装不下的时候:驱逐、换出还是重算¶
显存紧张时调度器要做取舍。驱逐(丢掉某个请求的 KV,之后需要时重新 Prefill)实现最简单;换出(把 KV 搬到主机内存,之后再搬回来)保住了计算成果,但要过 PCIe。
判据是一次带宽账:搬运 1 GB 的 KV 过 PCIe 需要多久,重新 Prefill 这段 prompt 又需要多久。很多情况下重算比搬运更快,因为 Prefill 是算力受限的,而 GPU 算力相对 PCIe 带宽极其充裕。所以"换出到 CPU"不是无脑更优的选项。
11.7 混合模型带来的新问题:两套状态¶
第一册第 12、13 章讲的混合架构(GDN 或 Mamba 层加少量标准注意力层)在推理侧制造了一个全新的工程问题。
线性层保存的不是 KV,而是一个固定大小的循环状态。它不随序列增长,看起来很美好——但它不能按 token 分页(它是历史的压缩混合物,没有"第 17 个 token 的状态"这种东西),不能做前缀缓存的任意拼接(两段前缀的状态无法合并),驱逐之后也不能只重算一部分。
于是引擎要同时维护两套内存管理:标准注意力层的分页 KV(可分页、可共享、可部分驱逐)和线性层的固定状态(整体分配、整体迁移、要么全有要么全无)。抢占、前缀缓存命中、请求迁移这些操作在两套状态上的语义都不一样。这是 2025 年之后才浮现的问题,前几年的推理系统设计里完全没有。
核心问答¶
Q:PagedAttention 到底省了什么? 省的是预留浪费和外部碎片,不是 KV 本身的大小。模型该产生多少 KV 还是多少,但同样的显存能装下几倍的并发。
Q:block table 里存的是什么? 一个数组,第 i 项记录"本请求逻辑上第 i 块 token 的 KV 存在哪个物理块里"。attention kernel 每次都要先查它。
Q:为什么 attention kernel 必须重写? 因为 KV 不再是连续张量,kernel 要按 block table 做间接寻址和分块 gather。这是分页方案的主要实现成本。
Q:前缀缓存为什么该优先做? 它直接消掉计算,不是把计算搬来搬去,而且完全无损。在多轮和 Agent 负载下收益可达一个数量级,远超 kernel 层面的百分之几十。
Q:线性注意力的状态能不能也分页? 不能按 token 切分。它是压缩后的历史,只能作为整体管理。混合模型需要引擎里有两套内存语义。