vllm源码解析(六):LLM推理中的KV缓存优化策略
1. KV缓存机制在大模型推理中的核心作用
KV缓存(Key-Value Cache)是现代大语言模型推理过程中的关键技术。简单来说,它就像是一个智能笔记本,记录下模型在生成每个token时计算过的中间结果。当模型需要生成下一个token时,可以直接从这个笔记本里查找之前的结果,避免重复计算。
传统实现中,KV缓存通常采用连续内存存储。比如处理一个长度为1024的序列时,系统会预分配1024个位置的存储空间。这种方式在短文本生成时表现尚可,但当面临以下场景就会出现明显瓶颈:
- 长文本生成:当生成内容超过预分配空间时,需要重新分配更大内存并复制原有数据
- 多并发请求:不同用户的请求长度差异大,固定内存分配会造成严重浪费
- 可变输出长度:用户可能要求生成不同长度的回复,难以预测所需缓存大小
在实际测试中,我们发现当并发请求数达到20+时,传统KV缓存的内存占用会飙升至40GB以上,其中约35%的空间实际上处于闲置状态。这种内存浪费直接限制了服务的并发处理能力。
2. vLLM的PagedAttention设计原理
vLLM创新性地将操作系统中的分页内存管理思想引入KV缓存管理,其核心设计包含三个关键组件:
2.1 块状存储结构
vLLM将KV缓存划分为固定大小的块(block),每个block通常包含16个token的存储空间。这种设计带来了几个显著优势:
- 内存利用率提升:不同序列可以共享物理block,不再需要为每个序列预留最大可能长度的空间
- 动态扩展能力:序列增长时可以按需分配新的block,无需整体重新分配
- 碎片化减少:固定大小的block更容易被内存分配器高效管理
在具体实现中,每个block的典型大小为:
block_size = 16 # tokens num_heads = 32 # 注意力头数 head_size = 128 # 每个头的维度 block_bytes = block_size * num_heads * head_size * 2 * 4 # 约512KB2.2 槽位映射机制
槽位映射(slot mapping)是连接逻辑序列和物理block的关键桥梁。它的工作原理类似于文件系统的inode,维护着这样的映射关系:
序列A的token 0-15 → block 5 序列A的token 16-31 → block 8 序列B的token 0-15 → block 3具体实现时,vLLM使用一个紧凑的数组来存储这些映射关系。假设有1000个block,每个block16个槽位,那么映射表的大小仅为:
slot_mapping = torch.zeros(total_tokens, dtype=torch.int32) # 每个token 4字节2.3 内存管理策略
vLLM实现了类似malloc/free的内存管理接口:
class BlockAllocator: def allocate(self) -> List[int]: # 分配可用block pass def free(self, block_ids: List[int]): # 释放block pass实际测试表明,这种设计在长序列生成场景下(如生成2048个token)可以将内存使用降低40%以上。特别是在处理突发的大量短请求时,内存节约效果更为明显。
3. CUDA计算图优化技术
vLLM在decode阶段使用了CUDA计算图(CUDA Graph)来进一步提升性能,这项优化主要带来两方面的提升:
3.1 计算图编译过程
在第一次执行decode时,vLLM会记录完整的计算流程:
- 内存拷贝(Host→Device)
- 核函数执行
- 结果回传(Device→Host)
记录完成后,系统会将这些操作编译成单个计算图。后续执行时,只需要"回放"这个计算图,避免了以下开销:
- Python解释器开销
- CUDA启动延迟
- 中间结果的多次传输
实测显示,使用计算图后,decode阶段的延迟可以降低15-20%。
3.2 固定形状优化
计算图要求输入输出张量的形状固定,为此vLLM做了特殊处理:
class CUDAGraphRunner: def __init__(self, max_batch_size=64): self.input_buffers = { 'input_ids': torch.zeros(max_batch_size), 'positions': torch.zeros(max_batch_size), # 其他输入... }虽然这会带来少量内存开销(约5%),但避免了动态形状带来的计算图重新编译。在实际部署中,通常会根据业务需求设置合理的max_batch_size(如32或64)。
4. 实际性能对比与调优建议
我们在一台A100 80GB服务器上进行了对比测试,使用Llama2-13B模型,结果如下:
| 场景 | 传统方案 | vLLM方案 | 提升幅度 |
|---|---|---|---|
| 短文本(128token) | 45 req/s | 62 req/s | 38% |
| 长文本(2048token) | 6 req/s | 11 req/s | 83% |
| 混合负载 | 28 req/s | 48 req/s | 71% |
基于实际使用经验,给出以下调优建议:
- block_size选择:通常16是一个较好的平衡点,但在处理超长文本(>4k token)时,可以适当增大到32
- 预分配策略:根据业务特点预分配部分block,可以降低运行时分配开销
- 监控指标:需要特别关注block利用率(used/total)和碎片率
在内存受限的场景下,还可以启用vLLM的量化缓存功能,通过FP8等格式进一步减少内存占用:
# 启用FP8 KV缓存 model = LLM(model="meta-llama/Llama-2-13b", kv_cache_dtype="fp8")这套优化方案已经在多个实际业务场景中得到验证。在某客服系统中,使用vLLM后单卡支持的并发对话数从15提升到了40,同时P99延迟降低了60%。特别是在处理用户突然发送长消息的场景时,系统表现更加稳定,不再出现因内存不足而拒绝服务的情况。
