vLLM Deep Dive Part 2: PagedAttention - The Core Innovation
第二部分:PagedAttention——核心创新
简介
PagedAttention 是 vLLM 的突破性创新,彻底改变了大语言模型的服务方式。在 PagedAttention 出现之前,高效服务 LLM 长期受到内存碎片化和浪费问题的困扰。本文将深入探讨 PagedAttention 的工作原理、重要意义,以及它在 vLLM 中的实现方式。
LLM 服务中的内存问题
理解 KV Cache
在 transformer 模型中,attention 层为每个 token 计算键(K)和值(V)向量。在生成过程中:
- Prefill 阶段:为所有提示词 token 计算 K、V
- Decode 阶段:对每个新 token,计算其 K、V,并对所有之前的 K、V 进行 attention 计算
挑战:我们必须存储所有之前的 K、V 张量(即”KV cache”),以避免重复计算。
内存大小:以 Llama-3-8B 为例:
- 32 个 attention 层
- 4096 隐藏维度
- FP16 精度(2 字节)
- 每个 token:32 层 × 4096 维度 × 2(K、V)× 2 字节 = 512 KB
对于 2048 个 token 的序列:仅 KV cache 就需要 1 GB!
碎片化问题
传统服务系统为每个请求的最大长度预先分配连续内存:
1 | Request 1 (max 2048 tokens, actual 512): |
问题:
- 过度分配:必须按最坏情况的长度进行分配
- 碎片化:无法将一个请求的未使用空间分配给另一个请求
- 低吞吐量:内存成为瓶颈,而非计算
实际影响:没有 PagedAttention,你可能只能服务 10 个并发请求;而有了 PagedAttention,同样的内存可以服务 50 个以上的请求!
PagedAttention 的解决方案
PagedAttention 将虚拟内存的理念应用于 attention 计算:
核心思想:不再将 KV cache 连续存储,而是将其拆分为固定大小的 block。每个请求获得一个 block table,将逻辑位置映射到 physical block。
虚拟内存类比
| 虚拟内存(操作系统) | PagedAttention(vLLM) |
|---|---|
| 页面(Page) | block(如 16 个 token) |
| 页表(Page Table) | block table |
| 物理内存 | GPU 内存 |
| 内存分配器 | block pool |
| 缺页错误(Page Fault) | cache miss |
| 写时复制(Copy-on-Write) | prefix 共享 |
基于 block 的存储
取代连续分配的方式:
1 | Request 1 (512 tokens, 32 blocks): |
优势:
- 无过度分配:按需分配 block
- 无碎片化:任何空闲 block 都可分配给任意请求
- 近零浪费:每个请求只有最后一个 block 可能是部分填充的
- 共享:共同前缀可共享 physical block
实现深度解析
block 结构
文件位置:vllm/v1/core/kv_cache_utils.py
当前源码中的 KVCacheBlock 是一个 @dataclass(slots=True),核心字段包括:
1 |
|
其中 block_hash 通过 property 暴露,实际携带的是“block hash + KV cache group id”的组合键,而不是一个简单的 int。
物理存储:
v1 不再假设单一固定的 torch.empty(..., float16) layout。实际实现会先根据 KVCacheConfig 分配原始缓存,再由不同 attention backend(如 FlashInfer、Triton、FlashAttention)重塑为各自需要的 KV layout。常见 shape 仍然围绕 num_blocks / 2(K,V) / block_size / num_kv_heads / head_size 展开,但具体维度顺序与 dtype 取决于 backend。
Block Pool
文件位置:vllm/v1/core/block_pool.py
BlockPool 维护所有 KV cache block、空闲队列与 prefix cache 索引。构造函数的核心形态如下:
1 | class BlockPool: |
这有两个容易忽略的细节:
block_id=0不是普通数据块,而是null_block占位块。cached_block_hash_to_block不是简单的dict[int, block],而是支持 hash 冲突的BlockHashToBlockMap。
分配与释放:
BlockPool 的核心接口是 get_new_blocks()、touch() 与 free_blocks():
1 | new_blocks = block_pool.get_new_blocks(num_blocks) |
get_new_blocks()会从 free queue 批量取出 block,并在需要时调用_maybe_evict_cached_block()清理旧的缓存元数据。touch()用于 prefix cache 命中路径:若命中的 block 仍在 free queue 中,会先移出再增加ref_cnt。free_blocks()按列表批量释放 block;调用方通常按逆序释放,以便让尾部 block 更容易被优先回收。
block table 与 KVCacheBlocks
当前 v1 更接近“按 KV cache group 组织 block 列表”的实现,而不是 [[7], [23], ...] 这种每个逻辑 block 再包一层列表的结构。
- 请求侧使用
KVCacheBlocks.blocks: tuple[Sequence[KVCacheBlock], ...] - 调用
get_block_ids()后,会得到按 KV cache group 分组的 block id 列表 - kernel 侧最终消费的是按 group 构造的
block_table tensor
对单一 attention group 来说,可以把它理解为一个平坦的 physical block id 序列;有多个 group(如 attention + Mamba)时,外层再按 group 分组。
KVCacheManager
文件位置:vllm/v1/core/kv_cache_manager.py
KVCacheManager 的职责不只是“给 request 分配新 block”,而是把 prefix cache 命中、完整序列准入、sliding window 裁剪、speculative lookahead,以及 encoder-decoder 的 cross-attention 都纳入同一套 slot 管理逻辑。
当前更重要的接口是:
1 | computed_blocks, num_computed_tokens = kv_cache_manager.get_computed_blocks(request) |
这套接口有几个关键点:
get_computed_blocks()与allocate_slots()是分开的两个阶段,scheduler 会先查 prefix cache,再决定是否准入。allocate_slots()返回KVCacheBlocks | None,而不是(blocks, num_cached_tokens)。allocate_slots()还会在真正分配前调用remove_skipped_blocks(),把 sliding window 或 sparse attention 下已经不需要的 block 替换成null_block。
Prefix Caching:跨请求共享
Prefix caching 仍然是 PagedAttention 最有价值的能力之一,但当前实现比“对 token tuple 做一次 Python hash”要复杂得多。
block 哈希
源码里的 block hash 是链式计算的:当前 block 的 hash 会把父 block 的 hash、当前 block token、以及 multimodal / LoRA / cache salt / prompt embed 等额外键一起纳入计算。返回值类型是 BlockHash(bytes 包装),不是简单的 int。
可以把它理解为:
1 | block_hash = H(parent_block_hash, current_block_tokens, extra_keys) |
这让 vLLM 可以:
- 只对完整 block建立 prefix cache 索引
- 区分同一 token 序列在不同 KV cache group 中的缓存项
- 在多模态、LoRA 或 prompt embedding 参与时避免误共享
cache 查找
当前 KVCacheManager.get_computed_blocks() 会调用 coordinator / manager 路径来寻找“最长可命中的前缀”,而不是直接遍历 request.block_hashes 并手动递增 ref_cnt。
1 | computed_blocks, num_new_computed_tokens = ( |
几个细节很重要:
- prefix cache 只会命中完整 block
- 即使“整个 prompt 都命中”,也通常要重新计算最后一个 token 以得到 logits,因此
max_cache_hit_length会被限制为request.num_tokens - 1 - 命中后的引用计数增加发生在
block_pool.touch(...)路径,而不是在查找函数里直接修改
reference counting
reference counting 的基本含义没有变化:多个 request 可以共享同一个 prefix block,只有 ref_cnt == 0 时它才会回到 free queue,成为可复用或可淘汰对象。
cache 淘汰
当前实现不是“扫描 free queue 找第一个 ref_cnt == 0 的 cached block 并返回”的 evict_cached_block() 接口。更接近真实源码的过程是:
get_new_blocks()先从 free queue 取出候选 block- 若该 block 仍带有
block_hash,则调用_maybe_evict_cached_block(block)把它从cached_block_hash_to_block中摘除 - 若需要显式按 block id 淘汰 prefix cache,则使用
evict_blocks(block_ids)
也就是说,淘汰是围绕“取出的 block 是否还挂着 cache 元数据”展开的。
使用 block 进行 attention 计算
Prefill 阶段
当前 v1 并没有统一公开的 paged_attention_prefill(...) Python API。prefill 由具体 attention backend 负责执行,例如 FlashInfer 路径会使用 BatchPrefillWithPagedKVCacheWrapper / BatchPrefillWithRaggedKVCacheWrapper,其他 backend 则会构造自己的 metadata 和 kernel 调用。
对外可以把它理解为两步:
- scheduler / attn metadata 根据
seq_lens和 block tables 构建 batch 视图 - backend kernel 直接按 paged KV cache layout 完成 prefill attention
Decode 阶段
decode 阶段通常由具体 attention backend 负责。以 FlashInfer 路径为例,decode 侧会通过 BatchDecodeWithPagedKVCacheWrapper(或 TRT-LLM decode path)直接消费 paged KV cache。
因此,当前实现更准确的描述是:
- prefill / decode 都是 backend-specific
- block table 会在 metadata 构建阶段转成 tensor 形式
- kernel 直接根据 paged KV layout 取 K/V,而不是先在 Python 层把 block 拼接成连续张量
多组 KV cache
vLLM 支持不同层使用不同 cache 规格,相关配置类型是 KVCacheGroupSpec / KVCacheConfig。
概念上可以理解为:
KVCacheConfig持有num_blocks、kv_cache_tensors和kv_cache_groups- 每个
KVCacheGroupSpec绑定一组 layer 名称与对应的kv_cache_spec kv_cache_spec可以是FullAttentionSpec、SlidingWindowSpec、MambaSpec等不同类型
因此,一个 request 的 block 记录也会天然按 group 分层,例如:
1 | blocks = ( |
这也是为什么 BlockHash 需要和 group id 绑定:同一段 token 在不同 KV cache group 中对应的是不同的物理缓存项。
性能影响
内存效率
使用 PagedAttention 之前(连续分配):
1 | 10 requests × 2048 max length × 512 KB/token = 10.5 GB |
使用 PagedAttention 之后(block 分配):
1 | 500 blocks allocated across all requests |
结果:并发请求数提升 4 倍!
吞吐量提升
真实基准测试(Llama-3-8B,运行于 H100):
| 指标 | 未使用 PagedAttention | 使用 PagedAttention | 提升幅度 |
|---|---|---|---|
| 并发请求数 | 12 | 64 | 5.3x |
| 吞吐量(tok/s) | 1,500 | 8,000 | 5.3x |
| 内存占用 | 60 GB | 60 GB | 持平 |
| 延迟(TTFT) | 45ms | 42ms | -7% |
Prefix Caching 收益
使用共同系统提示词(500 个 token)时:
| 请求次数 | 未使用缓存 | 使用缓存 | 加速比 |
|---|---|---|---|
| 第 1 次 | 10ms(prefill) | 10ms | 1x |
| 第 2 次 | 10ms | 1ms | 10x |
| 第 100 次 | 10ms | 1ms | 10x |
缓存命中率:对于带有系统提示词的聊天机器人,通常在 60-80% 之间。
进阶主题
滑动窗口 attention
对 Mistral 这类 sliding-window 模型,当前实现不会简单地“切片旧 block 然后逐个 free”。更接近真实源码的流程是:
- 先根据
total_computed_tokens计算哪些 block 已经落到窗口之外 - 调用
remove_skipped_blocks()把这些位置替换为null_block - 再把不再需要的真实 block 批量归还给 block pool
这样既保留了逻辑 block table 的形状,又避免了窗口外 token 继续占用可复用的缓存块。
speculative decoding
在当前 v1 中,speculative decoding 主要通过 num_lookahead_tokens、slot allocation 和 scheduler 的 scheduled_spec_decode_tokens 协同完成。
更准确地说:
- scheduler 会为 lookahead token 预留 slot
allocate_slots()只会把最终确认的 token 提交到 prefix cache- 被拒绝的 draft token 会在 slot mapping / 输出更新路径中被屏蔽,而不是靠手工
ref_cnt += 1/ 回滚 block 列表来管理
实现中的注意事项
hash 碰撞
当前实现通过 BlockHashToBlockMap 处理哈希碰撞。对于同一个 hash,值可能是单个 KVCacheBlock,也可能退化成 {block_id: KVCacheBlock} 这样的结构,而不是简单的 hash -> list[block]。
不完整的 block
最后一个 block 仍然可能是不完整的,但当前实现不会在 KVCacheBlock 元数据里单独维护 num_tokens_in_last_block 这样的字段。vLLM 只对完整 block建立 prefix cache,并通过 seq_lens 等 batch metadata 在 attention 计算时屏蔽无效位置。
线程安全
线程安全方面,block_pool.py 并没有对外暴露 allocate/_allocate_unsafe 这类接口;调度路径更接近单一 owner 管理 block pool 状态,而不是通过显式锁 API 暴露给外部使用。
核心要点
PagedAttention 将 KV cache 拆分为固定大小的 block,消除了碎片化和过度分配问题
block table 将逻辑 block 映射到 physical block,类似于虚拟内存的页表
prefix caching 跨请求共享 block,大幅减少了冗余计算
reference counting 确保安全共享,防止过早释放内存
近零内存浪费在实践中实现了 4-5 倍的吞吐量提升
attention 内核经过优化,可直接使用 block 索引存储进行计算
下一步
在第三部分,我们将探索 Scheduler——vLLM 的核心调度器,它负责决定处理哪些请求、何时进行抢占,以及如何通过 continuous batching 最大化 GPU 利用率。
参考资料
PagedAttention 是 vLLM 卓越性能的基础。在下一篇文章中,我们将看到 Scheduler 如何在此之上协调请求的执行。