vllm 深度解析:一切从 PagedAttention 谈起
一、背景与问题
在大语言模型(LLM)的推理服务中,KV Cache 是性能的关键瓶颈之一。传统推理框架(如 HuggingFace Transformers)为每个请求预分配一块连续的显存空间来存储 KV Cache,其大小按最大序列长度分配。这种方式带来三个核心问题:
- 显存浪费严重:实际序列长度通常远小于最大长度,大量预分配空间被闲置。据统计,实际利用率仅 60%~80%。
- 显存碎片化:请求的生成长度不可预知,连续分配导致大量外部碎片,无法被新请求复用。
- 并发受限:由于上述浪费和碎片,系统能同时服务的请求数(batch size)被严重限制,直接影响吞吐量。
vLLM 是 UC Berkeley 团队开源的高性能 LLM 推理引擎(2023 年 SOSP 论文),其核心创新 PagedAttention 正是为解决上述问题而生——借鉴操作系统虚拟内存的分页思想,将 KV Cache 从连续内存改为按块(block)离散存储,通过 Block Table 实现逻辑地址到物理地址的映射。
本文将以 vLLM 源码为线索,从四个层次深入解析 PagedAttention 的完整实现:
- 层次 1:KV 缓存管理(块分配/释放)
- 层次 2:Block Table(逻辑到物理的映射)
- 层次 3:CUDA 内核(核心计算)
- 层次 4:Python 接口层
二、PagedAttention 实现全景
PagedAttention 是 vLLM 的核心创新(来自 2023 年 SOSP 论文),本质是将 KV 缓存从连续内存改为分页(块)存储,类比操作系统的虚拟内存分页。代码实现涉及 4 个层次:
层次 1:KV 缓存管理(块分配/释放)
工作机制:
Request → Scheduler → KVCacheManager.allocate(request)
→ SingleTypeKVCacheManager.get_num_blocks_to_allocate()
→ BlockPool.allocate() → 返回 KVCacheBlock 列表
→ req_to_blocks[req_id] = [blocks]
→ Scheduler 拿到 KVCacheBlocks(含 block_id 列表)
→ Worker 将 block_id 写入 BlockTable
KVCacheBlock — 物理 block 元数据
文件: vllm/v1/core/kv_cache_utils.py:116
|
|
设计要点:元数据与数据分离——KVCacheBlock 不存储实际 KV 张量数据,仅通过 block_id 计算显存偏移地址。
ref_cnt 详解 — 通用引用计数,非 prefix caching 专属
ref_cnt 是一个通用的引用计数器,追踪"有多少个请求正在使用这个 block",不是仅为 prefix caching 预留的。无论是否开启 prefix caching,ref_cnt 都会正常工作:
- 常规分配(
block_pool.py:354):get_new_blocks()从空闲队列取出 block 时,ref_cnt += 1,表示 1 个请求持有该 block。请求结束时free_blocks()执行ref_cnt -= 1,归零后 block 回到空闲队列。未开启 prefix caching 时 ref_cnt 始终为 0 或 1。 - Prefix caching 共享(
block_pool.py:402-415):touch()是专为 prefix caching 设计的方法。当新请求的 prefix hash 命中已有 block 时,ref_cnt += 1并从空闲队列移出,此时 ref_cnt >= 2,表示多个请求共享同一物理 block。共享 block 不会被驱逐,直到所有引用者都释放(ref_cnt 回到 0)。
示例: block_size=16, 两个请求共享前缀 "Hello, how are"
时间线:
T1: Request A 生成 "Hello, how are" 的 KV cache, 写满 block P3
→ P3._block_hash = hash("Hello, how are")
→ P3.ref_cnt = 1
T2: Request B 以 "Hello, how are" 开头, prefix hash 命中 P3
→ touch(P3): P3.ref_cnt = 2 ← 两个请求共享同一物理 block
T3: Request A 结束, free_blocks()
→ P3.ref_cnt = 1 ← B 仍在用, P3 不会回到空闲队列
T4: Request B 结束, free_blocks()
→ P3.ref_cnt = 0 ← 无人引用, 回到 FreeKVCacheBlockQueue
设计要点
- 元数据与数据分离:不存 KV 张量,只持有一个 block_id,通过 block_id * block_size + offset 计算显存地址。数据全在 GPU,元数据全在 CPU,各司其职。
- 空闲块管理:
FreeKVCacheBlockQueue(kv_cache_utils.py:164)通过prev_free_block/next_free_block将空闲 block 组织为双向链表,支持 O(1) 在链表中间删除(任意 block 回收),性能接近 C++std::deque。 - LRU 驱逐顺序:空闲链表按 LRU 排序——least recent used 的 block 在前。当同一序列的多个 block 同时释放时,尾部 block(含更多 hash token)排在前面,优先被驱逐。
- Prefix Caching 生命周期:
- block 写满 → 计算 hash → 写入
_block_hash→ 加入全局哈希索引 - 新请求到达 → 通过哈希索引匹配 → 命中则复用物理 block(
ref_cnt++) - block 被驱逐 →
reset_hash()清空_block_hash→ 从哈希索引移除
- block 写满 → 计算 hash → 写入
is_null标志:用于标记占位 block(如 padding),这些 block 永远不会参与 prefix caching,防止无效缓存污染。
层次 2:Block Table(逻辑到物理的映射)
核心文件:vllm/v1/worker/gpu/block_table.py
BlockTables 管理一组 [max_num_reqs, max_num_blocks] 的 int32 tensor,
本质是一个二维查找表,将每个请求的逻辑块序号映射为物理 block ID。
逻辑视图(每个请求的视角)
每个请求看到的是从 token 0 开始连续编号的线性序列,按 block_size 切分为逻辑块。
以 block_size = 16 为例,Request 1(25 tokens)、Request 2(12 tokens):
Request 1 (25 tokens, 2 个逻辑块):
逻辑块 0 (token 0~15) 逻辑块 1 (token 16~24)
┌────────────────┬────────────────┐
│████████████████│█████████░░░░░░░│
└────────────────┴────────────────┘
16/16 满 9/16 部分
Request 2 (12 tokens, 1 个逻辑块):
逻辑块 0 (token 0~11)
┌────────────────┐
│████████████░░░░│
└────────────────┘
12/16 部分
物理视图(GPU 显存布局)
物理 block 按页式分配,空闲 block 被 FreeKVCacheBlockQueue 以双向链表管理。
分配时从链表头部取出,同一请求的物理 block 不一定连续:
GPU KV Cache 物理地址空间 (block_size=16, 每个物理 block 可存 16 个 token 的 KV):
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ P0 │ P1 │ P2 │ P3 │ P4 │ P5 │ P6 │ P7 │
│ FREE │ Req2 │ FREE │ Req1 │ FREE │ FREE │ FREE │ Req1 │
│ │ T0~T11 │ │ T0~T15 │ │ │ │T16~T24 │
│ [ ] │ [██▓] │ [ ] │ [███] │ [ ] │ [ ] │ [ ] │ [██▓] │
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
↑ ↑ ↑
被 Req2 占用 被 Req1 占用 被 Req1 占用
(逻辑块0) (逻辑块0, 满) (逻辑块1, 部分满)
FreeKVCacheBlockQueue 双向链表:
HEAD ⇄ P0 ⇄ P2 ⇄ P4 ⇄ P5 ⇄ P6 ⇄ TAIL
↑ 这些物理 block 空闲,等待分配
Block Table 映射表
Block Table 是连接逻辑视图与物理视图的核心数据结构——
一个 [max_num_reqs, max_num_blocks] 的 int32 tensor,存储在 GPU 上:
BlockTable (行 = 请求, 列 = 逻辑块序号, 值 = 物理 block id):
逻辑块0 逻辑块1 逻辑块2 ... 逻辑块N-1
┌────────┬────────┬────────┬──┬────────┐
Req1 │ P3 │ P7 │ -1 │..│ -1 │ num_blocks=2
├────────┼────────┼────────┼──┼────────┤
Req2 │ P1 │ -1 │ -1 │..│ -1 │ num_blocks=1
├────────┼────────┼────────┼──┼────────┤
Req3 │ -1 │ -1 │ -1 │..│ -1 │ (空闲槽位,未分配)
├────────┴────────┴────────┴──┴────────┤
... │
└───────────────────────────────────────┘
-1 = 无效/未分配, num_blocks = 该请求已分配的逻辑块数量
Token → 物理地址 完整映射链
Kernel 拿到 position 后,通过两步查表获得物理地址(_compute_slot_mappings_kernel):
输入: req_idx=Req1, position=18
│
▼
┌──────────────────────────────────────────────────┐
│ Step 1: position → 逻辑块索引 + 块内偏移 │
│ │
│ block_index = 18 // 16 = 1 (第 1 号逻辑块) │
│ block_offset = 18 % 16 = 2 (块内第 2 个位置) │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Step 2: 查 BlockTable 获取物理 block │
│ │
│ physical_block = BlockTable[Req1][1] = P7 │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Step 3: 计算最终 slot_id,访问 GPU 显存 │
│ │
│ slot_id = P7 * 16 + 2 │
│ = 物理 block 基址 + 块内偏移 │
│ │
│ 访问: k_cache[slot_id, kv_head, ...] │
└──────────────────────────────────────────────────┘
映射公式
|
|
两请求对比
| Request 1 (25 tokens) | Request 2 (12 tokens) | |
|---|---|---|
| 逻辑块数 | ⌈25/16⌉ = 2 |
⌈12/16⌉ = 1 |
| 占用的物理 block | P3, P7 | P1 |
| BlockTable 行 | [P3, P7, -1, ...] |
[P1, -1, ...] |
| num_blocks | 2 | 1 |
| 最后一个 block 利用率 | 9/16 = 56% | 12/16 = 75% |
本质:Block Table 让每个请求都以为自己在使用"从 0 开始、连续排布"的私有 KV 缓存,而底层物理内存是离散分页的——完全类比操作系统的虚拟内存页表。
BlockTables(GPU 版)— GPU 端 Block Table
文件: vllm/v1/worker/gpu/block_table.py:12
|
|
核心方法:
append_block_ids()— 追加新的 block ID 到某请求行gather_block_tables()— 按调度顺序从源表收集到input_block_tablescompute_slot_mappings()— 计算每个 token 的slot_id = physical_block * block_size + offset
其他数据结构
FreeKVCacheBlockQueue — 空闲 block 双向链表
文件: vllm/v1/core/kv_cache_utils.py:164
|
|
核心方法:popleft() / popleft_n(n) 从头部取出、append() / append_n() 从尾部放回、remove() O(1) 删除中间节点。排序策略为 LRU——least recent used 在队头,优先被分配/驱逐。
BlockPool — block 池管理器
文件: vllm/v1/core/block_pool.py:130
|
|
BlockHashToBlockMap — prefix caching 哈希索引
文件: vllm/v1/core/block_pool.py:34
|
|
BlockTable(Worker 版)— Worker 端 Block Table
文件: vllm/v1/worker/block_table.py:18
|
|
核心方法:append_row() / add_row() / clear_row() / move_row() / swap_row() — 行级 CRUD;commit_block_table() — CPU→GPU 拷贝。
MultiGroupBlockTable — 多组 KV cache 的 Block Table 聚合
文件: vllm/v1/worker/block_table.py:223
|
|
为混合注意力模型(如 full attention + sliding window)设计,每组独立管理自己的 block table。
KVCacheBlocks — Scheduler 与 KVCacheManager 之间的接口
文件: vllm/v1/core/kv_cache_manager.py:26
|
|
核心方法:get_block_ids() → tuple[list[int], ...],提取所有 block ID,用于写入 Block Table。
BlockHashWithGroupId — 哈希键
文件: vllm/v1/core/kv_cache_utils.py:47
本质是 NewType("BlockHashWithGroupId", bytes) = BlockHash(32字节) + group_id(4字节大端 uint32) 的拼接。
|
|
数据结构关系总览
Scheduler
|
KVCacheManager.allocate_slots()
|
+---------------+---------------+
| |
SingleTypeKVCacheManager SingleTypeKVCacheManager
(FullAttention) (SlidingWindow)
| |
+---------------+---------------+
|
BlockPool
+-------+-------+
| |
FreeKVCacheBlockQueue BlockHashToBlockMap
(doubly linked, LRU) (hash index, prefix cache)
|
KVCacheBlock[]
(physical block metadata)
| allocation result
KVCacheBlocks
(Scheduler interface object)
|
| get_block_ids()
BlockTables / BlockTable
[max_num_reqs, max_num_blocks] <- GPU int32 tensor
|
| compute_slot_mappings()
slot_mappings[token] = physical_block * block_size + offset
|
v
CUDA Kernel reads k_cache[slot_id] / v_cache[slot_id]
层次 3:CUDA 内核(核心计算)
核心文件:
csrc/libtorch_stable/attention/attention_kernels.cuh— 核心设备函数paged_attention_kernelcsrc/libtorch_stable/attention/paged_attention_v1.cu— V1 启动器csrc/libtorch_stable/attention/paged_attention_v2.cu— V2 启动器 + Reduce 内核
内核设计要点
| 维度 | 说明 |
|---|---|
| Grid | (num_heads, num_seqs, max_num_partitions) |
| 每个 Block | 处理 1 个 head × 1 个 seq × 1 个 partition |
| Thread Group | WARP_SIZE / BLOCK_SIZE 个线程协作处理 1 个 KV token |
| Warp | 每次迭代处理 1 个 block(在多个迭代中覆盖多 block) |
| Vec Size | 确保每 Thread Group 每次取 16 字节(内存合并) |
Q 加载(共享内存)
q_vecs[THREAD_GROUP_SIZE][NUM_VECS_PER_THREAD] // 共享内存
每个 thread group 读取同一 query token 的不同部分
线程 0 读取 vec [0,4,8,...],线程 1 读取 vec [1,5,9,...]
K 加载(寄存器)
k_vecs[NUM_VECS_PER_THREAD] // 寄存器
通过 block_table 间接寻址获取物理 block
k_cache[physical_block_number, kv_head, physical_block_offset, vec_offset]
每个 warp 的 thread group 处理 block 中的不同 token
QK 计算 + Softmax
1. QK dot product(thread group 内规约) + scale + ALiBi bias
2. logits[] 存储到共享内存
3. qk_max = warp-reduce(fmax) → block-reduce(fmax) → broadcast
4. exp_sum = sum(exp(logits[i] - qk_max)) → block_reduce
5. logits = exp(logits - qk_max) / exp_sum (softmax)
V 计算 + 输出
1. 按 block 迭代,从 v_cache[physical_block, kv_head, head_size, block_size] 读取
2. v_vec 与 logits_vec 做 dot product → accs[NUM_ROWS_PER_THREAD]
3. Warp 内规约 accs → Warp 间规约 accs
4. 最终输出到 out[seq_idx, head_idx, head_size]
V1 vs V2
| 特性 | V1 | V2 |
|---|---|---|
| Grid Z 维度 | 1(无分区) | max_num_partitions |
| PARTITION_SIZE | 0 | 512(默认) |
| 适用场景 | 短序列 | 长序列 |
| 工作机制 | 单 kernel 完成全部计算 | 多个 partition 并行 + Reduce 合并 |
V2 的分区合并策略:
1. V2 Kernel: 每个 partition 计算部分 KV blocks 的 attention
输出: tmp_out[partition], exp_sums[partition], max_logits[partition]
2. Reduce Kernel: 用 LSE (Log-Sum-Exp) 算法合并各 partition:
global_max = max(max_logits[:])
weight[i] = exp_sums[i] * exp(max_logits[i] - global_max)
out = sum(tmp_out[i] * weight[i]) / sum(weight)
ROCm 变体
csrc/rocm/attention.cu 使用 AMD MFMA 指令实现了相同算法。
层次 4:Python 接口层
vllm/v1/attention/ops/paged_attn.py:
|
|
vllm/_custom_ops.py:
|
|
这三个函数是 PyTorch 自定义 C++ 拓展 (torch.ops._C) 的 Python 包装器。
三、注意力后端集成
PagedAttention 被多个注意力后端使用:
| 后端 | 文件 | 使用方式 |
|---|---|---|
| ROCm | vllm/v1/attention/backends/rocm_attn.py |
直接用 PagedAttention.split_kv_cache/write_to_paged_cache |
| Triton Unified | vllm/v1/attention/ops/chunked_prefill_paged_decode.py |
自己实现了 Triton 版分页注意力 kernel_paged_attention_2d |
| FlashInfer | vllm/v1/attention/backends/flashinfer.py |
使用 FlashInfer 库自带的 BatchDecodeWithPagedKVCacheWrapper |
| FlashAttention | vllm/v1/attention/backends/flash_attn.py |
使用 flash_attn_with_kvcache 的变长分页模式 |
四、KV 缓存的完整生命周期
1. 模型 forward
→ Attention 层计算新 K/V tensor(当前 token)
→ PagedAttention.write_to_paged_cache(key, value, key_cache, value_cache, slot_mapping)
→ reshape_and_cache 将 [num_tokens, heads, dim] → [blocks, block_size, heads, dim]
2. 调度器调度
→ KVCacheManager.get_num_blocks_to_allocate(request)
→ BlockPool.allocate(num_blocks)
→ 返回 KVCacheBlocks(blocks=[KVCacheBlock(block_id=3), ...])
3. Worker 准备输入
→ BlockTables.append_block_ids(req_index, new_block_ids)
→ 构建 input_block_tables: [num_reqs, max_blocks] 的 int32 tensor
→ 传递给 CUDA kernel
4. CUDA kernel 执行 PagedAttention
→ 通过 block_table[seq_idx] 查物理 block_id
→ k_cache[physical_block, kv_head, ...] 读取 K
→ v_cache[physical_block, kv_head, ...] 读取 V
→ 计算 attention 输出
5. 请求完成
→ KVCacheManager.free(request)
→ BlockPool.free(block_ids)
→ 物理块回收复用
五、关键设计决策
-
K/V 内存布局不同:K 使用交错布局
[num_blocks, num_kv_heads, head_size/x, block_size, x]以支持 16 字节合并读取;V 使用[num_blocks, num_kv_heads, head_size, block_size]布局,因读取模式不同。 -
分区支持:V2 内核针对长序列将 KV 序列切分为
PARTITION_SIZE=512的分区并行计算,最后用 LSE 归约合并,避免了单个线程块的共享内存瓶颈。 -
块稀疏注意力:内核编译时支持
IS_BLOCK_SPARSE,在循环中跳过不参与的 block,结合blocksparse_vert_stride和blocksparse_local_blocks参数实现。 -
FP8 KV 缓存:支持 4 种精度(Auto/Fp8E4M3/Fp8E5M2),读取时通过
fp8::scaled_convert在线反量化,减少缓存内存占用。 -
前缀缓存:通过
BlockHash计算块哈希值,相同前缀的请求共享物理块,enable_caching=True时在SingleTypeKVCacheManager中实现。
六、参考文件索引
Python 层
vllm/v1/attention/ops/paged_attn.py— PagedAttention 类定义vllm/_custom_ops.py— paged_attention_v1/v2/rocm Python 包装器vllm/_aiter_ops.py— ROCm AIter 库绑定vllm/v1/attention/ops/chunked_prefill_paged_decode.py— Triton 分页注意力内核vllm/v1/worker/gpu/block_table.py— GPU Block Table 管理vllm/v1/core/kv_cache_manager.py— KV 缓存管理器vllm/v1/core/single_type_kv_cache_manager.py— 单类型 KV 缓存管理vllm/v1/core/kv_cache_utils.py— KVCacheBlock 数据结构
CUDA 内核
csrc/libtorch_stable/attention/attention_kernels.cuh— paged_attention_kernel 核心设备函数csrc/libtorch_stable/attention/paged_attention_v1.cu— V1 内核启动器csrc/libtorch_stable/attention/paged_attention_v2.cu— V2 内核启动器 + Reduce 内核csrc/libtorch_stable/attention/attention_utils.cuh— QK 点积辅助函数csrc/rocm/attention.cu— ROCm 分页注意力实现csrc/attention/attention_generic.cuh— 通用注意力 CUDA 头文件csrc/attention/attention_dtypes.h— FP16/FP32/BF16/FP8 dtype 定义
注意力后端
vllm/v1/attention/backends/rocm_attn.py— ROCm 注意力后端(直接使用 PagedAttention)vllm/v1/attention/backends/rocm_aiter_fa.py— ROCm AIter 后端vllm/v1/attention/backends/flash_attn.py— FlashAttention 后端vllm/v1/attention/backends/flashinfer.py— FlashInfer 后端vllm/v1/attention/backends/triton_attn.py— Triton 注意力后端vllm/v1/attention/backend.py— 注意力后端抽象基类vllm/v1/attention/selector.py— 后端选择逻辑
测试和文档
tests/kernels/attention/test_attention.py— PagedAttention 测试benchmarks/kernels/benchmark_paged_attention.py— 性能基准测试docs/design/paged_attention.md— 原始设计文档
- 原文作者:Kevin
- 原文链接:http://www.subond.com/post/2026-06-03_vllm_paged_attention/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。