3. vLLM核心概念:PagedAttention、Continuous Batching、KV Cache管理与吞吐量指标

3.1 为什么传统推理框架难以支撑高吞吐?

在深入vLLM之前,我们先要理解一个关键矛盾:大语言模型推理是“显存饥饿型”任务。以LLaMA-13B为例,模型权重约占26GB显存(FP16),但处理一个长度为2048的序列时,其KV Cache(键值缓存)就可能消耗超过16GB显存。传统推理框架(如FasterTransformer的静态批处理模式)在请求到达时,会为每个序列预分配固定长度的KV Cache空间,这导致两个严重问题:

  • 显存碎片化:不同请求的实际生成长度差异极大,预分配的空间往往浪费50%以上。
  • 等待阻塞:必须等当前批次中最长的序列生成完毕,才能释放显存并接收新请求,造成GPU利用率波动。

vLLM的核心创新,正是通过操作系统虚拟内存的分页思想来解决上述问题,从而将吞吐量提升数倍甚至一个数量级。

核心原理速览:vLLM将KV Cache划分为固定大小的“块”(Block),每个块存储固定数量token的键值向量。请求不再占用连续显存,而是通过块表(Block Table)映射到非连续的物理块上。这就像操作系统的虚拟内存分页——逻辑上连续,物理上离散。

传统预分配(连续显存) 请求A:预分配2048 tokens 实际使用 512 tokens(浪费75%) 请求B:预分配2048 tokens 实际使用 1024 tokens(浪费50%) ✗ 显存碎片化 + 等待阻塞 vLLM PagedAttention(分页管理) 逻辑块表 Block 0 → 物理块 7 Block 1 → 物理块 3 Block 2 → 物理块 9 Block 3 → 空闲 物理块池 块7(请求A) 块3(请求A) 块9(请求B) 块2(请求B) 块5(空闲) 显存利用率 ~40% 吞吐量受限 显存利用率 >95% 支持动态批处理 关键差异: 传统方式:每个请求独占连续显存 → 碎片化严重,无法共享。 vLLM方式:块级共享 + 按需分配 → 支持跨请求KV Cache共享(如并行采样、Prefix Caching)。

3.2 PagedAttention:让KV Cache像内存分页一样灵活

PagedAttention是vLLM的底层注意力实现,它改变了传统注意力机制中KV Cache的存储与访问方式。其核心数据结构包括:

  • KV块(KV Block):固定大小(默认16个token的键值向量),是显存分配的最小单位。
  • 块表(Block Table):每个序列维护一张映射表,记录其逻辑块到物理块的对应关系。
  • 块管理器(Block Manager):全局统筹物理块的分配、释放与复制,支持写时复制(Copy-on-Write)优化。

在计算注意力分数时,PagedAttention并非一次性读取整个序列的KV,而是按块遍历,逐块计算部分注意力分数并累加。这种设计带来了三个直接收益:

  1. 显存零浪费:最后一个块可以只分配实际需要的部分,不再预留整个序列长度。
  2. 共享前缀:当多个请求共享相同的系统提示词(如ChatML模板)时,这些前缀的KV块可以被多个序列同时引用,极大节省显存。
  3. 并行采样支持:同一个Prompt生成多个候选回复时,只需复制块表而非复制实际KV数据。

实操验证:在vLLM中,你可以通过环境变量 VLLM_LOG_LEVEL=DEBUG 启动服务,观察日志中的块分配信息。例如:

# 启动时设置日志级别
VLLM_LOG_LEVEL=DEBUG python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-3.1-8B-Instruct \
    --gpu-memory-utilization 0.9

# 日志中会显示类似信息:
# Block manager: block_size=16, num_gpu_blocks=18432, num_cpu_blocks=0
# 这说明每个块16个token,GPU上共有18432个可用块

3.3 Continuous Batching:动态调度,消灭气泡

传统静态批处理(Static Batching)的流程是:收集请求 → 组成批次 → 推理直到所有序列结束 → 释放批次。这会导致严重的GPU气泡——当批次中大部分序列已生成完毕(如生成了EOS),但仍有少数长序列在运行时,GPU计算单元大量空闲。

vLLM采用Continuous Batching(连续批处理),也称为迭代级调度(Iteration-level Scheduling)。其核心思想是:在每个解码步骤(Decode Step)结束后,立即判断哪些序列可以结束,并立刻将新到达的请求加入当前批次。调度粒度从“整个序列”细化到“单个解码步骤”。

对比维度 静态批处理 Continuous Batching
调度粒度 整个序列生命周期 单个解码迭代
GPU利用率 存在明显气泡(等待长尾序列) 持续饱和,气泡极小
新请求延迟 必须等待当前批次完全结束 下一个迭代即可加入
显存需求 按最大可能长度预分配 按实际生成进度动态分配
典型吞吐量(8×A100) 约500-800 tokens/s 约2000-4000 tokens/s

vLLM的调度器(Scheduler)在每个迭代执行以下逻辑:

1. 检查当前批次中所有序列的状态
2. 若某序列生成EOS或达到max_tokens → 标记为完成,释放其KV块
3. 从等待队列中取出新请求,检查是否有足够空闲块
4. 为新请求分配KV块,加入当前批次
5. 执行一次前向传播(Prefill + Decode 混合)
6. 重复步骤1-5直到所有请求完成

案例:混合Prefill与Decode。假设当前批次有3个序列正在Decode阶段,此时新到达一个长Prompt请求(需要Prefill)。vLLM不会让新请求等待,而是将Prefill计算与Decode计算合并到同一个迭代中执行。GPU的Tensor Core同时处理矩阵乘(Prefill)和向量操作(Decode),实现计算资源的最大化利用。

3.4 KV Cache管理:显存优化的艺术

KV Cache管理直接决定了服务能同时处理多少并发请求。vLLM通过以下机制实现精细化管理:

3.4.1 显存预留与水位控制

vLLM启动时会根据 --gpu-memory-utilization 参数(默认0.9)预留显存。预留的显存中,一部分用于模型权重,剩余部分全部用于KV Cache块池。你可以通过 --max-num-seqs 限制最大并发序列数,防止显存溢出。

3.4.2 Prefix Caching(前缀缓存)

当多个请求共享相同的前缀(如系统提示词、Few-shot示例)时,vLLM会自动检测并复用这些前缀的KV块。实测中,对于包含长系统提示词的对话场景,Prefix Caching可以减少30%-50%的Prefill计算量。

# 启用Prefix Caching(vLLM >= 0.4.0)
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-3.1-8B-Instruct \
    --enable-prefix-caching \
    --max-model-len 8192

3.4.3 块大小调优

默认块大小为16个token。增大块大小(如32)可减少块表开销,但会增加显存碎片;减小块大小(如8)则相反。对于长序列生成场景,建议保持默认;对于短对话场景,可尝试 --block-size 8 观察效果。

常见误区:不要盲目设置 --gpu-memory-utilization 0.98。过高的利用率会导致CUDA OOM(Out of Memory)风险,尤其在处理突发长请求时。建议生产环境设置为0.85-0.92,预留部分显存给CUDA上下文和临时张量。

3.5 吞吐量指标:如何正确衡量服务性能

吞吐量是衡量LLM服务性能的核心指标,但错误的测量方式会得出完全误导性的结论。vLLM官方与业界通常关注以下指标:

3.5.1 核心指标定义

  • 输出吞吐量(Output Tokens/s):每秒生成的输出token数。这是最直接反映生成能力的指标。
  • 总吞吐量(Total Tokens/s):每秒处理的输入+输出token总数,反映系统整体处理能力。
  • TTFT(Time to First Token):从发送请求到收到第一个输出token的延迟。对交互式应用至关重要。
  • ITL(Inter-Token Latency):相邻两个输出token之间的时间间隔,反映生成流畅度。

3.5.2 测量方法

vLLM内置了Benchmark脚本,推荐使用官方工具进行标准化测试:

# 安装benchmark依赖
pip install vllm[benchmark]

# 测试吞吐量(模拟真实并发场景)
python benchmarks/benchmark_serving.py \
    --backend vllm \
    --model meta-llama/Llama-3.1-8B-Instruct \
    --tokenizer meta-llama/Llama-3.1-8B-Instruct \
    --dataset sharegpt \
    --num-prompts 200 \
    --request-rate 20 \
    --max-concurrency 50 \
    --host 127.0.0.1 \
    --port 8000

# 输出示例:
# Successful requests: 200
# Output tokens: 45230
# Total tokens: 67890
# Request throughput: 4.2 req/s
# Output token throughput: 950.3 tokens/s
# Total token throughput: 1425.7 tokens/s
# Mean TTFT: 245.3 ms
# Mean ITL: 18.5 ms

3.5.3 指标解读与调优方向

现象 可能原因 调优建议
TTFT高但ITL正常 Prefill阶段计算量大,或排队请求多 启用Prefix Caching;增加实例数;限制max-num-seqs
ITL波动大 批次中序列长度差异过大 启用Continuous Batching;调整块大小
总吞吐量低 GPU利用率不足或显存受限 增大并发数;检查gpu-memory-utilization;使用更小的模型
OOM频繁 并发过高或单序列过长 降低max-num-seqs;减小max-model-len;增加块大小

3.6 案例复盘:从500到3000 tokens/s的优化之路

某团队部署Llama-3.1-8B服务,初始使用TGI(Text Generation Inference)框架,测得输出吞吐量仅520 tokens/s。迁移到vLLM并完成以下优化后,吞吐量提升至3100 tokens/s:

  1. 启用PagedAttention:显存利用率从45%提升至92%,并发数从8提升至32。
  2. 开启Continuous Batching:GPU利用率从60%提升至95%,消除了长尾请求的气泡。
  3. 配置Prefix Caching:由于所有请求共享相同的系统提示词(约800 tokens),Prefill计算量减少40%。
  4. 调整实例参数:设置 --gpu-memory-utilization 0.92 --max-num-seqs 64 --block-size 16。

复盘要点:优化过程中,团队最初尝试增大 --max-num-seqs 到128,结果出现OOM。回退到64后,通过观察 nvidia-smi 发现KV Cache利用率已达95%,说明瓶颈在显存而非计算。最终通过启用Prefix Caching,在相同显存下支持了更多并发,才安全提升到64并发。

理解这四大核心概念是掌握vLLM性能调优的基础。PagedAttention解决了显存碎片化问题,Continuous Batching消除了调度气泡,KV Cache管理决定了并发上限,而吞吐量指标则提供了客观的度量标准。在实际部署中,这四个方面相互影响,需要综合考量才能获得最优性能。