☰
彻底搞懂KV Cache CPU Offload与交换机制:显存优化实战
2026/10/20 2:43:17 网站建设 项目流程

每次我把推理服务的批次大小往上调一档,心里都会咯噔一下:显存明明还剩几个GB,结果跑完前几个请求就直接OOM。后来我才意识到,绝大多数情况下问题都不在模型权重,而在KV Cache——这个随上下文长度和并发数悄悄膨胀的缓存。今天这篇就把CPU Offload和KV Cache交换机制从头到尾说透:显存账本怎么算、什么时候该把KV块搬到内存、按什么顺序搬回来,以及两者怎样协同才能既保吞吐、又压延迟。适合正在做LLM推理部署、显存调优、延迟优化的工程师参考。

1. 先算清这笔显存账:KV Cache为什么会卡住并发

1.1 一句话说清KV Cache是什么,以及它为什么按层膨胀

KV Cache就是推理过程中缓存下来的Key矩阵和Value矩阵。每一层Attention在计算时都要拿当前token的Query去和历史上所有token的Key做点积,再对Value做加权求和。如果不缓存,那每生成一个新token,都要把之前所有token的Key和Value重新算一遍,这个开销随序列长度线性增长,整体推理速度会慢一个数量级。

所以业界普遍做法是:算完某层的K和V之后,直接存在显存里,等后续token过来继续用。

但它贵就贵在“每一层都要存”。以常见结构举例,假设一个8B参数规模的模型有32层,每组Query对应的KV头数是8,每个头的维度是128,精度用FP16(每元素2字节),那么每个token要占用的KV缓存就是:

2(K和V两个矩阵)× 32(层)× 8(KV头数)× 128(头维度)× 2字节 = 131,072字节

也就是每token约128KB。

1.2 用一个8B模型的实例做显存账本

有了每token 128KB这个数字,算起来就非常直观了。

打卡一个10万量级用户的在线服务场景,假设单次请求平均上下文长度是4K,推理时并发batch是8,那KV Cache的占用就是:

128KB × 4096 × 8 = 4GB

如果上下文长度变成32K,单请求的KV Cache就到了4GB,8个并发请求一进来,光KV Cache就是32GB。

而一个FP16精度的8B模型,权重本身大约16GB。也就是说在40GB左右的显卡上,权重吃掉16GB,剩下可用显存里再刨掉激活值和临时计算buffer预留几个GB,留给KV Cache的空间可能也就15GB到20GB。这个容量面对“32K上下文 + 高并发”的请求分布,几乎是秒满。

另外一个容易忽视的规律是:模型权重是固定开销,但KV Cache跟“上下文长度”和“并发数”同时成线性关系。你每把并发数从8提到16,KV Cache就得翻倍;每把上下文窗口从4K提到32K,单个请求的KV Cache就会涨到原来的8倍。显存再大也扛不住这种双线性膨胀。

1.3 别想关掉KV Cache,重算的代价更吓人

有人可能会想:那我不缓存了,每次重新计算Key和Value行不行?理论上可以,但代价非常离谱。

以一次decode步为例,如果不读取KV Cache,每个新token的Query都要跟整个历史序列的Key和Value全部计算一遍Attention。推理的总计算量会从O(1)(只算当前token的Attention)退化成O(序列长度)。对于4K上下文的请求,这意味着每一步的计算量扩大数千倍,原本每步几毫秒的decode直接变成几百毫秒甚至秒级。

所以KV Cache不存在“要不要”的问题,只存在“放哪里、怎么放、什么时候搬”的问题。这也带出了本文的主角:CPU Offload和交换机制。

2. CPU Offload的物理边界:带宽、页锁定与块粒度

2.1 权重Offload和KV Cache Offload是两码事

很多教程把Offload说成一种笼统的技术,但实际工程里必须区分“权重Offload”和“KV Cache Offload”,因为两者的访问模式完全不同。

权重Offload的做法是把某些层或某些参数矩阵放到CPU内存,等计算到该层时再换回GPU。它的特点是静态、可预知——同一个层的权重一旦确定,访问顺序就固定了。既然顺序固定,调度器就能在计算前一层的同时,预取下一层需要的权重,传输延迟可以被很好地隐藏。

KV Cache Offload则完全不同。它缓存的是随请求动态增长的内容,同一个block可能被任意时刻的decode步骤访问。新token生成后,新的KV block会不断产生;旧的block也可能因为Attention的局部访问模式被随时调回来。调度器无法用“下一个固定层”来预测,只能靠启发式策略去猜。

所以,把权重Offload的经验直接搬到KV Offload上,几乎必然出问题:你会遇到换入不及时导致的显存空闲等待,或者换入太频繁导致的PCIe拥塞。

从实现层面,权重Offload做的是“一次性转移”,而KV Cache Offload需要的是“双向、可逆、按块管理”的搬运。后者的工程复杂度要高一个台阶。

2.2 搬运能力的物理上限:PCIe和内存总线能给你多少

讨论CPU Offload之前,必须先摆清楚物理通道的带宽数字。因为所有策略最后都落在这条管道的传输能力上。

当前主流的服务器PCIe 4.0 x16理论带宽是32GB/s,实际传输能到24GB/s到27GB/s就不错了。PCIe 5.0 x16理论带宽翻倍到64GB/s,实测大概能到45GB/s到55GB/s。而一块H系列GPU的显存带宽是3TB/s级别,和PCIe完全不在一个量级。

这意味着什么?拿一次20MB的KV缓存搬运来说,在PCIe 4.0的实际带宽下大约需要0.8ms。一次decode step的预算通常也就几十毫秒,所以一次小规模搬运勉强能接受。但如果你某次搬了2GB数据,那就是接近100ms的阻塞,P99延迟当场爆炸。

CPU这侧同样有瓶颈。消费级双通道内存的实测带宽大约40GB/s到50GB/s,比PCIe高不了太多。如果同时有多个请求在换入换出,内存控制器会排队,实际吞吐会进一步下降。这就是为什么调度策略不能依赖“传输带宽很宽随便搬”的假设——它必须是精打细算的。

2.3 Pinned Memory是交换性能的分水岭,职业选手都在这里下功夫

很多人做Offload时只关注“数据从GPU拷贝到CPU”,却忽略了CPU侧内存的分配方式。这里有个非常关键、也非常容易被忽视的细节:普通malloc分配的内存,GPU做DMA传输时需要通过操作系统页表转换,甚至可能触发缺页中断,性能抖动非常大;而使用Pinned Memory(页锁定内存),物理页面会固定在内存里,DMA引擎可以绕过CPU直接读写,传输稳定性和速度都有明显提升。

落到工程实操上,步骤并不复杂:

  • 在进程启动时,用类似cudaHostAlloc的接口预先分配一块固定大小的CPU内存池,而不是在请求运行时频繁malloc和free。
  • 把这块内存池切成固定大小的槽位,与KV Block一一对应。每个Block换出时直接写入对应的CPU槽位,换入时也从槽位拷回GPU显存。
  • CPU侧要预留足够的内存池,避免因为临时分配导致半路卡顿。

很多人踩过的坑是:把“内存池已经满了”当成一种异常来处理,但实际上内存池满恰恰应该是常态,调度器需要负责回收冷block、复用槽位,而不是让分配器兜底。

2.4 为什么必须是Block粒度,整段搬运行不通

关于KV Cache Offload,早年间也有过整段保存的方案——把整个序列的KV Cache作为一大块连续内存,要么全在GPU、要么全在CPU。这种方案最大的问题是:无法精确控制“搬多少”。

举个例子,如果一个请求的上下文是32K,它的KV Cache总大小是4GB。你想把它的一部分搬到CPU,另一部分留在GPU,整段方案做不到。就算可以整体搬运,一次搬4GB的成本也直接让延迟失控。

页面式缓存管理把KV Cache切成固定大小的Block,每个Block内部包含固定数量token的KV数据。比如一个Block对应16个token,那在上面的8B模型例子里,每个Block大约是128KB × 16 = 2MB。调度器以Block为最小单位,决定谁留GPU、谁去CPU、谁被换回。

Block粒度带来的最大收益是可预测性。调度器知道这次换出的是几个Block、总字节数是多少,可以根据当前PCIe剩余带宽估算这次搬运需要多少时间,进而决定是否值得执行。整段缓存方案永远给不了这个信息。

3. 交换机制的核心博弈:预判、优先级与异步预取

3.1 把GPU当主存、CPU当二级存储来分页调度

如果理解了Block粒度,就会发现KV Cache交换机制本质上就是一套“虚拟内存”系统。GPU显存是主存,CPU内存是二级存储,调度器负责把“即将被访问”的Block留在主存,把“暂时没人碰”的Block换出去。

我见过不少初学阶段的实现,调度逻辑写得非常朴素:先设一个GPU KV Cache占用率阈值,比如到90%就按某种顺序把Block挪到CPU,低于70%就停止。这确实能跑,但远不够好。

真正要解决的是两个问题:一是“哪些Block该留下”,二是“哪些Block该提前换入”。

先说第一个。由于Attention在decode阶段的访问模式其实有规律可循——新token的Query会对所有历史Block做Attention,所以理论上所有Block都可能是热点。但如果每个Block都在GPU里,缓存就失去了意义。实践中需要找到“留下收益最高”的Block集合。

再说第二个。decode是一个逐token串行过程,生成第N+1个token时,它对应的KV Cache还没产生,但第N个token所在Block的后续部分、以及它之后可能马上要用到的Block,是可以预判的。只要预判准确,就能在计算间隙异步搬运,延迟几乎无感。

3.2 LRU不够用:Attention Sink和冷启动保护才是实战关键

最直观的替换策略是LRU(最近最少使用)。每访问一个Block就更新它的时间戳,要腾空间时优先换出最久没被访问的Block。这个策略在传统操作系统中非常经典,但用在KV Cache上有明显短板。

短板出在Attention分布的一个特殊现象上:早期token会在后续几乎每一步Attention中都保持较高的注意力权重。这个现象通常被叫作Attention Sink。也就是说,序列最前面几个token对应的KV Block虽然“老”,但它实际上是高频热点,几乎每个decode Step都会高权重访问。如果LRU把它们换出,每一个后续token的decode可能都需要换入这个Block,效果等同于这个Block一直在做乒乓交换。

应对方法有两种。最简单的是做“永久驻留保护”:把序列前几个token(比如前128个token)所在Block标记为不可换出,始终留在GPU里。这个保护块数量很小,占用的显存也小,但能把整体交换频率压下来一大截。

另一种是把替换策略从“纯LRU”升级为“LRU + 冷启动保护”。所谓冷启动保护,是指一个Block从CPU换入GPU之后,给它一个最短驻留时间窗口,比如至少驻留64个decode step。在这个窗口内,即使它的访问时间戳不是最新的,也不允许再次被换出。这样可以避免刚搬回来还没来得及发挥作用,又被下一个请求挤出去。

3.3 换出和换入的代价不对称,这是一条铁律

在调度策略上,有一条最容易被忽略但极其重要的原则:换出便宜,换入昂贵。

换出是异步的,你可以把一个Block从显存搬到内存,完全不阻塞当前的计算。反正当前batch中暂时没有请求真正依赖这个Block,传输可以在后台进行,只要在它再次被访问之前搬完就算成功。

换入则不同。如果某个Block在decode步骤中被实际Query到,但此刻不在GPU显存里,那就必须同步等待它从CPU传回来。这一步传导到用户体验就是:生成第N个token用了20ms,生成第N+1个token却突然变成200ms,因为它等了一次换入。

所以调度器在设计上要遵循“换入优先、换出次之”的原则。优先保证“即将被访问的Block必须在GPU里”,再考虑腾空间。如果GPU空闲Block数量不足,那就提前换出,而不是等到decode需要Block时才紧急换入。

这个不对称性还带来一个推论:替换策略不能只看访问热度,还要看“换出代价”。一个刚被从CPU换入、未来大概率马上还要被连续访问的Block,即使LRU时间戳不占优,也应该留在GPU里。这也是为什么前面说的“最短驻留时间窗口”在实战中这么重要。

3.4 用多流预取把传输藏进计算间隙

交换机制真正能做到“无感”,靠的不是减少数据量,而是把传输时间和计算时间重叠起来。

现在的GPU通过多个CUDA Stream可以同时执行计算和显存拷贝。调度器可以在当前batch的计算任务尚未完成时,预测下一个decode step可能需要的Block,提前发起cudaMemcpyAsync把数据从CPU搬到GPU,让它在后台传输。

一个实用的预取判断逻辑并不复杂:对于正在生成中的请求,它下一步decode所需的KV Block,大概率就是当前正在使用的Block加上下一个连续Block。对于刚刚换入的Block,可以给它更高的驻留优先级。对于已经完成生成的请求,第一时间卸载整个序列的KV Cache。

实际工程里,我会把预取分成两个队列:高频预取队列和低频批量换出队列。高频队列处理当前正在decode的请求的邻居Block,优先级最高;低频队列处理缓存水位太高时的批量块换出。每个队列各用一个CUDA Stream,互不干扰。实测下来,这种双队列设计比单Stream从头串到尾要平稳得多。

4. 协同调参实战:分级缓存与水位配置

4.1 三级内存体系:GPU显存、CPU内存、NVMe的分工

当单个模型的KV Cache需求超过GPU显存并且CPU内存也吃紧时,可以考虑引入NVMe作为第三级存储。

但不要一上来就往NVMe上放。NVMe的顺序读带宽大约7GB/s,和PCIe 4.0的25GB/s实际带宽相比差了四五倍,更别说和GPU显存带宽比。它只适合存放“非常冷”的KV数据,比如已经被完整保存但短时间不可能再被访问的历史请求缓存。

推荐的分级策略是:

  • 第一级:GPU显存,存放活跃请求的KV Block、Attention Sink保护块、以及即将被访问的预取块。
  • 第二级:CPU内存(Pinned Memory),存放非活跃但可能在当前batch内被重新访问的Block。
  • 第三级:NVMe,存放已经结束生成、但未来可能要用于多轮对话历史恢复的完整KV缓存。

每一级之间都用异步拷贝粘合,尽量不让CPU参与数据搬移的路径。这个三级模型和操作系统的三级缓存体系逻辑几乎一模一样,理解了它,后面调优就顺了。

4.2 一个可落地的配置模板

下面是一个我实际使用中调得比较顺的配置模板,场景是某个40GB显存的GPU、96GB CPU内存、PCIe 4.0 x16环境,服务一个8B参数规模的模型。具体数字不一定适用所有环境,但可以作为起点。

# KV Cache 块配置 kv_block_tokens = 16 gpu_kv_capacity_blocks = 3000 # 约等于 6GB 显存空间 cpu_kv_capacity_blocks = 12000 # 约等于 24GB CPU Pinned内存 nvme_kv_capacity_blocks = 40000 # 约等于 80GB NVMe存储空间 # 水位线配置 swap_out_high_watermark = 0.85 # GPU KV占用到85%,开始换出 swap_in_low_watermark = 0.65 # GPU KV占用降到65%,停止换出 evict_protected_blocks = 8 # 每个序列头部保护块数量,不参与换出 # 批量搬运配置 swap_out_chunk_blocks = 8 # 每次换出8个Block,约16MB swap_in_chunk_blocks = 4 # 每次换入4个Block,约8MB prefetch_stream_count = 2 # 预取流数量,建议和计算流分离

这里最值得琢磨的是swap_out_chunk_blocks和swap_in_chunk_blocks的取值。

一次性搬运太大(比如一次换出512个Block,也就是1GB),单次传输耗时太长,P99延迟会直接崩掉。量化一下:1GB数据在25GB/s实际带宽下约40ms,这个时间已经超过很多场景的decode P50延迟,绝对不能接受。

但每一次搬运太小也不行。Block粒度过小,DMA启动和管理开销占比会上升,PCIe的实际吞吐会下降。我的经验是尽量让每次传输控制在8MB到32MB之间。8B模型下每个Block约2MB,所以4到16个Block是比较合理的区间。

4.3 监控哪些指标,按什么顺序调参

调参不能靠感觉,得先建立监控面板。我每次优化Offload和交换机制,最先看四个指标:

第一是PCIe读/写吞吐。这个指标直接反映交换压力。如果写吞吐长期超过理论带宽的60%,说明换出过于频繁;该考虑扩大GPU KV Cache容量,或者提高高水位线,减少换出触发频率。

第二是swap等待时间占比。统计一个batch内真正等待cudaMemcpy完成的时间占整体decode时间的比例。如果这个占比超过10%,说明预取策略还有优化空间,或者换出策略太激进。

第三是P50/P95/P99延迟分布。P50基本不变、P99突然飙升,几乎可以断定是换入阻塞;如果P50和P99一起涨,大概率是整体带宽吃满了。

第四是CPU Pinned Memory利用率。利用率长期超过90%时,说明二级缓存都快满了,需要考虑把更老的数据继续下沉到NVMe。

调参顺序上,我习惯先固定Block大小,再把高水位线从0.95往下压,观察PCIe吞吐和P99的变化。然后调整换出chunk大小,直到延迟毛刺最小。最后才动预取流数量。不要一上来就同时改四五个参数,否则出了问题根本定位不到是谁导致的。

4.4 三个踩过的坑,写出来省得你们再跳

第一个坑:把换出chunk大小设成512MB。当时想的是“反正换出可以异步执行,一次多搬点省得频繁唤起来回切换调度器”。结果是,一次512MB搬运在PCIe 4.0上要占掉近20ms的带宽。这本该是后台操作,但因为数据量大,持续占用DMA通道,把高优先级的换入请求也堵在队列里了。最终P99从180ms直接飙到400ms以上。后来把chunk压到8个Block(约16MB),P99立刻恢复平稳。

第二个坑:高水位线设成100%。这个做法的本意是“尽量延迟换出,让GPU缓存利用到极致”,但代价是代价极其惨重的同步等待。当显存彻底满、新请求并发到达时,调度器必须在decode路径上同步等待换出完成,才能为新请求腾出空间。这一步是纯阻塞的,而且一次腾出的空间可能只有几个Block,下一轮decode又可能继续等待。正确的做法是留出缓冲余量,让换出始终跑在“满”之前。

第三个坑:CPU侧的内存没有预分配,而是用普通malloc在运行期临时分配。这个看起来不起眼的细节,让KV Cache在换入换出时不断触发页表操作和内存拷贝,DMA路径出现严重抖动,延迟分布变得非常难看。后来改成启动时一次性预分配Pinned Memory池,问题直接消失。

5. 最后分享一点实际体会

我在调整这一整套机制的时候,最大的感触是“别指望某一个参数包打天下”。CPU Offload解决的是“显存不够容量怎么扩展”的问题,交换调度解决的是“扩展出来的慢速容量如何尽量不被感知”的问题,两者必须搭配使用才有意义。

如果你只在堆配置却不去看传输路径上的延迟分布,很容易陷入“显存看着够用但P99乱跳”的困境。我的做法是每到一个新环境,第一件事不是调缓存参数,而是先跑一个纯PCIe带宽基准测试,把这张卡的DMA实际吞吐测出来,再代入公式计算每次搬运的时间成本。有了这个基线,后面所有的水位线、chunk大小、预取策略才有决策依据。

另外值得留意的是,这套机制并不仅限于某个固定框架,只要你正在用的推理引擎暴露了KV Cache Block级别的控制接口,理论上都能把思路移植过去。如果某个方案只提供全局开关而不暴露Block粒度配置,那它的Offload优化空间通常也比较有限,换一个更底层可控的方案可能是更省力的选择。

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

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

立即咨询