4. PagedAttention核心思想:虚拟内存分页、KV Cache分块管理、按需分配与回收

4.1 从“连续大块”到“分页小块”的思维转变

在深入了解PagedAttention之前,我们需要先理解传统推理框架中KV Cache的存储方式及其致命缺陷。在GPT、BERT等经典Transformer模型的推理过程中,自回归解码需要逐步生成Token,每一步都要将当前Token的Key和Value向量追加到历史缓存中,供后续注意力计算使用。这个缓存被称为KV Cache。

传统实现(如HuggingFace Transformers、FasterTransformer早期版本)通常采用连续内存分配策略:在请求开始时,一次性为最大可能序列长度预留一整块连续显存。例如,若模型最大支持2048个Token,则每个请求都会预分配可容纳2048个Token的KV Cache空间,即使实际只生成了50个Token。

这种方式的弊端非常明显:

  • 内部碎片:预分配的空间远大于实际使用量,大量显存被闲置浪费。
  • 外部碎片:不同请求的KV Cache大小不同,频繁分配和释放导致显存出现大量不连续空洞,无法被新请求利用。
  • 无法共享:即使两个请求共享相同的前缀(如系统提示词),也必须各自保存一份完整的KV Cache副本。

PagedAttention的核心突破在于:将KV Cache划分为固定大小的块(Block),每个块存储固定数量Token的Key和Value向量,并通过页表(Page Table)将这些物理上不连续的块映射为逻辑上连续的序列。这一思想直接借鉴了操作系统中的虚拟内存分页机制。

核心类比:操作系统将进程的虚拟地址空间划分为固定大小的页,通过页表映射到物理内存中任意可用的页框。PagedAttention将请求的逻辑KV Cache序列划分为固定大小的块,通过块表映射到显存中任意可用的物理块。两者本质相同——用间接映射换取内存利用率和灵活性。
传统连续分配 vs PagedAttention分块分配 传统方式:连续预分配 预分配:最大2048 Token空间 实际使用:仅120 Token 内部碎片:闲置空间无法被其他请求使用 请求A 已分配 空洞 不可用 请求B 已分配 外部碎片:释放后产生不连续空洞 请求A和B共享相同系统提示词 但各自保存完整副本 → 显存翻倍 无法共享KV Cache PagedAttention:分块+页表映射 逻辑视图(连续) 块0 块1 块2 块3 物理视图(离散) 物理块7 物理块3 物理块12 物理块1 块表 逻辑块0 → 物理7 逻辑块1 → 物理3 逻辑块2 → 物理12 逻辑块3 → 物理1 请求A和B共享前缀时 块表中相同逻辑块 → 同一物理块 显存节省50%以上

4.2 块表(Block Table)与地址映射机制

PagedAttention中,每个请求维护一张独立的块表,记录其逻辑块到物理块的映射关系。块表本身存储在GPU显存中,由推理引擎统一管理。当注意力计算需要访问某个Token的KV向量时,计算流程如下:

  1. 根据Token在序列中的位置,计算其所属的逻辑块编号:logical_block_id = token_position / block_size。
  2. 查询块表,获取该逻辑块对应的物理块编号:physical_block_id = block_table[logical_block_id]。
  3. 在物理块内部,根据Token在块内的偏移量(token_position % block_size)定位到具体的Key和Value向量。

块大小(Block Size)是一个关键超参数,通常设置为16或32个Token。块大小越小,内存碎片越少,但块表越大,管理开销越高;块大小越大,块表越紧凑,但内部碎片可能增多。vLLM默认使用16,实际部署中需要根据模型规模和显存容量进行调优。

# 块表映射示意(伪代码)
class PagedAttention:
    def __init__(self, block_size=16):
        self.block_size = block_size
        # 每个请求的块表:list[int],存储物理块ID
        self.block_tables = {}  # request_id -> [physical_block_ids]
    
    def get_kv_vector(self, request_id, token_position):
        logical_block = token_position // self.block_size
        offset_in_block = token_position % self.block_size
        physical_block = self.block_tables[request_id][logical_block]
        # 从物理块中读取Key/Value向量
        key = self.physical_blocks[physical_block].keys[offset_in_block]
        value = self.physical_blocks[physical_block].values[offset_in_block]
        return key, value

4.3 按需分配与显存回收策略

PagedAttention的另一个核心优势是按需分配。与传统方式在请求开始时预分配全部空间不同,vLLM仅在请求实际产生新Token时才为其分配新的物理块。具体流程如下:

阶段 操作 显存变化
请求到达 仅分配块表,不分配物理块 极小开销
Prefill阶段 处理输入提示词,按需分配物理块存储KV 分配与提示词长度匹配的块数
Decode阶段(每步) 生成1个新Token,若当前块已满则分配新块 每步最多增加1个块
请求完成 释放该请求所有物理块,归还到全局空闲块池 显存回收,供其他请求使用

vLLM维护一个全局物理块池,所有请求共享。分配器采用类似内存分配器的策略:

  • 首次适配(First Fit):从空闲块链表中找到第一个可用块。
  • 块复用:当多个请求共享相同前缀时,它们的块表可以指向相同的物理块,并增加引用计数。
  • 写时复制(Copy-on-Write):当共享块的请求需要写入不同内容时(例如在共享前缀后生成不同分支),才复制该块。
写时复制机制详解:假设请求A和B共享前100个Token的KV Cache,此时它们的块表都指向物理块0~6。当A生成第101个Token时,如果第101个Token落在物理块7中,而B也需要写入物理块7(因为B也要生成自己的第101个Token),此时vLLM会为B分配一个新的物理块,复制原块内容,然后B的块表指向新块。这样A和B的后续生成互不影响,而共享部分仍然只占一份显存。

4.4 实际案例:多请求并发下的显存效率对比

我们通过一个具体案例来量化PagedAttention的优势。假设模型配置如下:

  • 隐藏层维度:4096
  • 层数:32层
  • 注意力头数:32头,每头维度128
  • KV Cache每个Token所需显存:2(Key+Value)× 32层 × 4096维 × 2字节(FP16)= 0.5 MB/Token
  • 块大小:16 Token/块,即每块8 MB

现在有3个并发请求:

  • 请求A:输入提示词200 Token,预计生成150 Token,总长度350 Token
  • 请求B:输入提示词200 Token(与A共享前缀),预计生成100 Token,总长度300 Token
  • 请求C:输入提示词50 Token,预计生成500 Token,总长度550 Token

传统连续分配方式:假设最大序列长度设为2048,则每个请求预分配2048 Token的空间,共需3 × 2048 × 0.5 MB = 3072 MB。实际使用量为(350+300+550) × 0.5 = 600 MB,利用率仅19.5%。

PagedAttention方式:

  • 请求A:350 Token → 22块(350/16向上取整)→ 176 MB
  • 请求B:300 Token,但前200 Token与A共享 → 共享12.5块,实际新增约7块 → 约56 MB新增
  • 请求C:550 Token → 35块 → 280 MB
  • 总计:约512 MB,显存利用率提升至85%以上(考虑块对齐开销)
结论:在本案例中,PagedAttention将显存占用从3072 MB降至约512 MB,节省约83%。更重要的是,节省下来的显存可以用于增大批处理大小(Batch Size),从而显著提升吞吐量。这正是vLLM能够实现比传统框架高2~4倍吞吐量的核心原因之一。

4.5 常见误区与调优复盘

误区一:块大小越大越好。虽然大块可以减少块表条目数量,但会增加内部碎片。例如,若块大小为64 Token,而请求只生成了65个Token,则需要分配2个块,第二个块中63/64的空间被浪费。实践中建议从16开始,根据平均请求长度和显存容量进行实验调优。
误区二:忽略块表本身的显存开销。块表存储在GPU上,每个条目通常需要4~8字节。当并发请求数极大(数千个)且序列很长时,块表本身可能占用数百MB显存。vLLM通过将块表存储在固定显存区域并复用内存池来缓解这一问题,但在极端场景下仍需关注。

调优复盘清单:

  1. 监控块命中率:如果共享前缀的请求很多,检查写时复制是否频繁触发。频繁复制会降低效率,此时可考虑增大块大小以减少复制次数。
  2. 调整最大批处理大小:PagedAttention释放的显存应转化为更大的Batch Size。vLLM的--max-num-seqs参数控制并发序列数,建议根据实际显存余量逐步调大。
  3. 使用Prefix Caching:vLLM支持自动前缀缓存(--enable-prefix-caching),系统自动检测并复用相同前缀的KV Cache块,进一步减少重复计算。
  4. 监控物理块利用率:通过vLLM的监控接口查看num_free_gpu_blocks和num_used_gpu_blocks,若空闲块长期为0,说明显存已满,需要降低并发或使用更小的模型。

PagedAttention的成功证明了操作系统经典思想在大模型推理领域的巨大价值。通过虚拟内存分页、块表映射、按需分配和写时复制,vLLM将KV Cache的显存利用率从不足20%提升至80%以上,为高吞吐推理奠定了坚实基础。理解这一机制,是后续掌握vLLM调度策略、连续批处理和投机解码等高级特性的前提。