☰
KV Cache显存优化:PagedAttention的块映射与写时复制
2026/10/19 10:34:31 网站建设 项目流程

我先说说为什么会专门挑这个题目来写。如果跑过大模型推理服务,尤其是自己搭过基于KV Cache生成的长对话、长文档总结这类场景,你大概率遇到过这样的情况:显存明明还剩下不少,但只要并发稍微拉高一点,就会出现out of memory;或者同一个模型,换了一批输入长度分布完全不同的业务数据,显存占用曲线突然变得极不规律。

我在排查这类问题的过程中,一步步把PagedAttention这套机制吃透了。这套机制的牛X之处在于,它不是靠“压缩”或者“量化”省显存,而是靠“更聪明地管理KV Cache的摆放位置”来提升显存利用率,顺带解决传统连续缓存方案里的碎片化、预分配浪费、并行采样时的共享复制成本这几个棘手问题。这篇文章我就把块映射、动态分配、写时复制这三个核心机制拆开讲,顺便聊聊我在实际工程中观察到的收益和一些容易被忽略的细节。

1. KV Cache为什么是“显存黑洞”:先把这笔账算清楚

想理解PagedAttention为什么有效,先得明白KV Cache在传统实现里是怎么存的,以及它到底吃掉多少显存。

1.1 KV Cache的规模不是线性增长的,是指数级的心头大患

Transformer结构里,每个token在做attention计算时,会产生各自的Key和Value向量。自回归生成下一个token时,新token要与已有所有token做attention,已有的Key和Value必然要被反复使用。与其每次都重新计算一遍,不如把之前算好的K、V缓存起来,这就是KV Cache的由来。

但问题在于,KV Cache不是只有一层,Transformer有多少层,每一层都要缓存一份;当前对话有多长,就缓存多长的历史。公式可以写成:

KV Cache显存占用 ≈ 2(K和V两份) × num_layers(层数) × max_seq_len(序列长度) × hidden_size(每层维度) × precision_bytes(精度,fp16下为2字节)

拿一个7B量级的模型举例,大致配置是:32层、hidden size是4096、fp16精度、上下文长度支持4096。如果此时并发拉满到16条请求,另外每个请求都接近最大长度,这个峰值占用算出来大概是:

2 × 32 × 4096 × 4096 × 2 × 16 ≈ 32GB

也就是说,在推理这种规模的模型时,光KV Cache一项就可以吃掉接近模型权重本身(约14GB fp16)两倍以上的显存。你以为模型是显存大头,实际上KV Cache才是真正的吞金兽。

1.2 传统“连续大数组”缓存的致命伤:预分配和碎片化

更麻烦的是,传统实现给KV Cache分配显存的方式也很粗暴:按最大序列长度给每条请求预留一块连续空间。也就是说,只要模型配置里写了context length是4096,就按4096个token的KV大小提前占好,不管你实际只生成了20个token。

这里有几个非常现实的问题:

  • 预分配浪费:线上业务的实际平均生成长度可能只有最大长度的20%到30%,但显存已经被“透支”了,这导致能同时服务的并发数被严重压低。
  • 长度波动造成的碎片:就算你取消预分配,动态给不同序列分不同大小的连续空间,因为每条请求的长度完全不一样,用完释放以后,显存间会出现很多大小不一的空洞。新的请求可能刚好找不到足够大的连续空间,明明总剩余空间足够,却依然分配失败。
  • 无法共享:一旦某条请求的KV Cache被放在一块连续区域里,你想在并行采样时让多条子请求共享相同的前缀历史,几乎不可能,除非抄一遍数据。

这种“按最大长度、要连续地址、各管各”的策略,和操作系统里内存管理早期的“连续分配”方式几乎一模一样,而操作系统早就用虚拟内存和分页解决了这些问题。PagedAttention正是把这一套类似页表的做法搬进了GPU显存管理。

2. 块映射:KV Cache的“虚拟地址表”是怎么搭的

PagedAttention的核心思想,是放弃“一条序列必须占用一段连续显存”的约束,把KV Cache切分成等长的块,可以散落地存放在任意物理位置。

2.1 把大数组切成定长块,每块只装固定数量的token

假设一个块固定装16个token对应的KV数据。一条长度是50的序列,原本需要分配一个容量为50的连续缓存,现在只需要分配4个容量为16的块(3个满块加1个装了2个token的尾部块)。

每个块是一个固定大小的显存区域。比如在某种实现里,每个物理块的形状大致是:

block_size(例如16个token) × num_kv_heads × head_dim

不同层之间的KV一般是独立的块集合,但块的大小和分配逻辑是一致的。这样设计的好处是:显存不再按“序列”这种不可预期的大小来切分,而是按“块”这种固定颗粒度来切分,分配和释放的管理粒度变得非常简单和平均。

2.2 Block Table:逻辑块号到物理块号的“页表”

块切好了,下一个问题是:怎么找到一个序列的第0个块、第1个块放在物理显存的哪个位置?答案是一张映射表,也就是PagedAttention里的block table。

每条序列都会维护自己的一张逻辑块号表。逻辑块号就是这条序列自己的“第0块”“第1块”“第2块”的概念编号;而表里记录的值,是对应物理块在全局显存中的实际块号。看个直观例子:

逻辑块号(序列维度)物理块号(全局显存维度)块内token数
Block 0物理块 816/16
Block 1物理块 2516/16
Block 2物理块 316/16
Block 3物理块 4712/16(尾部块)

这条逻辑上连续、看起来是“从第0个token到第50个token”的KV序列,实际在显存里被拆到物理块8、25、3、47这四个完全不相邻的位置。计算attention时,只需要按block table找到对应物理块地址,取出KV数据即可。对上层模型代码来说,它感知不到这种离散存储,只会觉得KV数据是按序可取的。

2.2对应的内容其实可以继续展开两句:物理地址到真实GPU显存地址的映射,一般由块分配器或缓存分配器维护。每个物理块在分配时会记录对应的显存偏移量。当block table里记了“逻辑块2指向物理块3”,实际读取时就会从物理块3的起始地址开始连续读,块内部的数据仍然是连续的。这正是PagedAttention和“纯页式存储”的相似之处:块内连续,块间离散。

2.3 块映射带来的直接收益:碎片不再致命

有了块映射,之前说的“找不到连续大空间”的问题基本消失。因为每个块都是一样大的,释放任意一块,它就可以被任意其他序列使用。整个显存池被划分成等量的小格子,只要剩余的空闲块数量足够,任何序列都能获得存储空间,不必关心这些空闲块是连在一起的还是散落在各个角落。

这就像你有一堆统一规格的储物柜,而不是一个大仓库。任何物品都能拆开放在任意多个柜子里,柜子之间不需要彼此相连。碎片化的概念被大大稀释了——只要柜子够用,大件物品也能塞进去。

3. 动态分配:显存不再“死占”,而是用到哪分到哪

块映射解决了“KV Cache怎么散落摆放”的问题;动态分配解决的是“KV Cache什么时候拿、什么时候还”的问题。

3.1 从“按最大长度预分配”到“按需逐块增长”

传统做法在请求开始时,就按max_seq_len一次性分配整块显存。PagedAttention的做法是:请求开始时只分配容纳现有token数量的块。随着自回归生成,每个新token产生后,需要写入新的KV数据,此时如果当前最后一个块已满,就再申请一个新的物理块,挂到block table末尾。

这种“写满了再申请”的策略,非常像操作系统里的请求调页(demand paging),按实际使用情况懒惰分配。效果很直接:一个只生成50个token的请求,就只占用4个块(4×16=64个token容量),而不会占用最大长度对应的256个块。线上业务里大量短文本场景,显存利用率可以瞬间翻倍。

3.2 分配器与引用计数:谁在用哪个块,一目了然

动态分配需要一套块管理器来记录全局所有物理块的状态。通常每个物理块有以下字段:

  • 状态:空闲 / 已分配。
  • 引用计数:当前有多个少序列的逻辑块映射到这个物理块。绝大多数情况是1,在做并行采样共享KV时可能大于1。
  • 物理地址:对应的显存偏移,或者缓存块张量中的行id。

分配流程大致是:模型发来“我需要一个新的物理块来存下一段KV”的请求,块管理器从空闲列表中弹出一个块,把引用计数设为1,把物理块号返回给调用方,调用方把物理块号追加到序列的block table末尾。释放流程则反过来:序列终止时,遍历它的block table,每块引用计数减1;减到0的块归还空闲列表。

这里有一个容易踩的坑:显存里的物理块池本身是怎么创造的?常见的做法是在推理服务初始化阶段,先预留一块连续显存,然后把它均匀地切成物理块。但预留多少是个问题——预留太多,本身就是浪费;预留太少,高并发时还是会OOM。成熟实现里一般会根据总显存、模型权重占用、最大并发数综合估算,也有一些实现支持一定程度的动态扩容,但复杂度和稳定性风险都会上升。

3.3 动态分配对性能的副作用,以及怎么消除

动态分配不是没有成本。每次申请新块都是一次管理器层面的操作,如果极端情况下每个token都触发一次新的块分配,这个开销会被放大。为了减少分配频率,块设计得越大,分配次数越少,但尾部浪费越多。我见过不少实际调优场景,block size从8调到32,分配次数能下降好几倍,但显存碎片浪费率也会上升,两者本身就是一个需要根据业务折中的参数。

另一个副作用是物理块池预留过大时,GPU显存里会有一块“冻结”的缓存区。如果你同时跑推理和训练,这个区域不能随便让出去,容易造成整体显存不灵活。所以如果要在自己的推理引擎里实现类似的机制,建议把物理块池做成可配置的,而不是写死。

4. 写时复制:并行采样里“共享前缀”的优雅解法

块映射和动态分配解决的是单条请求的存储问题,写时复制解决的是多条请求之间的公共KV数据要不要各存一份的问题。

4.1 并行采样场景里,共享前缀是天然的优化点

做beam search、并行采样或者把一个prompt跑多个随机结果时,通常会先生成一个共享的前缀序列,然后在某个位置分裂出多条分支。比如用同一个prompt生成3条不同的回答,前100个token的prompt本身是一样的,这些token对应的KV Cache也完全一样。

传统实现对每个生成样本都独立维护一份完整的KV Cache,哪怕前面100个token一模一样,也要抄三份。如果生成长度长、采样条数多,这部分的显存浪费和拷贝开销非常可观。

4.2 COW的触发条件与实现流程

写时复制的出发点很朴素:既然数据和前缀部分完全相同,那就先让它们共用同一个物理块,谁也别拷贝。只有在某个样本要写入这个共享块时,才真正复制一份,各改各的。

具体流程可以简化成三步:

  1. 并行采样开始时,多个序列的block table前几项都指向同一批物理块,物理块的引用计数变成3。
  2. 某个样本继续生成,需要往一个共享的、还没写满的块里写入新token。这时发现它的block table里这个物理块引用计数大于1,不能直接改原块,触发复制。
  3. 分配一个新物理块,把原块内容拷贝过去,引用计数减1,修改触发样本的block table里对应项指向新块。新块的引用计数是1,后续可以随意写入。

这里有个容易被忽略的细节:块是否写满,决定了COW的触发时机。如果多个样本共享的块已经写满了,后续每个样本各自要写新的token时,是申请全新的空块来写,这个新块本来就是各自独占的,完全不需要复制。只有当共享块还有剩余槽位、而又不能再共用时,才需要复制一整块出去。理解这一点很关键,不然在断点调试时会觉得COW的行为忽隐忽现。

4.3 COW的权衡:省显存,但不省计算

写时复制省的是显存和初始化阶段的KV计算量。前缀共享时,每个物理块只算了一次,多个样本复用,节省了大量重复attention计算。但要注意,触发复制的那一次,确实要多做一轮块拷贝,这个带宽开销在block size较大时会比较明显。所以COW也不是万能的,它最受益的场景是:分裂点靠后、原始前缀足够长、同一块被大量样本共享。如果序列非常短,或者分裂点非常靠前,共享节省的显存可能还抵不上维护引用计数和块表映射的复杂度。

还有个工程细节:引用计数必须谨慎处理,尤其是并发环境。多条样本同时触发对同一块的COW时,如果引用计数的增减操作不是原子的,轻则计数错乱导致块被提前回收,重则显存被重复分配,出现数据串写。我自己在调试中遇到过这种问题,表现非常奇怪——有的请求生成内容突然变乱码,排查半天才意识到是共享块被另一条线程改写了。

5. 实际动手观察:块大小、物理块总量与收益验证

把机制讲清楚了,还得落到工程选型上。这一节聊聊我在实际优化推理服务时怎么考虑这些参数,以及如何验证这套机制到底带来了多少收益。

5.1 块大小怎么选:一个典型的权衡

块大小(block_size)直接决定:块表查多少项、块内部有几条token的KV、尾部浪费的期望值。

如果块大小为16,一条长度为150的序列分10个块(9个满块 + 1个6 token的尾块),尾部浪费10/160 ≈ 6.3%。如果块大小为32,尾部浪费约3.1%,但块表项更少,分配次数也更少。如果块大小为8,尾部浪费更低,但每块只装8个token,分配频率大幅上升,block table也会更长,且块级传输效率变低。

从我的实践经验看,小模型(几B级别)用32比较舒服,大模型和超高并发场景用8或者16更合适。没有绝对最优,只看你的平均生成长度和并发分布。建议在服务里把block size做成动态可配置项,跑不同业务数据时分别压测一轮。

5.2 物理块总量:一个可以提前算出来的数

物理块池能做多大,其实受限于你愿意让KV Cache占用多少显存。计算公式大约是:

物理块总量 = 你为KV Cache预留的显存字节数 / 单块字节数 单块字节数 = block_size × 层数 × KV头数 × head_dim × 2

假设你决定KV Cache最多占20GB,block size是16,单块字节数如果算出来是64KB,物理块总量就是20GB / 64KB ≈ 320,000块。这个数字看起来很大,但几千个并发序列共享这32万个块时,实际每个序列平均能占到的块就变得很清楚了。时刻要记住:物理块池是所有序列公用的,不是每条一份。

5.3 验证收益:在线上业务中看几个关键指标

怎么知道这套机制对你有效?不用看理论,看下面几个指标:

  • 显存分配率:过去按最大长度预分配时,分配率可能长期只有30%到40%。引入按需分配后,这个数值应该能提升到70%以上。
  • 并发上限:同样一块卡,如果之前只能同时跑4路长对话,改动后能跑到8路甚至更多,说明动态分配省的显存确实转化成了吞吐。
  • OOM频率:短请求和长请求混跑时,之前的碎片化很容易导致OOM;块映射之后,OOM频率应该大幅下降。

我在实际场景里测过一组很典型的业务数据:prompt平均长度450,生成平均长度120,max_seq_len配置为2048。旧方案按2048预分配时,显存利用率只有27%左右;切换到按需分块之后,利用率拉到60%以上,单卡并发量翻了近一倍。这就是实打实的收益,不需要复杂的基准测试也能感受到。

5.4 如果要在自己的代码里模仿这套机制,建议从哪里入手

如果你不打算直接引入大型推理框架,而是想在项目里复刻这套思想,我的建议是动手做一个简单的模拟器:

  1. 用一个字典模拟物理块池,键是物理块号,值是KV数据索引。
  2. 用另一个字典模拟block table,键是序列ID,值是物理块号列表。
  3. 用一个空闲列表记录可用物理块。
  4. 写三个函数:allocate_block、free_sequence_blocks、copy_on_write_block。

只需要几十行Python,就能完全复现分配、释放、COW的流程。跑通这个模拟器之后,你会对block table为什么需要引用计数、为什么COW只在未满块上触发、为什么并行采样能省这么多显存有更直观的理解。

写在最后的实践建议

我在实际调优中最大的体会是:这套机制的核心不是“省显存”这个结果,而是“吞掉显存中的不确定性”。连续缓存的最大问题不是空间不够,而是明明有空间却用不上;分块动态管理则让空间利用率变得可控、可预测。

如果你准备把类似思路引入自己的推理服务,有两个小建议。第一个是:优先做块映射和按需分配,COW可以后置。前两者几乎不改变模型计算逻辑,改动风险最低,收益却立竿见影。第二个是:把block size和物理块池大小做成启动参数,别写死。没有一种配置能适应所有业务,留出调节空间比追求单点最优更重要。我每次调优都是靠线上数据反推参数,而不是靠公式硬算。

这机制的细节其实比表面上看起来要多,也正因如此,它才值得花时间彻底吃透。如果你正在被KV Cache的显存问题困扰,希望这篇拆解能让你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询