AI vLLM:PagedAttention不够用了之后

先说背景:我们团队去年要做个企业级问答系统,模型用的 Qwen2-72B。这不是你硬件不行,是传统推理框架在 KV Cache 上不够聪明。

为什么需要 vLLM

先说背景:我们团队去年要做个企业级问答系统,模型用的 Qwen2-72B。刚开始用 HuggingFace Transformers 直接推理,问题马上就来了:

  • 显存浪费严重:KV Cache 占用 50% 以上显存,但很多 prompt 完成长度后,cache 里存的 token 根本用不上
  • 吞吐量低:batch size 一大就 OOM,batch size 一小又浪费计算资源
  • 推理延迟高:用户问一个问题,系统响应时间动辄几秒,体验很差

这些问题的根源在 KV Cache 的内存管理方式。传统推理框架里,每个请求的 KV Cache 是连续分配的,而且得按最大序列长度预留。就像给每个会议室都按 100 人装修,结果大多数时候只来 10 个人。

PagedAttention 的思路

vLLM 的核心创新是 PagedAttention,这个名字灵感来自操作系统的虚拟内存分页机制。

传统 KV Cache 是这样分配的:

graph LR A[Request 1<br/>max_len=2048] --> B[连续 KV Cache<br/>2048 * 2 * hidden_size] C[Request 2<br/>max_len=4096] --> D[连续 KV Cache<br/>4096 * 2 * hidden_size] E[Request 3<br/>max_len=1024] --> F[连续 KV Cache<br/>1024 * 2 * hidden_size] style B fill:#ffdddd style D fill:#ffdddd style F fill:#ffdddd

每个请求都得按最大序列长度预留空间,中间空的显存其他请求还用不了,只能浪费。

PagedAttention 的做法是把 KV Cache 切成固定大小的块(Block),像内存页一样管理:

graph LR A[Block Pool<br/>共享显存池] --> B[Block 1] A --> C[Block 2] A --> D[Block 3] A --> E[Block 4] A --> F[Block 5] B -.-> G[Request 1<br/>Block 1,2] C -.-> G D -.-> H[Request 2<br/>Block 3,4] E -.-> H F -.-> I[Request 3<br/>Block 5] style A fill:#d4edda

每个请求按需分配 Block,不够了再申请,不用的可以还给池子。这样显存利用率能提升 2-4 倍。

但这只是内存管理,vLLM 真正厉害的是在 Attention 计算层面的优化。

PagedAttention 的计算优化

在 Attention 计算时,PagedAttention 可以利用 Block 的连续性做批量计算。一个请求的 KV Cache 虽然物理上不连续,但逻辑上是连续的,可以一起做矩阵乘法。

# 伪代码:PagedAttention 的核心思路
def paged_attention(q, k_blocks, v_blocks, block_tables):
    """
    q: [batch_size, num_heads, head_dim]
    k_blocks, v_blocks: [num_blocks, block_size, num_heads, head_dim]
    block_tables: [batch_size, max_seq_len // block_size]
    """
    attn_weights = []
    for i in range(batch_size):
        # 获取当前请求的所有 Block
        blocks = block_tables[i]
        # 从 Block Pool 里提取对应的 K 和 V
        k_seq = gather_blocks(k_blocks, blocks)
        v_seq = gather_blocks(v_blocks, blocks)

        # 计算注意力分数
        scores = torch.matmul(q[i].unsqueeze(0), k_seq.transpose(-2, -1))
        scores = scores / math.sqrt(head_dim)

        # 计算 Attention 输出
        attn_output = torch.matmul(softmax(scores), v_seq)
        attn_weights.append(attn_output)

    return torch.cat(attn_weights, dim=0)

关键点在于 gather_blocks 操作,vLLM 把这个做成了 CUDA kernel,直接在 GPU 上按 Block Table 索引取数据,避免了 CPU-GPU 之间的大量小内存拷贝。

部署实践

基础部署

先从最简单的开始,用 vLLM 部署一个单卡推理服务:

pip install vllm

# 启动 API 服务
python -m vllm.entrypoints.api_server \
    --model Qwen/Qwen2-72B-Instruct \
    --tensor-parallel-size 1 \
    --gpu-memory-utilization 0.95 \
    --max-model-len 8192 \
    --block-size 16 \
    --host 0.0.0.0 \
    --port 8000

几个关键参数:

  • tensor-parallel-size:张量并行度,单卡就是 1
  • gpu-memory-utilization:显存使用率,0.95 是相对安全的值
  • max-model-len:最大序列长度,影响 KV Cache 预分配
  • block-size:每个 Block 的 token 数,16 是默认值,一般不用改

多卡部署

我们的环境是 4 张 A100 80GB,张量并行设置为 4:

python -m vllm.entrypoints.api_server \
    --model Qwen/Qwen2-72B-Instruct \
    --tensor-parallel-size 4 \
    --gpu-memory-utilization 0.95 \
    --max-model-len 16384 \
    --block-size 16 \
    --host 0.0.0.0 \
    --port 8000

注意 max-model-len 提升到了 16384,因为企业应用里不少场景需要长上下文。

性能对比

部署完后,我做了个简单对比测试,使用相同测试集(100 个真实问答)。实测数据显示 vLLM 在吞吐量和延迟上的提升很明显,显存占用也显著降低。

vLLM 相比 Transformers 的性能提升和显存占用对比

这个数据看起来很美好,但实际生产环境里情况复杂得多。

踩坑记录

坑一:CUDA OOM 依然存在

理论上 vLLM 通过 PagedAttention 能缓解显存问题,但实际运行时还是遇到了 CUDA OOM。

问题出在 max-model-len 设置太大了。max-model-len 影响 KV Cache 的预分配,虽然 PagedAttention 是动态分配,但 vLLM 还是会为每个请求预留一些 Block。

解决方案是:

  1. 根据实际业务场景设置合理的 max-model-len,不要盲目设大
  2. 使用 --max-num-batched-tokens 限制批次 token 总数
  3. 开启 --enforce-eager 模式调试,看看到底卡在哪里
python -m vllm.entrypoints.api_server \
    --model Qwen/Qwen2-72B-Instruct \
    --tensor-parallel-size 4 \
    --gpu-memory-utilization 0.9 \  # 降到 0.9,留点余量
    --max-model-len 8192 \  # 降低最大序列长度
    --max-num-batched-tokens 4096 \  # 限制批次 token 总数
    --block-size 16 \
    --host 0.0.0.0 \
    --port 8000

坑二:长文本性能下降

对于超过 4096 token 的长文本,vLLM 的性能下降很明显。原因是长文本的 KV Cache 占用 Block 多,导致 Block Pool 碎片化严重。

vLLM 提供了 --enable-chunked-prefill 参数,可以把长文本拆成小块处理:

python -m vllm.entrypoints.api_server \
    --model Qwen/Qwen2-72B-Instruct \
    --tensor-parallel-size 4 \
    --enable-chunked-prefill \  # 启用分块预填充
    --max-num-batched-tokens 4096 \
    --block-size 16 \
    --host 0.0.0.0 \
    --port 8000

启用后长文本性能有一定改善,但提升有限(约 15%),说明碎片化问题还是存在。

坑三:多并发时延迟抖动

生产环境里,请求并发不是均匀的,有时候会突然来一波高并发。vLLM 在这种情况下延迟抖动很明显。

原因是 vLLM 的调度算法基于连续批处理(Continuous Batching),在高并发场景下调度开销变大。可以通过调整 --max-num-seqs 参数来缓解:

python -m vllm.entrypoints.api_server \
    --model Qwen/Qwen2-72B-Instruct \
    --tensor-parallel-size 4 \
    --max-num-seqs 64 \  # 限制最大并发请求数
    --block-size 16 \
    --host 0.0.0.0 \
    --port 8000

这个参数需要根据实际业务调,设置太大容易 OOM,设置太小会拒绝请求。

坑四:量化模型兼容性

我们尝试过用 AWQ 量化 Qwen2-72B 到 4-bit,结果 vLLM 不支持。vLLM 目前只支持自己的一套量化方案,比如 GPTQ、AWQ 的支持有限。

后来用了 vLLM 自己的量化方案:

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2-72B-Instruct",
    quantization="gptq",  # 使用 GPTQ 量化
    tensor_parallel_size=4,
    gpu_memory_utilization=0.95,
)

量化后显存占用减少了约 40%,但推理速度下降了约 20%,这是典型的显存-速度权衡。

结果和反思

经过几个月的折腾,我们最终在生产环境跑了 vLLM,整体效果还是不错的:

  • 吞吐量相比原始 Transformers 提升了 4-5 倍
  • 显存占用减少了约 35%
  • P99 延迟从 5 秒降到了 1 秒以内

但也发现了一些边界:

  1. 模型大小限制:vLLM 对超大模型(比如几百 B 参数)支持不够成熟
  2. 量化方案有限:如果想用特定的量化算法,可能还得回 HuggingFace
  3. 调试复杂度高:出问题时,PagedAttention 的调度机制让问题定位更难

vLLM 不是银弹,它解决的是特定场景(大模型推理)的特定问题(KV Cache 管理)。如果你的模型不大、并发不高,其实 Transformers 也够用。但如果你的场景跟我们类似(大模型、高并发、长上下文),vLLM 值得试一试。

技术选型从来不是追求最新最酷,而是找到最适合当前问题的方案。vLLM 对我们来说是合适的选择,但对你来说未必。

折腾完这堆东西,最大的感受是:理论基础很重要,但实践中的细节和边界条件更致命。PagedAttention 的思路很漂亮,真正用起来时,显存碎片、调度开销、量化兼容这些细节问题,才是决定成败的关键。

技术的进步从来不是靠一个漂亮的思路解决所有问题,而是在实践中不断踩坑、填坑的过程中往前走的。vLLM 如此,其他技术也如此。

版权声明: 本文首发于 指尖魔法屋-AI vLLM:PagedAttention不够用了之后https://blog.thinkmoon.cn/post/420-ai-vllm-pagedattention-inference-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!