2. vLLM核心架构解析:模型加载机制、PagedAttention原理、KV Cache管理
好,咱们直接进入正题。这一章我会把vLLM最核心的三个模块拆开来讲。说白了,你理解了这三个东西,vLLM在你眼里就不再是个黑盒了。我个人习惯是,先搞懂原理再动手调优,这样踩坑的时候心里有数。
2.1 模型加载机制:从磁盘到GPU的旅程
模型加载,听起来就是把文件读进来对吧?但实际没那么简单。我刚开始部署vLLM时,就遇到过加载一个70B模型把内存撑爆的情况。嗯,这里要注意,vLLM的加载机制其实分三步走。
2.1.1 权重格式与分片
vLLM支持多种权重格式,比如常见的FP16、BF16,还有量化后的INT4、INT8。你想想看,一个70B的模型,FP16下光权重就140GB。单卡显存根本放不下,所以必须分片。
核心原则:vLLM采用张量并行(Tensor Parallelism)来切分模型。每个GPU只加载自己负责的那部分权重。
举个例子,你用4张A100部署LLaMA-70B:
# 启动命令示例
python -m vllm.entrypoints.openai.api_server \
--model /path/to/llama-70b \
--tensor-parallel-size 4 \
--dtype bfloat16
这里--tensor-parallel-size 4就是告诉vLLM:把模型切成4份,每张卡一份。我在项目中遇到过,如果显存刚好卡在边界,可以配合--max-model-len适当降低序列长度来省显存。
2.1.2 加载流程
vLLM的加载流程大致如下:
- 元数据解析:读取config.json,确定模型结构、层数、头数等
- 权重映射:根据张量并行策略,计算每张卡需要哪些权重
- 分片加载:每个进程只加载自己的那部分,避免重复
- 显存分配:预分配KV Cache空间(这个后面细说)
避坑指南:我曾经遇到过加载时OOM,原因是--max-model-len设得太大,导致KV Cache预分配把显存吃光了。解决办法是先用小序列长度测试,再逐步调大。
2.2 PagedAttention原理:灵感来自操作系统
PagedAttention是vLLM的杀手锏。你想想看,传统的Attention实现里,KV Cache是一整块连续内存。但问题是,请求的长度不一样,有的长有的短,连续分配很容易产生碎片。
vLLM团队怎么解决的?他们借鉴了操作系统的分页内存管理。说白了,就是把KV Cache切成固定大小的“页”(Page),按需分配。这和虚拟内存的思路一模一样。
2.2.1 核心数据结构
PagedAttention引入了几个关键概念:
| 概念 | 说明 |
|---|---|
| 逻辑块(Logical Block) | 每个请求的KV Cache被分成逻辑块,每个块固定大小(比如16个token) |
| 物理块(Physical Block) | GPU显存中实际分配的块,大小和逻辑块一致 |
| 块表(Block Table) | 记录逻辑块到物理块的映射关系 |
为什么这样做?好处很明显:
- 消除碎片:物理块可以分散在显存各处,不要求连续
- 按需分配:请求来了才分配物理块,不像传统做法一次性预留全部空间
- 共享内存:多个请求可以共享同一个物理块(比如beam search场景)
我的经验:在实际部署中,块大小(block size)是个关键参数。默认是16,但如果你处理的都是短文本(比如对话场景),可以试试8,能减少内部碎片。反之,长文本场景用32更高效。
2.2.2 注意力计算
PagedAttention的计算过程和标准Attention不太一样。它需要根据块表去“拼凑”出完整的KV序列。具体来说:
# 伪代码示意
def paged_attention(query, block_table, physical_kv_cache):
# 遍历每个逻辑块
for logical_block_id in range(num_blocks):
# 查表找到物理块地址
physical_block_id = block_table[logical_block_id]
# 从物理缓存中读取KV
k, v = physical_kv_cache[physical_block_id]
# 计算当前块的注意力
attn_block = attention(query, k, v)
# 累加结果
attn_output += attn_block
return attn_output
嗯,这里要注意,实际实现中vLLM用了高度优化的CUDA kernel,不会真的逐块循环。但原理就是这么回事。
2.3 KV Cache管理:显存就是命根子
KV Cache管理,说白了就是怎么用好显存。在LLM推理中,KV Cache占的显存往往比模型权重还大。我见过不少团队,模型能加载进去,但跑几个请求就OOM了,问题就出在Cache管理上。
2.3.1 预分配 vs 动态分配
vLLM默认采用预分配策略。启动时根据--max-model-len和--gpu-memory-utilization算出KV Cache的总容量,一次性分配好。这样做的好处是:
- 避免运行时分配开销:推理过程中不会因为分配显存而卡顿
- 内存确定性:能提前知道最多支持多少个并发请求
但代价也很明显:如果--max-model-len设得太大,大量显存会被闲置。
调优建议:我个人习惯把--gpu-memory-utilization设为0.9到0.95,留出10%给模型权重和临时变量。如果业务场景的序列长度波动很大,可以试试0.85,更稳妥。
2.3.2 调度策略
vLLM的调度器负责决定:哪个请求的KV Cache该被换出?哪个该保留?
它用的是类似LRU的策略,但做了优化:
- 请求优先级:正在生成的请求优先保留
- 块级换出:以块为单位换出,不是整个请求
- 写回机制:换出的块可以写回CPU内存(如果配置了)
我曾经踩过的坑:有次我把--swap-space设得很大,以为能靠CPU内存来扩展容量。结果发现CPU-GPU之间的传输带宽成了瓶颈,吞吐量反而下降了。后来我干脆关掉了swap,只靠GPU显存,效果反而更好。
2.3.3 显存监控
生产环境中,我建议你至少监控这几个指标:
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| gpu_cache_usage_perc | KV Cache使用率 | > 90% |
| num_requests_running | 正在处理的请求数 | 根据模型容量设定 |
| num_requests_waiting | 排队等待的请求数 | > 100 |
这些指标vLLM的Prometheus exporter都会暴露,直接接入Grafana就行。
2.4 核心架构总览
为了让你更直观地理解这三个模块的关系,我画了张图:
从这张图你能看到,三个模块不是孤立的。模型加载决定了权重怎么放,PagedAttention决定了计算怎么快,KV Cache管理决定了显存怎么省。三者配合好了,才能实现vLLM宣称的“接近零碎片”和“高吞吐”。
一句话总结:模型加载是基础,PagedAttention是核心创新,KV Cache管理是工程落地关键。三者缺一不可。
好了,这一章的内容就到这里。理解了这个架构,你再去调优vLLM,就知道该从哪里下手了。