核心概念
本章深入讲解 vLLM 的核心技术原理,帮助你理解为什么 vLLM 能够实现如此高的推理效率。理解这些概念对于优化 vLLM 部署和排查问题至关重要。
PagedAttention:显存管理的革命
PagedAttention 是 vLLM 最核心的创新技术,它彻底改变了大模型推理中的显存管理方式。在深入理解 PagedAttention 之前,我们需要先了解传统方法的问题。
本文档基于 vLLM 的原始论文进行讲解。当前 vLLM 已使用自定义的注意力内核实现,但 PagedAttention 的核心设计思想——将 KV Cache 分块管理——仍然贯穿其中。vLLM 现在集成了 FlashAttention 和 FlashInfer 等优化的 CUDA 内核以获得更好的性能。
KV Cache:推理的显存瓶颈
Transformer 模型在生成文本时,需要缓存之前所有 token 的键(Key)和值(Value)向量,这些缓存合称为 KV Cache。这是 Transformer 自注意力机制的固有特性。
为什么需要 KV Cache?
在生成第 N 个 token 时,模型需要计算该 token 与之前所有 token 的注意力权重。如果没有缓存,每次生成都需要重新计算之前所有 token 的 Key 和 Value,这在计算上是极其浪费的。通过缓存,我们只需计算当前 token 的 Query、Key、Value,然后复用缓存的 Key 和 Value。
KV Cache 的显存开销:
对于 Llama-2-70B 模型:
- 每个 token 的 KV Cache 大小 ≈ 2 × 层数 × 头数 × 头维度 × 精度
- 以 FP16 精度计算,每个 token 约需 280KB
- 对于 4096 token 的上下文,单个请求需要约 1.1GB 显存
这个数字看起来不大,但当并发请求增加时,显存消耗会急剧增长。
传统方法的痛点
预分配策略的问题:
传统推理框架为每个序列预分配最大长度的连续显存空间:
序列 A(实际长度 100,最大长度 4096)
├─ 已使用: [100 tokens]
├─ 预留空间: [3996 tokens]
└─ 显存浪费: 97.5%
序列 B(实际长度 200,最大长度 4096)
├─ 已使用: [200 tokens]
├─ 预留空间: [3896 tokens]
└─ 显存浪费: 95%
研究表明,传统系统的 KV Cache 显存浪费率高达 60%-80%,而 vLLM 将浪费降低到 4% 以下。
碎片化问题:
预分配策略还会导致严重的内存碎片化:
- 内部碎片:已分配但未使用的显存
- 外部碎片:分散的小块空闲显存无法被利用
当序列长度变化时,频繁的分配和释放会加剧碎片化,最终导致虽然有足够的总空闲显存,但无法找到足够大的连续块来分配新请求。
无法共享:
在束搜索(Beam Search)、并行采样等场景中,多个序列往往共享相同的 prompt 前缀。传统方法无法让这些序列共享 KV Cache,导致重复存储相同的计算结果。
PagedAttention 的设计思想
PagedAttention 借鉴了操作系统虚拟内存的分页机制,将显存管理的灵活性提升到新的层次。
核心概念:块(Block)
┌─────────────────────────────────────────────────────────────┐
│ 物理显存(Block Pool) │
├─────────┬─────────┬─────────┬─────────┬─────────┬───────────┤
│ Block 0 │ Block 1 │ Block 2 │ Block 3 │ Block 4 │ ... │
│ 16 tokens│ 16 tokens│ 16 tokens│ 16 tokens│ 16 tokens│ │
└─────────┴─────────┴─────────┴─────────┴─────────┴───────────┘
↑
固定大小块(默认 16 tokens)
块是 KV Cache 存储的基本单位。每个块存储固定数量的 token 的 Key 和 Value 向量。默认块大小为 16 个 token,这个数值在性能和灵活性之间取得了良好的平衡。
逻辑视图与物理视图分离
序列 A 的逻辑视图(连续) 物理显存(非连续)
┌─────────────────────┐ ┌─────────────────────┐
│ Token 0-15 │─────────→│ Block 5 │
│ Token 16-31 │─────────→│ Block 2 │
│ Token 32-47 │─────────→│ Block 8 │
│ Token 48-63 │─────────→│ Block 1 │
└─────────────────────┘ └─────────────────────┘
块表(Block Table):
[5, 2, 8, 1] - 逻辑块索引 → 物理块索引的映射
这种设计将序列的逻辑连续性与物理存储的非连续性解耦:
- 逻辑视图:从用户角度,序列的 token 仍然是连续的
- 物理视图:实际的 KV Cache 存储在非连续的物理块中
- 块表:维护逻辑块到物理块的映射关系
按需分配
与预分配最大长度不同,PagedAttention 只在实际需要时分配块:
# 序列增长过程
初始状态: [] → 分配 Block 5
生成 16 tokens 后: [Block 5] → 分配 Block 2
生成 32 tokens 后: [Block 5, Block 2]
生成 48 tokens 后: [Block 5, Block 2, Block 8]
...
这消除了内部碎片,因为每个块都被完全利用后才分配下一个块。
内存共享
PagedAttention 支持多个序列共享相同的物理块,这对于以下场景特别有用:
束搜索(Beam Search):
┌─ Block 0 (共享) ──→ 候选 1
│
Prompt ──────┼─ Block 0 (共享) ──→ 候选 2
│
└─ Block 0 (共享) ──→ 候选 3
多个候选共享 prompt 的 KV Cache,无需重复存储
并行采样:
┌─ 分支 1 的专属块 ──→ 输出 1
│
Prompt ──────┼─ 分支 2 的专属块 ──→ 输出 2
│
└─ 分支 3 的专属块 ──→ 输出 3
共享 prompt 部分,各自维护生成部分
PagedAttention 的优势总结
| 特性 | 传统方法 | PagedAttention |
|---|---|---|
| 显存利用率 | 20%-40% | >96% |
| 内部碎片 | 严重 | 几乎消除 |
| 外部碎片 | 严重 | 消除 |
| 内存共享 | 不支持 | 完全支持 |
| 序列长度灵活性 | 需预知最大长度 | 完全动态 |
连续批处理(Continuous Batching)
传统批处理采用静态批次,需要等待批次内所有请求完成后才能处理下一批。vLLM 实现了连续批处理机制,也称为迭代级调度(Iteration-level Scheduling)。
静态批处理的问题
时间线(静态批处理):
批次 1 开始: [请求 A (需生成 100 tokens), 请求 B (需生成 10 tokens), 请求 C (需生成 50 tokens)]
└──────────────────────────────────────────────────────────────────────────────┘
等待最长的请求 A 完成
批次 2 开始: [请求 D, 请求 E, 请求 F]
└──────────────────────────────────────────┘
问题:
- 请求 B 在第 10 步就完成了,但必须等待请求 A
- GPU 在等待期间空闲
- 新请求无法加入正在处理的批次
静态批处理的核心问题是:批次的进度取决于最慢的请求。
连续批处理的工作原理
时间线(连续批处理):
T1: [请求 A (剩余 100), 请求 B (剩余 10), 请求 C (剩余 50)] 批次开始
T2: [请求 A (剩余 99), 请求 B (剩余 9), 请求 C (剩余 49)]
T3: [请求 A (剩余 98), 请求 B (剩余 8), 请求 C (剩余 48)]
...
T11: [请求 A (剩余 90), 请求 B 完成!, 请求 C (剩余 40)] 请求 B 完成,立即加入请求 D
T12: [请求 A (剩余 89), 请求 D (剩余 20), 请求 C (剩余 39)] 动态调整批次
...
T61: [请求 A 完成, 请求 D (剩余 10), 请求 C 完成] 请求 C 完成,加入请求 E
...
连续批处理的关键特性:
- 请求完成即退出:当请求完成生成后,立即从批次中移除,释放资源
- 新请求动态加入:不需要等待整个批次完成,新请求随时可以加入
- GPU 利用率最大化:始终保持 GPU 满载运行
在 vLLM 中配置连续批处理
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
max_num_seqs=256, # 最大并发序列数
max_num_batched_tokens=4096 # 单批次最大 token 数
)
参数说明:
max_num_seqs:同时处理的最大序列数量,影响并发能力max_num_batched_tokens:单次迭代处理的最大 token 数,影响 GPU 内存使用
推测解码(Speculative Decoding)
推测解码是一种通过小型草稿模型预测未来 token,再由主模型验证的加速技术。
工作原理
传统解码(每次生成 1 个 token):
主模型: [计算] → token 1 → [计算] → token 2 → [计算] → token 3
└────────┘ └────────┘ └────────┘
需要 3 次主模型前向传播
推测解码:
草稿模型: [快速计算] → token 1, token 2, token 3, token 4, token 5
(小模型,速度快)
主模型: [并行验证所有 5 个 token]
↓
验证结果: 接受 token 1, 2, 3,拒绝 token 4, 5
↓
实际生成: token 1, 2, 3,然后从 token 4 继续
只需要 1 次主模型前向传播,生成 3 个 token
使用推测解码
vllm serve meta-llama/Llama-2-7b-chat-hf \
--speculative-model meta-llama/Llama-2-1b-chat-hf \
--num-speculative-tokens 5
from vllm import LLM
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
speculative_model="meta-llama/Llama-2-1b-chat-hf",
num_speculative_tokens=5
)
推测解码的适用场景
推测解码在以下场景中效果最佳:
- 生成重复性高的文本:如代码补全、文档续写
- 草稿模型与主模型风格相近:接受率更高
- 对延迟敏感的应用:减少首 token 时间
性能考虑
推测解码的加速效果取决于草稿模型的接受率。如果接受率很低,可能反而降低性能,因为需要回滚已生成的 token。
前缀缓存(Prefix Caching)
前缀缓存用于缓存历史对话或公共 prompt 的 KV Cache,避免重复计算。
应用场景
多轮对话:
对话历史:
User: 你好
AI: 你好!有什么可以帮助你的吗?
User: 请介绍一下 Python
AI: Python 是一种广泛使用的编程语言...
User: 它有什么优点? ← 新请求
传统方法:重新计算整个对话历史的 KV Cache(约 100+ tokens)
前缀缓存:复用之前缓存的 KV Cache,只计算新 query
共享系统提示:
多个用户请求共享相同的系统提示:
System Prompt (2000 tokens): "你是一个专业的技术顾问..."
User A Query: "如何学习 Python?"
User B Query: "什么是微服务?"
User C Query: "如何优化数据库?"
传统方法:每个请求都需要计算 2000+ tokens 的 KV Cache
前缀缓存:只计算一次系统提示,所有用户共享
启用前缀缓存
from vllm import LLM
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_prefix_caching=True
)
vllm serve meta-llama/Llama-2-7b-chat-hf \
--enable-prefix-caching
前缀缓存的自动匹配
vLLM 会自动检测输入中的重复前缀。当新请求的开头部分与已缓存的序列匹配时,会复用相应的 KV Cache 块。
缓存中: "请用中文回答以下问题:什么是人工智能?..."
新请求: "请用中文回答以下问题:什么是机器学习?..."
↑
自动匹配并复用这部分缓存
Chunked Prefill
Chunked Prefill 将长序列的预填充阶段分块处理,减少延迟峰值。
传统 Prefill 的问题
在 Transformer 推理中,prefill 阶段需要一次性处理整个输入 prompt。对于长 prompt,这会导致:
- 延迟峰值:处理 4096 token 的 prompt 可能需要几百毫秒
- 阻塞其他请求:长时间占用 GPU,影响其他请求的响应时间
- 显存压力:需要同时存储所有 token 的中间状态
传统 Prefill(一次性处理):
长 prompt(4096 tokens):
[一次性计算所有 token 的 KV Cache]
└────────────────────────────────────────────────────────────┘
单次长时间计算
用户等待时间长
其他请求被阻塞
Chunked Prefill 方案
Chunked Prefill(分块处理):
长 prompt(4096 tokens):
[Block 1: 1024 tokens] → 部分结果
↓
[Block 2: 1024 tokens] → 部分结果(可交错处理解码请求)
↓
[Block 3: 1024 tokens] → 部分结果
↓
[Block 4: 1024 tokens] → 完整 KV Cache
可以在每个 chunk 之间处理解码请求,降低延迟感知
启用 Chunked Prefill
from vllm import LLM
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_chunked_prefill=True,
max_num_batched_tokens=2048
)
vllm serve meta-llama/Llama-2-7b-chat-hf \
--enable-chunked-prefill \
--max-num-batched-tokens 2048
Chunked Prefill 的优势
| 特性 | 传统 Prefill | Chunked Prefill |
|---|---|---|
| 首响应延迟 | 高(等待全部处理) | 低(快速开始) |
| 资源利用 | 峰值高 | 平滑 |
| 并发处理 | 阻塞其他请求 | 可交错处理 |
| 显存压力 | 峰值高 | 更稳定 |
Chunked Prefill 与 max_num_batched_tokens
使用 Chunked Prefill 时,max_num_batched_tokens 参数决定了每个 chunk 的大小:
- 较小的值(如 512):更频繁的调度切换,延迟更低但吞吐量可能下降
- 较大的值(如 2048):每个 chunk 处理更多 token,吞吐量更高但延迟略增
- 默认值通常能平衡延迟和吞吐量
from vllm import LLM
# 低延迟配置
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_chunked_prefill=True,
max_num_batched_tokens=512 # 更小的 chunk,更低的延迟
)
# 高吞吐量配置
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_chunked_prefill=True,
max_num_batched_tokens=4096 # 更大的 chunk,更高的吞吐量
)
块大小(Block Size)的选择
块大小是 PagedAttention 的关键参数,影响显存利用率和计算效率。
默认值与权衡
vLLM 默认块大小为 16 个 token。这个数值是经过权衡的选择:
较小的块大小:
- 优点:更细粒度的内存管理,减少浪费
- 缺点:更多的块表条目,更多的内存访问开销
较大的块大小:
- 优点:更少的块表条目,更高效的内存访问
- 缺点:更粗粒度的分配,可能增加浪费
配置块大小
from vllm import LLM
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
block_size=32 # 自定义块大小
)
大多数情况下,使用默认值即可获得最佳性能。只有在特定场景下(如非常长的序列或大量短序列)才需要调整。
内存层次结构
vLLM 采用分层的 KV Cache 管理策略:
┌─────────────────────────────────────────────────────────────┐
│ GPU 显存(最快) │
│ 存储:活跃请求的 KV Cache │
│ 大小:受限于 GPU 显存容量 │
└─────────────────────────────────────────────────────────────┘
↑↓ 溢出/换入
┌─────────────────────────────────────────────────────────────┐
│ CPU 内存(较快) │
│ 存储:溢出的 KV Cache 块 │
│ 大小:受限于系统内存 │
└─────────────────────────────────────────────────────────────┘
↑↓ 持久化
┌─────────────────────────────────────────────────────────────┐
│ 磁盘/分布式存储 │
│ 存储:需要长期保存的缓存 │
│ 用于:跨会话缓存、分布式缓存 │
└─────────────────────────────────────────────────────────────┘
这种分层策略使得 vLLM 能够处理超出 GPU 显存容量的大批次请求,通过 KV Cache 卸载(Offloading)将不活跃的缓存移至 CPU 内存。
特性兼容性矩阵
vLLM 的不同特性之间存在一定的兼容性约束,某些特性可能无法同时使用。了解这些约束有助于选择合适的配置组合。
特性之间的兼容性
下表展示了主要特性之间的兼容性关系:
| 特性 | Chunked Prefill | 前缀缓存 | LoRA | 推测解码 | CUDA Graph | 多模态 |
|---|---|---|---|---|---|---|
| Chunked Prefill | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 前缀缓存 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| LoRA | ✅ | ✅ | ✅ | ❌ | ✅ | 🟠 |
| 推测解码 | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ |
| CUDA Graph | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 多模态 | ✅ | ✅ | 🟠 | ❔ | ✅ | ✅ |
图例说明:
- ✅ 完全兼容
- 🟠 部分兼容(有限制)
- ❌ 不兼容
- ❔ 未知或待定
重要约束说明:
- LoRA 与推测解码不兼容:当使用推测解码时,无法同时使用 LoRA 适配器
- 多模态 LoRA 限制:LoRA 只能应用于多模态模型的语言骨干部分
- 池化模式限制:Chunked Prefill 和前缀缓存仅适用于最后 token 池化
硬件兼容性
不同特性在不同硬件架构上的支持情况:
| 特性 | Volta | Turing | Ampere | Ada | Hopper | CPU | AMD | Intel GPU |
|---|---|---|---|---|---|---|---|---|
| Chunked Prefill | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 前缀缓存 | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| LoRA | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 推测解码 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | 🟠 |
| CUDA Graph | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
| 多模态 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | 🟠 |
GPU 架构说明:
- Volta:V100,计算能力 7.0(较旧,部分新特性不支持)
- Turing:T4、RTX 2080 等,计算能力 7.5
- Ampere:A100、RTX 3090 等,计算能力 8.0/8.6
- Ada:L40、RTX 4090 等,计算能力 8.9
- Hopper:H100、H200 等,计算能力 9.0(最新,所有特性都支持)
选择建议
根据硬件条件选择特性配置:
Hopper GPU(H100/H200):可以使用所有特性,获得最佳性能
Ada GPU(RTX 4090/L40):可以使用绝大部分特性,包括 FP8 量化
Ampere GPU(A100/RTX 3090):不支持 FP8 量化,但可以使用 AWQ/GPTQ
Turing GPU(T4):不支持 Chunked Prefill 和前缀缓存,适合使用流水线并行
CPU 模式:不支持推测解码和 CUDA Graph,适合测试和开发
小结
vLLM 通过以下核心技术实现了高效的大模型推理:
- PagedAttention:将 KV Cache 分块管理,消除显存碎片,支持内存共享
- 连续批处理:动态调整批次,最大化 GPU 利用率
- 推测解码:利用小模型加速生成,降低延迟
- 前缀缓存:复用历史计算结果,避免重复计算
- Chunked Prefill:分块处理长 prompt,平滑延迟峰值
理解这些核心概念,有助于你在实际部署中做出正确的配置选择,并在遇到性能问题时能够快速定位原因。