1. 从显存焦虑说起:大模型推理为什么卡在内存上
做过大模型推理部署的工程师,大概率都经历过类似的场景:模型加载只占了几个GB,但一跑起来并发一高,显存直接拉满,然后就是OOM崩溃。很多人第一反应是模型太大,但其实更隐蔽的凶手是KV Cache——也就是推理过程中缓存下来的Key和Value张量。
用GPT类模型做生成时,每个新token都需要对之前所有的token做注意力计算。为了不重复计算,业界通行做法是把之前每层的Key和Value都存下来,下次直接用。这个缓存的增长速度非常惊人。以7B参数的模型为例,4096长度下,单条请求的KV Cache大约需要几十MB显存;如果是13B、70B参数,配合更长的序列,几百MB甚至上GB都是常有的事。显存本身就是稀缺资源,KV Cache和激活值、模型权重争抢空间,一旦并发上来,碎片化、浪费、溢出,问题一个接一个。
大家通常的缓解方式很朴素:要么batch开小一点,要么限制最大序列长度。这些办法确实有效,但代价是吞吐上不去,本来能同时处理10个请求的卡,可能要降到4个。真正解决这个问题的关键思路,其实是借鉴操作系统的一个经典设计——虚拟内存的分页管理。把KV Cache切成固定大小的块,逻辑上连续、物理上分隔,用时分配、不用释放、需要共享时还能通过引用来复用。这套机制在推理框架里的落地形态,就是PagedAttention。
我第一次看到这个思路的时候,第一反应是:这其实不是在搞什么全新数学,而是把几十年前操作系统的内存管理策略搬到GPU上。但恰恰是这种“老方案新场景”的迁移,解决了大模型推理落地中最棘手的一段隐性内存困境。这篇文章不打算给你复述论文结论,我会拆开块映射、动态分配、写时复制这三个核心机制,聊清楚它们到底解决了什么问题、内部是怎么运转的,以及在真实部署中你会碰到的坑和调优经验。
2. 块映射:用一张表解决“逻辑连续物理离散”的问题
2.1 为什么连续显存是奢望,碎片化才是常态
推理过程里,KV Cache的分配是按序列增长的。每个请求的序列都在变长,每个请求的长度也各不相同,如果按传统方式给每个请求预先分配整块连续显存,会出现两个问题。
第一是内存浪费。为了不频繁扩容,通常的做法是预留最大可能长度。比如预设最大长度2048,那每个batch槽位都会预留2048长度的KV Cache,哪怕这个请求实际只生成了200个token就结束了,剩下1848个token的空间全部闲置。支撑数百并发时,显存浪费比例往往会超过一半。第二是碎片化。处理完的请求释放显存后,空闲区域是被切割成大小不等的不规则区块的。新请求要分配大块连续内存的时候,即便总体剩余显存足够,也会因为没有足够大的连续区域而失败。
PagedAttention没有选择“尽量找连续大块”的路线,而是反其道而行之:放弃物理连续性,只保证逻辑连续性。它的做法是把KV Cache切成固定大小的块(block),每个块能存固定数量token的Key和Value。比如块大小设为16个token,那么每16个token的KV就放进一个块里。序列的KV Cache不再是一整块内存,而是一串块列表。
这些块在物理内存上的位置不需要相邻。某序列的第二个块可能在第3号物理块上,第三个块在第97号物理块上,完全错落。为了让逻辑顺序正确,每生成一批token,系统就把新分配的物理块编号追加到该序列的块索引表里。读取时,按逻辑顺序遍历索引表,逐个访问对应的物理块。
提示:块大小是个关键超参。块越小,内部碎片越少,但索引表和分配开销越大;块越大,管理开销小,但每条序列尾部未用完部分浪费的内存越多。常见默认在16到32个token之间,实际取舍需要结合平均生成长度来评估。
2.2 块表结构:一张表如何支撑任意长度的序列
从工程实现上看,块映射的本质是一张“逻辑块号 → 物理块号”的翻译表。这张表维护了每个序列的块列表、每块的引用计数、物理块的空闲状态等元数据。访问某个块时,通过索引查表一次就能定位到物理地址。
类似CPU里面TLB和进程页表的语义。GPU上访问KV Cache需要频繁执行这个查表动作,所以设计上必须保证这个翻译过程尽可能快。在实现层面,一个好的技巧是,把块表放在共享内存或者专用的高速缓存区域,避免每次访问都走一遍全局内存查表。
这里还有个小细节:物理块是全局共享的。一个物理块在某一个时刻可能只属于一个序列,但也可能被多个序列同时引用——这为后面讲到的写时复制(Copy-on-Write)打下了基础。因为块是共享的,所以每个块都要维护一个引用计数,表示当前有多少序列在使用这个块。引用计数归零时,这个块才会被真正回收。
来看一个典型的KV Cache块表结构:
| 序列ID | 逻辑块索引 | 物理块编号 | 引用计数 | token数(最后一块) |
|---|---|---|---|---|
| Req_A | Block 0 | Phy 12 | 1 | 16 |
| Req_A | Block 1 | Phy 45 | 1 | 16 |
| Req_A | Block 2 | Phy 21 | 2 | 9 |
| Req_B | Block 0 | Phy 21 | 2 | 9 |
这三个序列共享了同一个物理块Phy 21——它们在某一段历史时刻生成的token是完全一样的,所以显存里只需要存一份。这也是PagedAttention最精妙的地方:它把KV Cache的存储粒度从“整条序列”降到了“块”,共享和复用的可能性被大幅打开了。
2.3 从虚拟内存借用过来的设计哲学
虚拟内存为什么能对应用“撒谎”,告诉进程你有无限连续的内存?因为中间加了一层映射关系。物理内存随便怎么碎片化,只要映射表能把不连续的地方串成连续的逻辑地址,进程就感知不到底层的碎片。PagedAttention对KV Cache做的事情本质上一样。
一个token序列在逻辑上依然是连续的——从第1个token到第N个token顺序推下去不会有断点。但在物理层面,这些token的KV可以分布在任意物理位置。映射关系完全由块表决定。这就把“显存碎片化”这个难以根治的问题,转化为“在块表里查一个编号”这种极低成本的操作。
这种“加中间层解决不连续问题”的思路,在计算机系统里并不少见。文件系统用块号映射解决文件碎片问题;数据库用缓冲池解决磁盘块和内存页的映射问题。PagedAttention只是把这个成熟思想代入到GPU内存管理场景里。它最大的意义在于:当你不再需要连续物理内存时,内存的调度灵活性就剧增了。这为后面要说的动态分配和写时复制扫清了机制上的障碍。
3. 动态分配:按需取用,拒绝静态预留
3.1 静态分配的显存黑洞效应
传统推理框架对KV Cache最省事的管理方式是什么?预分配。比如最大序列长度定义为2048,batch大小定义为8,那KV Cache总量就是8×2048×层数×头数×每个值占用字节数,一次性用torch.empty或者CUDA API分配好。之后不管请求实际是否用到,这些显存都被永久“锁住”了。
这个设计在并发低、长度固定的场景下很简单,但真实业务场景下请求长度差异极大。有的请求只生成50个token就结束,有的却要写一篇2000字的文章。按最长的来预留,短请求的显存就白白浪费;按平均长度留,长请求又有被截断的风险。静态分配本质上是让用户为最坏情况买单。
PagedAttention把分配的粒度从“序列”级别降到了“块”级别。每个序列开始时只分配第一个块。当生成的新token超过当前块的容量时,才从全局块池里申请下一个块。块用完了,就还给全局池,其他序列可以马上复用。
这套机制带来的最直观收益就是:显存只跟着“实际生成的token数”走,而不是跟着“最大可能的token数”走。在真实业务中,请求的实际生成长度往往远小于上限,显存利用率通常能提升2到4倍。这意味着同样的GPU,可以同时跑更多的并发请求,整体吞吐自然就上去了。
3.2 全流程:从请求进入到块回收
动态分配虽然听起来只是“按需分配”,但工程上必须处理一整套状态流转。这里简单拆几条主线路径。
分配路径:新请求进入时,从全局空闲块池中取一个块,初始化引用计数为1,挂到请求的块表尾部。当生成循环发现最后一个块已满(比如块大小16,已经存了16个token),就再向空闲块池申请新块。如果空闲块池耗尽,系统进入阻塞或调度状态,等待其他请求结束、释放块,或者实施一些强制回收策略。
释放路径:请求结束后,遍历其块表,把每个块的引用计数减1。减到0时,物理块真正归还给空闲池。如果引用计数仍然大于0,那说明还有其他序列在共享这个块,不能释放,只需要断开当前序列的引用即可。
扩容路径:如果整个全局块池不够用了,CUDA malloc新的显存并扩容空闲块池是一种常见的做法。但要小心,这类动态显存扩张可能引入额外的地址不连续,设计上要做两层映射:块表只保存块编号,具体物理地址由块编号二次解析。这样即使底层显存地址变动,上层也只是改一个地址表项的事。
整个生命周期涉及块状态的变化:空闲→已分配→共享(引用计数>1)→可回收→空闲。状态机的鲁棒性很重要,最容易出bug的是共享计数和块释放的顺序问题。如果先减引用计数再清理地址映射,刚好有其他请求在读这个块,数据就出现悬空指针问题。正确的顺序应该是先摘除映射表里的对应项,再去递减引用计数。
注意:我在写分布式推理服务的时候踩过类似坑。对引用计数做原子操作还不够,必须配合内存屏障确保可见性。多线程并发分配/释放场景下,一个块的引用计数状态对别的线程不可见,会导致野指针和显存泄漏风险,这类问题排查起来极度痛苦。
3.3 GPU显存里还有多少利用空间:批处理如何从块收益
动态分配还有一个隐藏收益,就是它直接重构了批处理(batching)的方式,而批处理正是GPU利用率的核心来源。
传统连续KV Cache约束下,一个batch内的所有请求往往被强行限制成长度差不多,否则短请求生成完后,它所占据的那段预留给它的KV Cache空间就只能空着,利用率极低。
在PagedAttention的块级分配下,每个请求独立管理自己的块列表,生成结束的请求可以立即释放空间,新的请求可以随时进入batch,这个在工程上叫作连续批处理(Continuous Batching)。GPU上每一轮迭代,选一批当前需要计算的请求,它们各自的KV Cache分散在不同物理块里,但计算逻辑不变,对每个请求,按它的块表顺序,逐个块读取KV,完成注意力计算。
正因为没有“空闲槽位必须固定在某个位置”的约束,调度器可以按需组合任意请求进入batch,理论上每个迭代都能保持GPU算力满负荷运行。在这个层面上,PagedAttention对于GPU利用率的意义,远远大于单纯省显存。
4. 核心妙手:写时复制如何把显存需求指数级压缩
4.1 一次采样生成多个候选序列的显存噩梦
大模型生成过程里,有一种非常常见但显存开销极大的场景:并行采样生成多条候选序列。用户在做beam search(集束搜索)、best-of-n采样、或者self-consistency(自洽性采样)时,要从同一个prompt出发,在某个分支点同时生成多个不同的后续序列。
从同一个prompt前缀开始,这些序列前面的KV Cache其实是一模一样的。如果给每条候选序列都单独存一份完整的KV Cache,显存开销会按候选数量线性放大。假设beam size是4,完整的KV Cache原本就要占1GB,那4条序列就要4GB,实际上一大半都是重复存储。
在没有块级映射机制的时候,这个问题很难解。要么就接受成倍的显存浪费,要么就限制beam size,效果都有代价。但通过PagedAttention的块级共享+写时复制,这个问题做到了几乎“免费”的水平。
4.2 Copy-on-Write的块级实现方案
要理解这里面的机制,关键在于“何时复制”和“复制什么”。
假设起点是一个已经生成的prompt前缀,它对应的一系列物理块已经被分配好了。现在要从这个前缀分出4条候选序列。正常的做法是:给这4条序列各自分配一份完整的KV Cache空间,然后把prompt前缀的KV逐块复制过来。这个过程又慢又浪费。
PagedAttention的做法截然不同:这4条序列的前缀部分,直接共享同一组物理块,不做任何数据复制。在每条序列的块表里,引用相同物理块编号;每个块的引用计数变成4。因为大家引用的数据完全一样,所以没有正确性问题。
接下来,每条候选序列继续独立生成各自的token。新token的KV Cache,需要申请新的物理块来写。在这个点上,就遇到了“写时复制”的核心判断:当一条序列需要写入某个块,但这个块被多个序列共享时,它必须先检查:这个块是否只属于我?如果不是,我就需要复制一份物理块数据,然后只更新自己的块表指向新的物理块,再在新块的副本上写入新数据。
这整个过程可以拆解成几个关键判断点:
- 读取前缀块:直接读,不需要复制,因为所有序列读到的都是同样的KV。
- 写入新块:这个块单独属于当前序列,直接写。
- 写入共享块:需要先触发复制,然后把原来共享块的引用计数减1,再把当前序列的块表指向新副本。
值得强调的是,这种复制是懒惰的(lazy)——复制只发生在实际要写入那个块的时候,而不是从分支一开始就把所有前缀块复制一遍。如果一条候选序列生成了两步就终止了,那它可能从头到尾只复制了一次或两次块。相比“创建分支时全量复制”,这种按需复制策略,在候选序列寿命不长时,节省的显存和时间都非常可观。
4.3 引用计数与安全回收:写时复制最容易出错的一环
写时复制机制里最需要小心的地方,除了何时复制,还有何时释放。前面提到,引用计数决定了物理块何时可以被回收。写时复制场景下,引用计数会频繁地增减,一个块的引用计数可能在1、2、4、3之间反复跳动,任何一步出错都可能导致严重后果。
一类典型错误是:某条序列在被调度器淘汰时,遍历自己的块表,把每个块的引用计数都减1。如果某块引用计数减到了0,但这个块实际上仍被其他序列引用着,那么其他序列在后续迭代中会读取被回收的内存,数据随机、计算崩溃。这类问题在多序列并发场景里极其隐蔽。
工程上的建议是:对所有引用计数的修改,统一集中到一个模块管理,提供inc_ref(block_id)和dec_ref(block_id)两个原语接口,所有读写路径都通过这些接口操作。不要在业务逻辑代码里零散地修改引用计数,否则迟早会漏掉某个路径。
经验之谈:对于写时复制,强烈建议在调试模式下定期做显存一致性检查——把每个物理块的引用计数遍历一遍,与实际块表中的引用关系做交叉验证。我在实际项目里就是靠这种一致性校验,抓到了一个非常隐蔽的双重释放问题。
5. 实操视角:PagedAttention的典型实现路径与调优
5.1 从零实现时的核心模块拆解
如果要在自己的推理引擎里实现类似PagedAttention的机制,大概会涉及三个核心模块:
第一个是显存分配器。它维护一个物理块池,对外提供alloc_block()和free_block()接口。分配器的目标是最小化空闲碎片,同时保证分配效率。实际实现时,可以用自由链表维护空闲块,也可以用红黑树支持按地址查找,复杂度可控。
第二个是块表管理器。每个正在处理的请求拥有一张独立的块表,维护逻辑块到物理块的映射。块表需要在请求生命周期内动态扩展,所以底层通常是一个指针数组或者链表结构。读取时,通过逻辑块编号查物理块地址;写入时,可能需要触发写时复制。
第三个是核心注意力kernel。标准的注意力kernel假设KV Cache是连续内存,可以直接通过token位置索引。PagedAttention下的kernel必须改为“先查块表,再访问块数据”的模式。这一步其实是最影响性能的:查表的开销必须足够小,否则多级间接寻址带来的延迟会抵消省下显存带来的吞吐收益。业界通常会把块表预加载到共享内存,或者直接让kernel在读取下一个块时做预取(prefetch),隐藏掉查表延迟。
实现过程中最容易被忽视但也最关键的,是块表的缓存友好性。GPU上的内存访问模式对性能影响极大。如果按照逻辑token顺序读取,但物理块地址跳来跳去,会打乱GPU的合并内存访问(coalesced memory access)模式,导致实际带宽利用率暴跌。针对这个问题的一个常用优化是,对同一个序列的块尽量在物理内存中保持连续分配,让大部分访问路径仍然是线性的。
5.2 关键参数:块大小应该怎么定
块大小(block size)是PagedAttention最重要的超参数,直接影响内存利用率和kernel效率。从论文和工程实践来看,需要在这两者之间做一个清晰的权衡。
块大小越大,管理开销越小,kernel访问KV Cache时的局部性越好,但尾部浪费会增大——每个序列最后一个块往往装不满,造成平均约半个块的浪费。块大小太小,分配频率变高,块表查找开销更大,而且写时复制频繁。
一般来说,如果生产环境里序列长度的分布整体较长,块大小可以适度调大;如果很多短请求,块大小又应该调小。我见过一些团队直接把块大小设成16或者32,再用实际流量回放做A/B对比,这比反复读论文里的推荐值靠谱得多。根据我的经验,16到64之间通常能找到当前业务的最佳点,不要相信一个设置能通吃所有场景。
5.3 显存扩容与空闲块水位线设计
一个真实的推理服务,块池的总量并不会一成不变。系统需要根据显存压力动态扩容或者收缩。这里有一个设计问题:空闲块池的水位线应该怎么设置?
如果空闲块太少,高并发场景下新请求可能等待块分配而排队,产生不必要的延迟。如果空闲块太多,说明大量显存被“预留”但却没有实际使用,资源利用不充分。
一个常见做法是设置两个阈值:低水位和高水位。当空闲块数低于低水位时,尝试扩容块池;高于高水位时,可以考虑收缩。还可以根据预测算法预估未来请求量,提前扩容到目标水位。
GPU显存不像系统内存可以随时换页,所以水位线策略要和调度器配合。一种常用的技巧是:如果空闲块不足,不立即报错或阻塞,而是把调度器改为“等待模式”,让当前批次的请求继续生成,直到释放出可用块,再吸收新请求。这样能极大缓解显存突发压力,代价是某些请求的排队延迟变大。
5.4 实测调优案例:一个并发翻倍的优化过程
我在做某跨平台系统部署时,最初按传统静态分配方式配置,batch size最大开到16,显存就逼近上限。每次请求都要预留2048序列长度的KV Cache,但实际平均生成长度只有300多token,显存浪费比例接近80%。
切换到PagedAttention方案后,同样的GPU,batch size提升到48还能维持稳定运行。显存利用率的提升主要来自两个环节:一是块级分配让短请求不再拖累整体空间,二是写时复制让beam search场景的显存成本几乎降到了原来的四分之一。
整个调优过程其实没有太多高深技巧,就是几个动作:把块大小设为32,空闲块低水位设为总量的15%,写时复制开关打开,然后反复压测。最有意思的一次问题是,明明总占用显存下降了,吞吐却反而下跌了。排查半天发现是kernel的块表查表逻辑写得太蠢,每一轮都从全局内存里重新读块表,导致显存访问量在查表上暴增。后来把块表缓存到共享内存里,吞吐立刻恢复了。这提醒我一个道理:省显存只是第一步,把省下来的资源真正转化为计算效率,才是PagedAttention落地的关键。
6. 常见问题速查与实战避坑指南
6.1 显存泄漏排查:引用计数为什么会越滚越大
显存泄漏是PagedAttention实现里最让人头疼的问题之一。块池的空闲块数量一点点减少,最后服务OOM。
我遇到过的典型泄漏场景包括:请求被异常中断时,没有走正常的块表释放逻辑,导致已分配的块没有归还到空闲池;连续批处理调度中,请求在某一轮被提前移出batch,但块表的引用计数没有同步更新;多线程环境下,引用计数被并发修改,丢失了一次decrement操作。
排查思路很简单:在分配器模块里加一个统计计数,记录当前已分配物理块总数和引用计数总和。如果引用计数总合大于块表里实际引用数的总和,就说明有引用泄漏。还可以为每个块记录分配时的调用栈,一旦发现异常情况,直接看是哪条路径只增不减。
6.2 吞吐上不去:查表开销是隐形瓶颈
不少人以为只要上了PagedAttention,吞吐就自动高了。实际上,如果kernel实现得粗糙,可能比连续内存版本还慢。
块表每层KV Cache都要做一次逻辑块号到物理块号的翻译,翻译过程涉及一次访存。如果翻译路径设计不好,等于每个token多了一次额外访存延迟,累积起来非常可观。
解决方法:把当前批次所有请求的块表预取到共享内存;采用两级块表结构,让常用块尽量命中L1/L2;对多次重复读取的块数据做缓存;从kernel层面预测下一个物理块并提前发起加载。
如果这些优化都做了,吞吐还是上不去,那大概率是块大小或调度策略的问题,要用profiler对照实际瓶颈再调。
6.3 显存分配不出来的死循环
低显存场景下,空闲块池耗尽,但块池上方还压着部分并不需要立即释放的空间,这时候如果调度器逻辑写得不严谨,可能进入死循环——一直在等待某个块释放,但所有请求都在等待分配块继续生成,谁也等不到谁。
正确的设计是:当空闲块不足时,优先对当前batch内部分请求执行强制终止或者临时降级策略,释放显存,而不是死等。还有一种方案是,把当前batch中已完成全部生成步骤的请求立即踢出,释放KV Cache,而不是等到下一轮调度时再处理。
提示:设置一个硬性上限,当某个请求的空闲块等待时间超过阈值,就要做出取舍,宁可牺牲一部分请求的完成度,也要保住系统整体不陷入僵局。
6.4 一个容易忽略的点:前缀共享的边界
写时复制能省显存,但要小心:前缀的KV Cache并不总是完全可共享的。如果两条序列在某个位置的分支点已经不同(比如一个接着写“今天天气很好”,另一个接着写“今天的股价波动”),那么从这个分支点往后的物理块引用关系就完全不同。在实际实现时,块表里的引用关系必须精确到逻辑块,而不是整个前缀都一刀切共享。
一个简单稳妥的策略是:前缀完全相同的那段KV Cache块共享;进入分支后,新逻辑块一律独立分配,不再共享。块级粒度让这种“精确到块”的共享成为可能——整个前缀里,只有还没产生分歧的那一段才会被引用计算覆盖到。
7. 顺带聊几句:这套机制能扩展到哪些更远的场景
PagedAttention的设计里最核心的思想,也就是“块级映射+共享引用”和“懒惰复制”,不只适用于大模型的KV Cache。类似的显存优化技术,在多模态模型的视觉token缓存、长文本的向量索引缓存、以及各类LLM Agent式的长上下文推理服务里,都有直接或间接的移植价值。
比如在长文本对话场景里,系统经常需要维护同一用户的多轮历史上下文。不同轮次的对话之间,历史前缀重叠度很高。用块级映射管理多轮对话的KV Cache,不同会话可以安全共享前缀块,按需增量保存后续token,不仅省显存,还能显式控制历史上下文的保留与淘汰,这比以前的“按轮次整段缓存”要优雅得多。
另一个方向是跨请求的prompt复用。如果多个用户同时请求同一个热度很高的prompt前缀(比如公共的system prompt或工具定义),这个前缀块的引用计数可以支持超大规模共享。只要前缀块未被写入,并行请求之间就能够完全复用同一份显存,也因此省去了重复计算这部分前缀的时间开销。这些思路本质上已经超越了PagedAttention论文本身的范围,变成了一套“显存资源池化 + 引用管理”的通用方法论。
我个人的实际体会是,这套机制越深入到工程细节,越能理解它最开始为什么要从“块”出发做设计。正是靠块这个粒度,分配、映射、共享、复制、回收这些操作才能同时成立。用连续内存那一套思路,KV Cache共享根本无从谈起。你现在如果正在做长上下文推理、高并发推理服务、或者多候选序列生成,我建议认认真真把块映射、动态分配和写时复制这套机制从原理到代码级别吃透,它对系统整体收益的影响,短时间内很难被其他优化方式替代。