vLLM 原理与源码阅读
PagedAttention 显存管理、Continuous Batching 动态批处理、Prefix Caching 前缀缓存
vLLM 概述
为什么需要 vLLM
大语言模型(LLM)推理面临一个核心瓶颈:KV-Cache 显存管理。在自回归解码过程中,模型需要为每个已生成的 token 缓存 Key 和 Value 张量,用于后续 attention 计算。随着序列长度增长和并发请求增加,KV-Cache 消耗的显存迅速膨胀,成为推理吞吐的主要限制因素。
传统 LLM 推理框架(如 Hugging Face Transformers)存在两个突出问题:
- 显存浪费严重:KV-Cache 预分配固定大小的连续显存块,但实际序列长度往往远小于预分配上限,导致大量显存闲置。
- 碎片化问题:不同请求的序列长度不一,显存分配和释放产生大量内部碎片与外部碎片,进一步降低有效利用率。
实际生产中,传统方案的显存利用率通常只有 20%-40%,意味着 60%-80% 的 GPU 显存被浪费。
vLLM 的核心创新
vLLM 是由加州大学伯克利分校开发的高性能 LLM 推理引擎。其核心创新包括:
| 创新点 | 解决的问题 | 效果 |
|---|---|---|
| PagedAttention | KV-Cache 显存碎片化与浪费 | 显存利用率 95%+ |
| Continuous Batching | 批次间等待导致的吞吐损失 | 吞吐量 2-4 倍提升 |
| Prefix Caching | 重复 prompt 前缀的重复计算 | 首 token 延迟降低 |
以下逐一深入分析每个核心机制。
PagedAttention 显存管理
传统 KV-Cache 的问题
在标准 Transformer 推理中,每个请求的 KV-Cache 需要预先分配一块连续的显存空间,大小为 max_seq_len * num_layers * 2 * hidden_dim * dtype_size。例如,对于 13B 模型,单个请求的 KV-Cache 就可能占用数 GB 显存。
传统实现采用 预分配固定大小 的策略:
请求 A: [--- 已使用 ---|-------- 预分配但未使用 --------] 60% 浪费
请求 B: [--- 已使用 ----------|-------- 预分配 --------] 40% 浪费
请求 C: [--- 已使用 -|-------- 预分配 ----------------] 70% 浪费这种策略导致两种碎片:
- 内部碎片:预分配空间大于实际使用,多余部分无法被其他请求利用。
- 外部碎片:请求完成后释放的显存块大小不一,无法高效合并再分配给新请求。
PagedAttention 核心思想
PagedAttention 借鉴了操作系统虚拟内存的分页机制。它将连续的逻辑 KV-Cache 切分为固定大小的块(Block),每个 Block 包含若干个 token 的 KV 数据。通过维护逻辑块到物理块的映射表,物理块可以不连续存储,按需分配。
逻辑视角(连续):
[Block 0: tokens 0-15] [Block 1: tokens 16-31] [Block 2: tokens 32-47]
物理视角(离散):
物理块地址: [7] [3] [15] [22] [9]
↑ ↑ ↑ ↑ ↑
逻辑到物理映射: Block 0 → [7, 3]
Block 1 → [15, 22]
Block 2 → [9]Block 管理器
vLLM 的 Block 管理器是 PagedAttention 的核心组件,主要职责包括:
- 逻辑块到物理块的映射:为每个请求维护一个块表(Block Table),记录逻辑块索引对应的物理块地址。
- 按需分配:仅当解码推进、需要存储新 token 的 KV 数据时,才分配新的物理块。块大小默认 16 个 token,是一种时间-空间权衡。
- Copy-on-Write(写时复制):当多个请求共享相同的 prompt 前缀时(如 system prompt),它们共享同一组物理块。只有在某个请求需要修改共享块时,才执行复制操作,极大节省显存。
显存利用率提升效果
| 方案 | 典型显存利用率 | 最大并发请求数(16G 显存, 13B 模型) |
|---|---|---|
| Hugging Face Transformers | 20%-40% | 4-6 |
| vLLM PagedAttention | 95%+ | 20-30 |
实验数据表明,PagedAttention 可以将 KV-Cache 显存利用率从 20%-40% 提升至 95% 以上,同等硬件条件下支持 4-5 倍的并发请求数。
Continuous Batching 动态批处理
传统 Static Batching 的问题
传统的静态批处理(Static Batching)将多个请求打包为一个固定批次,共同经历 Prefill(预填充)和 Decode(解码)两个阶段。直到整个批次中所有请求都完成解码后,才释放批次资源并处理下一批请求。
Static Batching:
批次 1: [请求 A 完成] [请求 B 仍在解码] [请求 C 仍在解码]
↓ 必须等待 B 和 C 都完成 ↓
释放批次 → 加载批次 2这种方式的低效显而易见:
- 短请求被长请求拖累,GPU 计算资源空闲等待。
- 批次内请求完成时间差异越大,浪费越严重。
Continuous Batching:迭代级调度
Continuous Batching(也称为 Iteration-level Scheduling 或 Incremental Batching)是 vLLM 的核心调度策略。其思想是:在每个解码步骤(iteration)结束时,动态调整批次组成。
Continuous Batching:
Step 1: [A, B, C] → A 完成,加入新请求 D
Step 2: [B, C, D] → B 完成,加入新请求 E
Step 3: [C, D, E] → ...vLLM 的 Scheduler 在每个解码迭代中执行以下策略:
- 请求完成时立即移除:完成的请求不再参与后续解码,释放的显存和计算资源立即被利用。
- 新请求即时加入:等待队列中的新请求在下一个解码步骤即可加入批次,无需等待整个批次结束。
- 优先级调度:支持基于请求优先级、等待时间等因素的调度策略。
吞吐提升效果
| 场景 | Static Batching | Continuous Batching | 提升倍数 |
|---|---|---|---|
| 短文本生成(128 tokens) | 500 req/s | 1500 req/s | 3.0x |
| 混合长度(64-512 tokens) | 200 req/s | 800 req/s | 4.0x |
| 长文本生成(1024 tokens) | 100 req/s | 250 req/s | 2.5x |
Continuous Batching 在混合负载场景下效果最显著,因为短请求不再被长请求阻塞。
Prefix Caching 前缀缓存
重复 Prompt 前缀的缓存命中
在实际应用中,大量请求具有相同的 prompt 前缀。例如:
- System Prompt:所有对话请求共享相同的系统指令。
- Few-shot 示例:多次推理使用相同的示例集合。
- RAG 上下文:同一文档片段的多次查询。
传统方案中,每个请求都从头计算完整的 KV-Cache,即使前缀完全相同,这些重复计算也照常进行。
自动检测与复用
vLLM 的 Prefix Caching 机制自动检测并缓存 prompt 前缀的 KV-Cache:
- 缓存键生成:对 prompt 内容做 hash,生成缓存键。
- 前缀匹配:新请求到达时,在缓存中查找最长匹配的前缀。
- 增量计算:仅计算未命中部分(后缀)的 KV-Cache。
- 共享显存:利用 PagedAttention 的 Block 共享机制,缓存命中的物理块被多个请求直接引用。
# 启用 Prefix Caching
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-2-7b-chat-hf",
enable_prefix_caching=True, # 启用前缀缓存
max_num_batched_tokens=4096,
)
# 首次调用:完整计算并缓存
outputs = llm.generate(system_prompt + user_query_1)
# 后续调用(相同 system_prompt):命中缓存,仅计算后缀
outputs = llm.generate(system_prompt + user_query_2)适用场景与效果
| 场景 | 缓存命中率 | 首 token 延迟降低 |
|---|---|---|
| 固定 System Prompt | 80%-95% | 50%-70% |
| Few-shot 示例复用 | 60%-80% | 40%-60% |
| 多轮对话历史 | 40%-60% | 30%-50% |
代码示例:使用 vLLM 进行推理
基本使用
from vllm import LLM, SamplingParams
# 初始化模型
llm = LLM(model="Qwen/Qwen2.5-7B-Instruct", tensor_parallel_size=1)
# 配置采样参数
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=512,
stop=["<|im_end|>"],
)
# 构造请求
prompts = [
"请用中文解释什么是量子计算。",
"写一首关于秋天的五言绝句。",
"Python 中 list 和 tuple 的区别是什么?",
]
# 批量推理
outputs = llm.generate(prompts, sampling_params)
# 输出结果
for output in outputs:
prompt = output.prompt
generated_text = output.outputs[0].text
print(f"Prompt: {prompt}")
print(f"Generated: {generated_text}")
print("-" * 50)性能对比:vLLM vs Hugging Face
import time
from vllm import LLM as VLLM
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
# vLLM 推理
vllm_model = VLLM("Qwen/Qwen2.5-7B-Instruct")
start = time.time()
vllm_outputs = vllm_model.generate(prompts * 10, sampling_params)
vllm_time = time.time() - start
# Hugging Face 推理
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
hf_model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype=torch.float16,
device_map="auto",
)
start = time.time()
for prompt in prompts * 10:
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
outputs = hf_model.generate(**inputs, max_new_tokens=512)
hf_time = time.time() - start
# 性能对比
print(f"{'引擎':<20} {'总耗时 (s)':<15} {'吞吐 (req/s)':<15}")
print("-" * 50)
print(f"{'Hugging Face':<20} {hf_time:<15.2f} {len(prompts * 10) / hf_time:<15.2f}")
print(f"{'vLLM':<20} {vllm_time:<15.2f} {len(prompts * 10) / vllm_time:<15.2f}")
print(f"\n加速比: {hf_time / vllm_time:.2f}x")在实际测试中,vLLM 相比 Hugging Face Transformers 通常能达到 5-15 倍的吞吐提升,且显存占用显著降低。
总结
vLLM 通过对 LLM 推理全链路的系统级优化,解决了传统推理框架在显存管理和批处理策略上的瓶颈:
- PagedAttention 打破了 KV-Cache 必须连续存储的限制,将显存利用率从 20-40% 提升到 95% 以上。
- Continuous Batching 实现了迭代级别的动态调度,确保 GPU 计算资源始终饱和运转。
- Prefix Caching 消除了重复 prompt 前缀的冗余计算,进一步降低延迟。
这三个核心机制协同工作,使 vLLM 成为当前生产环境中部署 LLM 服务的主流选择。深入理解其原理与源码实现,对于从事 LLM 推理优化和系统开发的工程师而言,具有重要的参考价值。