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)将这些物理上不连续的块映射为逻辑上连续的序列。这一思想直接借鉴了操作系统中的虚拟内存分页机制。
4.2 块表(Block Table)与地址映射机制
PagedAttention中,每个请求维护一张独立的块表,记录其逻辑块到物理块的映射关系。块表本身存储在GPU显存中,由推理引擎统一管理。当注意力计算需要访问某个Token的KV向量时,计算流程如下:
- 根据Token在序列中的位置,计算其所属的逻辑块编号:
logical_block_id = token_position / block_size。 - 查询块表,获取该逻辑块对应的物理块编号:
physical_block_id = block_table[logical_block_id]。 - 在物理块内部,根据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):当共享块的请求需要写入不同内容时(例如在共享前缀后生成不同分支),才复制该块。
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%以上(考虑块对齐开销)
4.5 常见误区与调优复盘
调优复盘清单:
- 监控块命中率:如果共享前缀的请求很多,检查写时复制是否频繁触发。频繁复制会降低效率,此时可考虑增大块大小以减少复制次数。
- 调整最大批处理大小:PagedAttention释放的显存应转化为更大的Batch Size。vLLM的
--max-num-seqs参数控制并发序列数,建议根据实际显存余量逐步调大。 - 使用Prefix Caching:vLLM支持自动前缀缓存(
--enable-prefix-caching),系统自动检测并复用相同前缀的KV Cache块,进一步减少重复计算。 - 监控物理块利用率:通过vLLM的监控接口查看
num_free_gpu_blocks和num_used_gpu_blocks,若空闲块长期为0,说明显存已满,需要降低并发或使用更小的模型。
PagedAttention的成功证明了操作系统经典思想在大模型推理领域的巨大价值。通过虚拟内存分页、块表映射、按需分配和写时复制,vLLM将KV Cache的显存利用率从不足20%提升至80%以上,为高吞吐推理奠定了坚实基础。理解这一机制,是后续掌握vLLM调度策略、连续批处理和投机解码等高级特性的前提。