4、显存管理挑战:KV Cache带来的显存碎片化、浪费和容量限制

在深入PagedAttention的机制之前,我们必须先直面Transformer推理中最棘手的工程问题——KV Cache的显存管理。KV Cache是自回归解码过程中为加速计算而缓存的历史键(Key)和值(Value)张量。它的存在让模型无需重复计算之前的注意力结果,但也因此引入了三类相互交织的显存问题:内部碎片化、外部碎片化和容量硬限制。理解这三者,是理解PagedAttention为何诞生的前提。

4.1 核心原理:KV Cache的生命周期与显存分配

KV Cache的大小由两个关键参数决定:序列长度和批次大小。在推理过程中,每个请求的序列长度是动态增长的——每生成一个token,就需要向Cache中追加一行Key和Value。然而,传统显存分配器(如CUDA的cudaMalloc)在分配时并不知道请求最终会生成多长,因此只能按照最大可能长度(max_seq_len)一次性预留连续显存。

这种“预分配”策略直接导致了内部碎片化。假设一个请求实际只生成了50个token,但系统为其预留了2048个token的Cache空间,那么预留空间中约97.5%的显存被白白占用。更糟糕的是,这种浪费是动态累积的:当多个请求并发时,每个请求都按最大长度预留,显存很快被“看似合理”的预留耗尽,而实际有效数据可能只占用了很小比例。

关键公式:单个请求的KV Cache显存占用 = 2(K和V) × 层数 × 注意力头数 × 头维度 × 序列长度 × 每个元素字节数(通常为2字节,FP16)。

以一个7B模型为例,假设40层、32个注意力头、头维度128,那么每个token的KV Cache占用为:2 × 40 × 32 × 128 × 2 = 655,360字节 ≈ 0.64MB。如果并发处理100个请求,每个请求预留2048个token的Cache,则总预留显存为:100 × 2048 × 0.64MB ≈ 131GB。这远超单张A100(80GB)的显存容量,而实际有效数据可能只有几GB。

传统KV Cache显存分配:碎片化与浪费示意 实际使用 内部碎片(预留未用) 外部碎片(无法合并) 空闲显存 请求A(实际生成64 token,预留2048) 内部碎片:约97%显存被浪费 请求B(实际生成512 token,预留2048) 内部碎片:75%浪费 请求C(实际生成1900 token,预留2048) 外部碎片:请求结束后释放的显存无法合并 碎片间隙无法容纳新的连续分配请求

4.2 方法流程:显存分配的生命周期与瓶颈

传统推理引擎的显存管理流程可以概括为以下步骤,每一步都暗含浪费的隐患:

  1. 请求到达:系统根据请求的输入长度和预设的max_seq_len,计算需要预留的KV Cache大小。
  2. 连续分配:调用cudaMalloc分配一块物理连续的显存区域。如果当前显存中没有足够大的连续空闲块,分配失败,请求被阻塞或拒绝。
  3. 动态增长:随着解码进行,KV Cache按token逐行填充。但预留区域的大小不会改变,即使实际使用远小于预留。
  4. 请求结束:调用cudaFree释放整块预留显存。释放后,该区域成为空闲块,但可能因为大小不一而无法与其他空闲块合并。

这种“按最大长度预留、按整块释放”的模式,在并发场景下会迅速耗尽显存。更严重的是,外部碎片化会让显存总量看似充足,却无法满足新的分配请求。例如,显存中有3个不连续的空闲块,大小分别为100MB、200MB和150MB,总量450MB,但新请求需要300MB连续显存,分配器只能返回失败。

问题类型 产生原因 典型后果 量化示例(7B模型)
内部碎片化 按max_seq_len预留,实际生成较短 显存利用率低,有效数据占比小 预留2048 token,实际64 token,浪费97%
外部碎片化 不同请求生命周期不同,释放后无法合并 显存总量充足但无法分配连续块 3个空闲块共450MB,无法满足300MB请求
容量硬限制 并发请求数 × 预留长度 超过物理显存 请求排队或OOM崩溃 100并发 × 2048 token × 0.64MB ≈ 131GB

4.3 案例/场景:一个真实的OOM事故复盘

假设我们在一台8×A100(80GB)的服务器上部署一个13B模型,使用传统的vLLM早期版本或HuggingFace Transformers的静态缓存。模型权重占用约26GB(FP16),每张卡剩余约54GB用于KV Cache。

某次线上流量高峰,同时到达了120个请求,每个请求的输入长度约为100 token,系统设置的max_seq_len为4096。每个token的KV Cache大小为0.64MB(13B模型层数更多,此处按7B估算),那么每个请求预留的Cache为:4096 × 0.64MB ≈ 2.6GB。120个请求共需312GB显存,远超单卡54GB的可用空间。

实际运行中,系统在分配第21个请求的Cache时,cudaMalloc返回“out of memory”。此时,前20个请求中,有12个已经完成,释放了约31GB显存,但由于这些释放的块大小不一(有的2.6GB,有的0.5GB),且分布在不同的显存地址,新的2.6GB连续分配请求无法被满足。最终,系统只能将剩余请求排队,导致P99延迟从200ms飙升到15秒,部分请求超时失败。

关键教训:显存管理的核心矛盾不是“总量不够”,而是“碎片化导致的有效容量远小于物理容量”。即使显存总量足够,碎片化也会让系统无法利用这些空间。传统的“预分配+整块释放”策略在长序列、高并发的LLM推理场景中必然失效。

4.4 误区/复盘:常见错误认知与设计反思

在设计和优化KV Cache管理时,工程师常陷入以下误区:

  • 误区一:增大max_seq_len就能提升吞吐。实际上,更大的预留长度会加剧内部碎片化,反而降低有效并发数。正确做法是动态分配,按需增长。
  • 误区二:显存碎片化可以通过“重启进程”解决。重启确实能清空碎片,但代价是丢失所有正在处理的请求,且无法解决根本问题——分配策略的缺陷。
  • 误区三:使用内存池(Memory Pool)就能解决所有问题。传统内存池只是复用固定大小的块,但KV Cache的大小是动态变化的,固定池无法适应不同长度的序列。
  • 误区四:只关注KV Cache的总字节数,忽略对齐和分配粒度。CUDA分配通常按512字节对齐,小请求也会占用整块,加剧碎片。

复盘上述OOM事故,我们可以提炼出三个核心设计目标,这也是PagedAttention的出发点:

  1. 按需分配:Cache大小应随实际生成长度动态增长,而非预留最大值。
  2. 消除连续内存依赖:允许KV Cache存储在非连续的物理页中,通过页表映射逻辑连续。
  3. 细粒度共享:多个请求的相同前缀(如系统提示词)可以共享同一份KV Cache,减少重复存储。