4. vLLM核心机制:PagedAttention、Continuous Batching、动态批处理

4.1 为什么传统推理框架会“卡”在显存上

在深入 vLLM 之前,先看一个传统推理框架(如静态批处理的 PyTorch 原生服务)在并发请求下的显存分配方式。假设 KV Cache 按请求最大长度预留,每个请求预留 2048 token 的缓存空间,而实际平均只用到 512 token,那么 75% 的显存被白白占用。更糟的是,显存碎片化严重:当新请求到达时,即使总空闲显存足够,也可能因为找不到连续的大块空闲区域而触发显存不足(OOM)。

传统框架的批处理策略同样低效:要么等当前批次全部生成完毕再接收新请求(导致 GPU 空闲等待),要么频繁重启批次(导致重复计算)。这种“静态批处理”模式在请求到达率不均匀时,GPU 利用率波动极大。

核心矛盾:KV Cache 的动态增长特性与显存静态预分配之间的矛盾,以及请求到达的随机性与批处理固定节奏之间的矛盾。

4.2 PagedAttention:操作系统的“虚拟内存”思想

vLLM 的核心创新在于将操作系统中的分页机制引入 KV Cache 管理。传统方案为每个请求分配连续的显存块,而 PagedAttention 将 KV Cache 划分为固定大小的“页”(通常每页 16 或 32 个 token 的 KV 数据),通过页表实现逻辑连续、物理离散的映射。

逻辑视图(连续) 请求 A 的 KV Cache(逻辑连续) 页 0 页 1 页 2 页 3 页表(逻辑页 → 物理页) 页 0 → 物理页 7 页 1 → 物理页 2 页 2 → 物理页 5 映射 物理显存(离散页) 物理 0 物理 1 物理 2 ← 页1 物理 3 物理 4 物理 5 ← 页2 物理 6 物理 7 ← 页0 物理 8 物理 9 物理 10 物理 11 物理 12 物理 13 物理 14 物理 15 物理 16 物理 17 空闲 空闲 空闲 空闲 空闲 空闲 新请求 B 分配 2 个空闲物理页 传统方式的问题 • 需连续显存,碎片化严重 • 预分配导致浪费 • 无法共享 KV Cache • 大请求可能直接 OOM 显存利用率提升: 最高可达 4~5 倍 (官方数据,实际取决于请求长度分布)

PagedAttention 的注意力计算不再假设 KV 在内存中连续,而是通过页表逐页查找。虽然引入了额外的寻址开销,但 GPU 的高并行度完全掩盖了这一点。更重要的是,它带来了两个关键收益:显存碎片化几乎消失(任意空闲页都可分配),以及支持 KV Cache 共享(如并行采样时多个输出共享同一个前缀的 KV 页)。

4.3 Continuous Batching:迭代级调度

传统批处理以“完整序列”为单位:一个序列生成完 [EOS] 才退出批次,新请求必须等待整个批次结束。Continuous Batching 将调度粒度缩小到单次解码迭代(即一次 forward pass)。

具体流程如下:

  1. 迭代开始:调度器检查当前批次中哪些序列已生成 [EOS] 或达到最大长度,将其移出批次。
  2. 插入新请求:从等待队列中取出新请求,为其分配初始 KV 页,加入当前批次。
  3. 执行前向:对批次内所有序列执行一次解码,得到新的 token 和更新的 KV Cache。
  4. 重复:进入下一次迭代,再次检查完成状态并插入新请求。

这种机制使得 GPU 在每一轮迭代都保持“满载”状态。即使某个请求已经生成完毕,它的显存页也会被立即回收,供新请求使用。在请求到达率较高的场景下,Continuous Batching 可以将吞吐量提升 10~20 倍(相比静态批处理)。

4.4 动态批处理:抢占与优先级

vLLM 的调度器还实现了动态的抢占(Preemption)机制。当显存不足时,调度器会选择部分序列将其 KV Cache 从 GPU 卸载到 CPU,腾出空间给新请求。被抢占的序列在后续迭代中会从 CPU 恢复,继续生成。这种机制保证了服务的高可用性,避免因单个大请求导致整个服务 OOM。

vLLM 支持两种抢占策略:按长度优先(优先抢占生成长度最短的序列,因为恢复成本低)和按到达时间优先(优先抢占最晚到达的请求)。实际部署中,通常配合 --max-num-seqs 和 --gpu-memory-utilization 参数来控制批大小和显存水位。

4.5 三种机制的协同工作流程

机制 解决的问题 核心数据结构 性能影响
PagedAttention 显存碎片化、预分配浪费 页表(逻辑页 → 物理页) 显存利用率提升 4~5 倍
Continuous Batching GPU 空闲等待、批次切换开销 等待队列 + 运行批次 吞吐量提升 10~20 倍
动态抢占 长请求导致 OOM、服务不稳定 CPU 侧 KV 缓存池 P99 延迟更稳定

三者协同的典型流程:新请求到达 → 放入等待队列 → 调度器在下一轮迭代将其加入批次 → 分配物理页并建立页表映射 → 执行 PagedAttention 解码 → 若显存不足则抢占部分序列 → 被抢占序列的 KV 页标记为可回收 → 新请求获得显存继续执行。

4.6 案例:并发请求下的行为对比

假设 GPU 显存可容纳 100 个页(每页 32 token),同时到达 20 个请求,每个请求平均生成 200 token。

  • 传统静态批处理:每个请求预留 2048 token 的连续 KV 空间,20 个请求共需 20 × 2048 × 2(K+V)× 2 bytes(FP16)≈ 160 MB。若实际平均只生成 200 token,则浪费约 90% 显存。GPU 利用率在批次切换时降为 0。
  • vLLM:每个请求按需分配页,20 个请求平均共需 20 × 200 × 2 × 2 ≈ 16 MB。剩余显存可继续接收新请求。GPU 在每轮迭代都保持满负荷。
实测参考:在 A100 上使用 Llama-2-7B,vLLM 的吞吐量可达 2500+ tokens/s(连续请求),而 HuggingFace 原生实现通常只有 200~400 tokens/s。差距主要来自 Continuous Batching 和 PagedAttention 的叠加效应。

4.7 常见误区与调优要点

误区 1:认为 PagedAttention 只是“显存优化”,不影响延迟。实际上,页表查找在长序列(>2048 token)时会引入额外开销,极端情况下可能增加 5~10% 的单 token 延迟。

误区 2:盲目调大 --max-num-seqs 追求吞吐。当批大小超过一定阈值(通常 64~128),GPU 计算资源饱和,继续增大批大小只会增加排队延迟,降低单请求响应速度。

误区 3:忽略 --gpu-memory-utilization 的设置。默认值 0.9 意味着预留 10% 显存给激活值和临时张量。若模型较大或输入序列较长,应适当降低该值(如 0.85),避免运行时 OOM。

调优建议:先用 vllm serve --help 查看默认参数,再根据实际负载逐步调整。监控指标重点关注 gpu_cache_usage(KV 缓存使用率)和 num_requests_running(当前运行请求数)。当 gpu_cache_usage 长期高于 0.95 时,说明显存接近瓶颈,应考虑增加 --max-num-seqs 或使用更小的模型。