KV Cache 全景
核心概念KV Cache 是 LLM 推理中最核心的优化技术——没有它,自回归生成每秒只能生成 1 个 token。
理解 KV Cache = 理解 LLM 推理性能问题的关键。vLLM 的 PagedAttention 是这个领域近两年最重要的创新之一。
KV Cache 是什么
KV Cache(Key-Value Cache)是 LLM 推理过程中对历史 token 的 Key 和 Value 矩阵的缓存。
在自回归生成中,每生成一个新 token,模型需要关注所有之前的 token。如果不缓存,每次都要重新计算所有历史 token 的 K 和 V,计算量会从 O(n) 膨胀到 O(n²)。
vLLM 为什么重要
vLLM 由 UC Berkeley 开发,它提出的 PagedAttention 机制借鉴了操作系统虚拟内存的分页思想, 将 KV Cache 从连续内存中解放出来,解决了三个核心问题:
- 内存碎片 — 传统实现为最大序列长度预分配,浪费大量内存
- 利用率低 — 实际使用的 KV Cache 远小于预分配量
- 无法共享 — 相同前缀的请求无法复用 KV Cache
你能从这个教程学到什么
| 章节 | 主题 | 核心知识点 |
|---|---|---|
| s01–s03 | 核心概念 | 自注意力机制 → KV Cache 定义 → 传统实现的痛点 |
| s04–s07 | vLLM 深度 | 整体架构 → PagedAttention → Block Manager → 调度策略 |
| s08–s10 | 优化技术 | Prefix Caching → KV Cache 量化 → 分布式场景 |
| s11–s13 | 实践与对比 | 配置调优 → 系统对比 → 监控工具 |
先修知识
- 了解 Transformer 架构的基本概念(Q/K/V/Attention)
- 了解自回归生成的流程(逐 token 生成)
- 了解 GPU 显存的基本概念
一句话记住
KV Cache 是"用空间换时间"——用显存存历史计算的 K/V,避免每次生成都重新算一遍所有历史 token。
学习路径建议
- 先过 s01-s03 — 理解"为什么需要 KV Cache"比"怎么实现"更重要
- 重点读 s05(PagedAttention) — 这是 vLLM 最核心的贡献
- 再读 s06(Block Manager) — 理解内存如何被实际管理
- 最后 s08(Prefix Caching) — 最实用的优化技术
推荐阅读
自注意力回顾
核心概念自注意力是 Transformer 的核心,KV Cache 是加速自注意力推理的关键。
自注意力计算公式
Transformer 的自注意力机制的核心公式:
Attention(Q, K, V) = softmax(Q × K^T / √d_k) × V
其中:
Q = 输入 × W_Q (Query 矩阵)
K = 输入 × W_K (Key 矩阵)
V = 输入 × W_V (Value 矩阵)
d_k = Key 的维度
自回归生成过程
LLM 生成文本时是逐 token 生成的(自回归):
Step 1: [I] → 生成 "love"
Step 2: [I, love] → 生成 "you"
Step 3: [I, love, you] → 生成 "."
每一步,模型需要计算当前所有 token 之间的注意力。
关键观察
在 Step 3 中,模型需要计算 3 个 token 的注意力:
对 token "." 的注意力计算:
q_4 = "." 的 Query
K = [k_1, k_2, k_3] ← 所有历史 token 的 Key
V = [v_1, v_2, v_3] ← 所有历史 token 的 Value
问题:k_1, k_2, v_1, v_2 在 step 2 已经算过了!
每次重新计算 → O(n²) 的计算量
朴素实现的问题
如果不使用 KV Cache,生成第 n 个 token 时需要:
- 重新计算前 n-1 个 token 的 K 和 V
- 计算量随序列长度平方增长:O(n²)
- 对于一个 2048 token 的序列,约 50% 的计算是重复的
- 对于一个 8192 token 的序列,约 87% 的计算是重复的
推理的阶段性差异
┌─────────────────────────────────────────────┐
│ LLM 推理两阶段 │
├──────────────┬──────────────────────────────┤
│ Prefill 阶段 │ Decode 阶段 │
│ (并行计算) │ (逐 token 生成) │
│ │ │
│ 输入完整 prompt│ 每步只生成 1 个新 token │
│ 计算所有 K/V │ 需要关注所有历史 token │
│ O(n²) 但可并行│ O(n) 但不可并行(串行) │
│ 计算密集 │ 访存密集(Memory-bound) │
└──────────────┴──────────────────────────────┘
什么是 KV Cache
核心概念KV Cache = 把之前算好的 Key 和 Value 矩阵存下来,下次直接用。
KV Cache 的定义
KV Cache 是在 Decode 阶段,对每一层 Transformer 中已经计算过的 Key 和 Value 矩阵的缓存。
传统方式(无 KV Cache):
Step 1: 计算 token 1 的 K₁, V₁ → 输出 token 2
Step 2: 计算 token 1,2 的 K₁,V₁,K₂,V₂ → 输出 token 3 ← 重复计算 K₁,V₁
Step 3: 计算 token 1,2,3 的 K₁,V₁,K₂,V₂,K₃,V₃ → 输出 token 4 ← 再次重复!
使用 KV Cache:
Step 1: 计算 K₁, V₁ → 缓存到 KV Cache → 输出 token 2
Step 2: 只计算 K₂, V₂ → 追加到 KV Cache → 输出 token 3
Step 3: 只计算 K₃, V₃ → 追加到 KV Cache → 输出 token 4
KV Cache 有多大
KV Cache 的显存占用公式:
每 token 的 KV Cache = 2 × num_layers × num_heads × head_dim × 2 bytes
↑ FP16 每个元素 2 字节
以 LLaMA-70B 为例:
num_layers = 80
num_heads = 64
head_dim = 128
每 token ≈ 2 × 80 × 64 × 128 × 2 = 2.6 MB
batch_size=16, seq_len=4096:
KV Cache ≈ 2.6 MB × 16 × 4096 ≈ 170 GB!
→ 远超单张 GPU 显存(A100 80GB)
KV Cache 的重要性
| 指标 | 有 KV Cache | 无 KV Cache |
|---|---|---|
| Decode 每步计算量 | O(1) — 只计算新 token | O(n) — 重新计算所有历史 |
| 推理延迟 | 随序列长度缓慢增长 | 随序列长度线性增长 |
| 瓶颈 | 访存带宽(Memory-bound) | 计算量(Compute-bound) |
Decode 阶段的瓶颈转换
使用 KV Cache 后,Decode 阶段的主要瓶颈从计算变成了访存:
Decode 每步的计算量很小(只算 1 个 token 的 QKV)
但需要把整个 KV Cache 从 HBM 搬到 SRAM
→ 访存带宽成为瓶颈
这就是为什么:
- FlashAttention 通过分块减少 HBM 访问
- vLLM 的 PagedAttention 通过分页管理减少内存碎片
传统实现与问题
核心概念传统实现的 KV Cache 就像一个为每本书预留一整面书架——即使只读几页,也要占一整面墙。
传统实现方式
大多数框架(HuggingFace Transformers、DeepSpeed 等)的 KV Cache 实现:
class KVCache:
def __init__(self, max_batch, max_seq_len, ...):
# 预分配最大可能的内存
self.key_cache = torch.zeros(
max_batch, num_layers, num_heads,
max_seq_len, head_dim, # ← 预分配到最大长度
dtype=torch.float16, device='cuda'
)
self.value_cache = torch.zeros(...) # 同理
def append(self, token_idx, key, value):
# 把新的 K/V 写入预分配的位置
self.key_cache[:, :, :, token_idx, :] = key
self.value_cache[:, :, :, token_idx, :] = value
三大问题
1. 预分配浪费
# 假设 max_seq_len=8192,实际只用了 512
# 浪费率 = (8192 - 512) / 8192 = 93.75%!
问题在于:你不知道请求会生成多长
→ 只能按最大值预分配
→ 大多数请求比最大值短得多
→ 巨大浪费
2. 内存碎片
不同的请求长度不同,释放后留下碎片:
[请求 A: 2048 tokens] [请求 B: 128 tokens] [请求 C: 4096 tokens]
↓ 请求 C 结束后
[请求 A: 2048 tokens] [空闲: 128] [空闲: 4096]
↑ 128 的碎片,无法被任何新请求复用
3. 无法共享
两个请求有相同的前缀 "What is the capital of France?":
请求 A: "What is the capital of France? Answer in English."
请求 B: "What is the capital of France? Answer in French."
传统实现:每个请求独立计算和存储 K/V
前 7 个 token 的 K/V 被计算了 2 次
如果 1000 个请求共享前缀 → 重复计算 1000 次!
问题汇总
| 问题 | 后果 | 严重程度 |
|---|---|---|
| 预分配浪费 | 大量显存被空占,batch size 受限 | 非常高 |
| 内存碎片 | 即使总空闲空间足够,也无法分配给新请求 | 高 |
| 无法共享 | 相同前缀重复计算 K/V,浪费算力和显存 | 中高 |
这些问题的根源
所有问题的根源在于:KV Cache 被当作连续内存块管理。
就像操作系统早期用连续分配管理物理内存一样——会产生严重的碎片和浪费。 vLLM 的核心创新就是:用虚拟内存的分页思想来管理 KV Cache。
vLLM 架构概览
vLLM 深度vLLM 的架构可以概括为:PagedAttention + Block Manager + Scheduler,三者协同工作。
vLLM 整体架构
┌─────────────────────────────────────────────────────┐
│ LLM API (OpenAI 兼容) │
├─────────────────────────────────────────────────────┤
│ Scheduler (调度器) │
│ 请求排队 → 内存检查 → 批处理 → 执行 → 输出 │
├─────────────────────────────────────────────────────┤
│ Block Manager (内存管理器) │
│ 分页分配 → 写时复制 → 碎片回收 → 共享管理 │
├─────────────────┬───────────────────────────────────┤
│ PagedAttention │ Attention 计算后端 │
│ (分页注意力) │ (FlashAttention / FlashInfer) │
├─────────────────┴───────────────────────────────────┤
│ Worker (GPU 执行器) │
│ 模型加载 → 前向传播 → 输出 │
└─────────────────────────────────────────────────────┘
核心组件
Scheduler(调度器)
决定哪些请求可以进入当前批次,核心逻辑:
1. 检查剩余显存能否容纳新请求的 KV Cache
2. 如果可以 → 加入当前批次
3. 如果不行 → 请求排队等待
4. 已完成的请求释放 KV Cache 块
Block Manager(内存管理器)
管理 KV Cache 的物理块分配,这是 PagedAttention 的地基。
PagedAttention(分页注意力)
修改了注意力计算方式,使得 K/V 可以从非连续的物理块中读取。
工作流程
1. 客户端发送请求(prompt + 参数)
2. Scheduler 检查内存,分配 logical blocks
3. Block Manager 映射 logical → physical blocks
4. Prefill 阶段:计算 prompt 所有 token 的 K/V
5. Decode 阶段循环:
a. 计算新 token 的 Q,读取对应物理块的 K/V
b. 执行 PagedAttention
c. 生成下一个 token
d. 分配新的 block(如果需要)
6. 请求完成 → 释放 blocks
与传统系统的对比
| 维度 | vLLM | 传统实现 (HF) |
|---|---|---|
| 内存分配 | 按需分配,非连续 | 预分配,连续 |
| 内存碎片 | 几乎无碎片(分页管理) | 严重碎片 |
| 前缀共享 | 支持(写时复制) | 不支持 |
| 批处理效率 | 高(内存利用率高) | 低(预分配浪费) |
PagedAttention 原理
vLLM 深度PagedAttention 借鉴了操作系统虚拟内存的分页思想——让 KV Cache 可以存在不连续的物理块中。
核心思想
把 KV Cache 分成固定大小的 Block(页),用 Block Table(页表) 映射逻辑位置到物理位置。
传统方式:连续内存
┌──────────────────────────────────────────────┐
│ K₁V₁│K₂V₂│K₃V₃│K₄V₄│K₅V₅│K₆V₆│K₇V₇│K₈V₈│... │ ← 连续分配
└──────────────────────────────────────────────┘
PagedAttention:分页内存
Logical Blocks: Physical Blocks:
┌─────────┐ ┌─────────┐
│ Block 0 │────┐ │ Block 2 │ (别的请求的)
├─────────┤ │ ├─────────┤
│ Block 1 │ ├────────→│ Block 5 │
├─────────┤ │ ├─────────┤
│ Block 2 │ │ │ Block 0 │←──── 其他请求也共享
├─────────┤ └────────→│ Block 7 │
│ Block 3 │ └─────────┘
└─────────┘
Block Table
每个请求的 Block Table:
Request "Hello, world! How are you?"
logical_block_0 → physical_block_7 [Hello, wo]
logical_block_1 → physical_block_5 [rld! How]
logical_block_2 → physical_block_3 [ are you]
请求只关心 logical blocks,Block Manager 处理物理映射。
写时复制(Copy-on-Write)
当多个请求共享相同前缀时,vLLM 使用写时复制:
请求 A: "What is the capital of France? Answer in English."
请求 B: "What is the capital of France? Answer in French."
↑ 前 7 个 token 共享相同的物理块
初始状态:A 和 B 的 Block Table 指向同一物理块
当请求 A 需要写入新 token 时:
→ 先复制物理块
→ 在副本上写入
→ A 的 Block Table 指向新副本
→ B 仍然指向原块
传统 vs PagedAttention
| 特性 | 传统 Attention | PagedAttention |
|---|---|---|
| K/V 存储 | 连续内存 | 非连续的分页内存 |
| 内存浪费 | ~60-80%(预分配) | <4%(页内碎片) |
| 前缀共享 | 不支持 | 写时复制 |
| 计算方式 | torch.matmul 直接计算 | 分块读取,非连续计算 |
| 最大 batch | 受预分配限制 | 只受物理显存限制 |
PagedAttention 计算细节
传统 Attention 计算:
attn = softmax(Q @ K^T / sqrt(d)) @ V
# K 和 V 是连续的 [seq_len, head_dim] 矩阵
PagedAttention 计算:
# K 和 V 分布在多个非连续的物理块中
for each physical_block in block_table:
block_k = load_block(physical_block) # [block_size, head_dim]
block_v = load_block(physical_block)
attn += softmax(Q @ block_k^T / sqrt(d)) @ block_v
# 使用特殊 kernel(flash_attn_varlens_func 或 flashinfer)
# 直接接受 block_table + 物理地址作为输入
Block 大小的影响
block_size 是关键超参数:
- block_size=16: 更细粒度,碎片更少,但页表更大
- block_size=64: 计算效率更高,但页内碎片更多
vLLM 默认值:block_size=16(对大多数场景最优)
vLLM 源码关键位置
# vllm/attention/backends/flash_attn.py
class FlashAttentionBackend:
def forward(
self,
query, # [num_tokens, num_heads, head_size]
key_cache, # 物理块中的 K
value_cache, # 物理块中的 V
block_table, # logical → physical 映射表
...
# vllm/worker/model_runner.py
# 调用 attention 后端的地方
attn_output = self.attn_backend.forward(
query=query,
key_cache=self.cache_engine.key_cache[layer_idx],
value_cache=self.cache_engine.value_cache[layer_idx],
block_table=block_tables,
)
Block Manager
vLLM 深度Block Manager 是 vLLM 的内存分配器——决定了谁能用、用多少、用完后怎么回收。
Block Manager 的职责
1. 初始化:计算可用物理块数量
total_blocks = available_gpu_memory / (block_size * KV_cache_per_token)
2. 分配:为新请求(或增长中的请求)分配物理块
alloc(seq_group) → [physical_block_ids]
3. 写时复制:共享前缀时管理 COW
cow(seq_group, block_id) → new_physical_block
4. 回收:请求完成后释放物理块
free(seq_group) → [freed_physical_block_ids]
5. 交换:当显存不足时,把块交换到 CPU 内存
swap_out(blocks, cpu_blocks)
swap_in(blocks, gpu_blocks)
物理块初始化
# vllm/block_manager.py (简化)
class BlockManager:
def __init__(self, cache_config, device_config):
# 计算 KV Cache 每个 token 的字节数
cache_dtype = cache_config.cache_dtype # FP16/BF16/FP8
num_layers = cache_config.num_layers
num_heads = cache_config.num_kv_heads
head_dim = cache_config.head_dim
self.block_size = cache_config.block_size # 默认 16
# 每个物理块的字节数
bytes_per_block = (
2 # K 和 V
* num_layers
* num_heads
* self.block_size
* head_dim
* torch.finfo(cache_dtype).bits // 8
)
# 可用块数
gpu_memory = get_gpu_memory()
kv_cache_memory = gpu_memory * cache_config.gpu_memory_utilization
self.num_gpu_blocks = kv_cache_memory // bytes_per_block
self.num_cpu_blocks = ... # CPU 交换空间
分配策略
# Prefill 阶段:为 prompt 中的每个块预分配物理块
def allocate_prefill_blocks(seq_group):
num_prompt_blocks = ceil(seq_group.prompt_len / self.block_size)
# 先检查是否有空闲块
if self.free_blocks.count() >= num_prompt_blocks:
return self.free_blocks.pop(num_prompt_blocks)
else:
# 尝试交换
# 如果还不行 → OutOfMemory
# Decode 阶段:每次生成 block_size 个 token 后分配一个新块
def allocate_token_block(seq_group):
if seq_group.needs_new_block():
# 分配一个物理块
block = self.free_blocks.pop()
seq_group.block_table.append(block)
return block
return None
Watermark 机制
为了应对突发内存需求,vLLM 使用 watermark 预留:
总物理块:1000
Watermark: 50(5%)
当空闲块 ≤ 50 时:
→ 停止接受新请求
→ 先完成正在运行的请求
→ 回收内存后再接受新请求
避免:接受了新请求后发现内存不足
GPU 内存分布
GPU 显存布局(以 A100 80GB / LLaMA-70B 为例):
┌────────────────────────────────────────┐
│ 模型权重: ~140GB(需要多 GPU 分片) │
├────────────────────────────────────────┤
│ KV Cache: 可用显存 × gpu_memory_util │
│ (默认 0.90, 即 72GB × 0.9 ≈ 65GB) │
├────────────────────────────────────────┤
│ 激活值 + 临时 buffer │
├────────────────────────────────────────┤
│ 预留 (watermark + 余量) │
└────────────────────────────────────────┘
调度与批处理
vLLM 深度有了分页管理 KV Cache,就可以更大胆地组 batch——只要物理块够,什么请求都能塞一起。
Scheduler 的核心决策
每个 step,Scheduler 需要决定:
1. 哪些等待队列中的请求可以加入当前批次?
→ 检查可用块数是否足够容纳新请求的 KV Cache
2. 哪些运行中的请求应该暂停(交换出去)?
→ 当显存不足时,选择优先级最低的请求
3. 哪些请求已经完成?
→ 回收其 KV Cache 块
调度策略
默认策略:先来先服务 + 贪心批处理
def schedule(self):
running = [] # 当前正在运行的请求
waiting = [] # 等待的请求
# 1. 尝试把等待中的请求加入批处理
for seq in waiting:
needed_blocks = compute_needed_blocks(seq)
if free_blocks >= needed_blocks:
running.append(seq)
allocate_blocks(seq, needed_blocks)
# 2. 运行一步 Decode
outputs = self.model.execute(running)
# 3. 处理完成和新生成的块
for seq in running:
if seq.finished:
free_blocks_for(seq)
elif seq.needs_new_block():
if free_blocks > 0:
allocate_one_block(seq)
else:
# 内存不足 → 暂停
pause(seq)
return outputs
vLLM 的批处理优势
因为 PagedAttention 让 KV Cache 不再连续,vLLM 可以:
传统系统:
batch_size 受限于"预分配 x max_seq_len"
如果一个请求需要 4K 长度 → 所有请求都按 4K 预分配
→ 短请求浪费大量空间
→ batch_size 被迫减小
vLLM:
batch_size 受限于"物理块总数"
短请求只占几块,长请求占多块
不会因一个长请求而浪费所有请求的空间
→ 同样的显存,可以处理更大的 batch
调度参数
max_num_batched_tokens # 每批最大 token 数(默认 2048)
max_num_seqs # 每批最大请求数(默认 256)
gpu_memory_utilization # GPU 显存用于 KV Cache 的比例(默认 0.90)
# 当这两个条件任一满足时,停止加入新请求
# - 总 token 数 > max_num_batched_tokens
# - 请求数 > max_num_seqs
性能对比
| 场景 | 传统批处理 | vLLM 批处理 |
|---|---|---|
| 短请求(128 token) | batch=64,浪费 92% 显存 | batch=1024+ |
| 混合长度 | 按最长请求预分配,浪费严重 | 按需分配,无浪费 |
| 长文本(32K token) | batch 被拉低到 1-2 | batch 可到 8-16 |
Prefix Caching
优化技术如果你的用户都在问同一个系统提示/prompt 前缀——APC 让第一个请求算完,后面的直接复用。
什么是 Prefix Caching
Automatic Prefix Caching(APC)是 vLLM 的一项关键优化: 自动检测请求中相同的前缀 token 序列,共享它们的 KV Cache 块。
请求 A: "[System Prompt] Tell me a story about a cat."
请求 B: "[System Prompt] Tell me a story about a dog."
传统方式:
A 和 B 各自独立计算 "[System Prompt] Tell me a story about a" 的 8 个 token
→ 重复计算,浪费!
APC 方式:
A 先算 → 这些块的 hash 存入全局缓存
B 来了 → 检测到相同前缀 → 直接共享 A 的物理块
B 只需要计算 " dog" 的 2 个新 token
实现原理
# 核心:每个物理块通过其内容的 hash 来索引
class PrefixCache:
def __init__(self):
self.hash_to_block = {} # hash → physical_block
def get_or_compute(self, tokens):
# 逐块尝试匹配
blocks = []
for block in chunk_to_blocks(tokens):
block_hash = hash(block.tokens)
if block_hash in self.hash_to_block:
# 命中缓存,复用物理块
blocks.append(self.hash_to_block[block_hash])
else:
# 未命中,创建新块
new_block = alloc_block()
compute_kv(new_block, block.tokens)
self.hash_to_block[block_hash] = new_block
blocks.append(new_block)
return blocks
APC 的适用场景
最佳场景(TTFT 降低 50-90%):
✓ 所有请求共享长系统提示(如 ChatML 模板)
✓ 少量 prompt 模板 + 大量请求(如 LLM API 服务)
✓ 多轮对话(历史消息反复出现)
效果有限:
○ 每个请求 prompt 完全不同
○ prompt 很短(< block_size=16)
不适合:
✗ 前缀长度 < block_size(无法共享)
✗ 请求都是独立的长文本
启用方式
# 启动 vLLM 时开启
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-8B \
--enable-prefix-caching # ← 开启 APC
# 开启后不需要任何配置,自动生效
性能影响
| 指标 | 关闭 APC | 开启 APC |
|---|---|---|
| TTFT(首 token 延迟) | 100%(基准) | 30-60%(共享前缀越⻓,降低越多) |
| KV Cache 显存 | 100%(基准) | 50-70%(共享前缀不需额外存储) |
| 吞吐量 | 100%(基准) | 120-180%(节省的显存可服务更多请求) |
量化 KV Cache
优化技术KV Cache 占用了绝大多数推理显存——把它从 FP16 压到 FP8,显存用量直接减半。
为什么需要量化 KV Cache
LLaMA-70B, batch=16, seq_len=4096, FP16:
每 token KV Cache ≈ 2.6 MB
总 KV Cache ≈ 2.6 MB × 16 × 4096 ≈ 170 GB
→ 需要至少 3 张 A100 80GB(仅 KV Cache 就 > 2 张卡!)
LLaMA-70B, batch=16, seq_len=4096, FP8:
总 KV Cache ≈ 170 GB / 2 = 85 GB
→ 只需要 1 张 A100 80GB(省了一半显存!)
→ 可以把 batch 翻倍,或支持更⻓序列
vLLM 支持的 KV Cache 量化格式
# FP8(推荐,精度损失极小)
--kv-cache-dtype fp8
# 每个元素 1 字节,带宽需求减半
# 需要 H100/H200/B200 硬件支持或模拟
# FP8 E4M3(默认 FP8 格式)
# 精度范围:±448,对 KV Cache 足够
# INT8(兼容性更好)
# 需要 calibration,对 outlier 敏感
精度影响
FP16 → FP8 对 KV Cache 的影响:
优点:
✓ 显存减半
✓ 访存带宽需求减半 → Decode 速度提升
✓ 对生成质量影响通常 < 0.5%(perplexity 损失极小)
注意事项:
⚠ FP8 对 outlier(异常值)敏感
⚠ 某些 KV head 的特定位置方差极大
⚠ 建议配合 KV Cache scaling factor 使用
量化 + 分页 = 更强
PagedAttention 解决了空间利用率问题
FP8 量化减少了每块需要的物理空间
两者组合:
同样的 80GB 显存
无量化:~30000 个块(block_size=16)
FP8 量化:~60000 个块
→ 2 倍容量 = 2 倍 batch size 或 2 倍序列长度
启用方法
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-70B \
--kv-cache-dtype fp8 \
--gpu-memory-utilization 0.95
分布式 KV Cache
优化技术模型太大放不进一张卡?KV Cache 也跟着分散在多卡上。
张量并行(Tensor Parallel)
TP 下 KV Cache 也是分片的:
单卡场景:
num_heads=32 → 每卡 32 个 head
KV Cache 大小: 2 × 32 × ...
TP=4 场景(4 卡):
num_heads=32 → 每卡 8 个 head
KV Cache 大小: 2 × 8 × ... ← 每卡减少 4x!
优点:
✓ 每卡 KV Cache 减少 = 可以处理更⻓序列/更大 batch
✓ 注意力计算也分片
缺点:
⚠ 需要 all-reduce 聚合结果
⚠ 通信开销随 head 数增长
流水线并行(Pipeline Parallel)
PP 下,不同 GPU 持有不同层的 KV Cache:
GPU 0: layers 0-19 → 只存 layers 0-19 的 KV Cache
GPU 1: layers 20-39 → 只存 layers 20-39 的 KV Cache
总 KV Cache 分布在各卡上
每卡只存自己负责的层
优点:大幅减少单卡 KV Cache 量
缺点:多了一层通信(点对点传输激活值)
vLLM 的分布式调度
# vllm 用 Ray 做分布式调度
from vllm import LLM, SamplingParams
# 自动检测 GPU 并分配 TP/PP
llm = LLM(
model="meta-llama/Meta-Llama-3-70B",
tensor_parallel_size=4, # 4 卡 TP
pipeline_parallel_size=1, # 1 路 PP
)
# 每个 worker 有自己的 Block Manager
# 但共享同一个调度策略
分布式场景的特殊问题
1. 全局内存协调
- Scheduler 需要知道所有 GPU 的内存使用情况
- 不能某张卡内存满了但其他卡还有空
2. 通信带宽
- TP 中的 attention 需要 all-reduce
- 跨节点通信(NVLink vs InfiniBand vs Ethernet)影响很大
3. Prefix Caching 跨节点
- 相同的 prefix 在不同节点上能否共享?
- vLLM 目前支持单节点内 prefix 共享
- 跨节点共享仍在开发中
配置调优指南
实践与对比KV Cache 调优的核心是 Trade-off:更多 KV Cache = 更大 batch/序列 vs 留给模型权重的空间更少。
关键配置参数
# 1. GPU 显存分配
--gpu-memory-utilization 0.90 # KV Cache 占可用显存比例
# 0.90(default) → 90% 给 KV Cache
# 调高 → 更大容量,但留给模型更少
# 调低 → 更多余量,但容量减少
# 2. 最大序列长度
--max-model-len 4096 # 模型支持的最大序列长度
# 越大 → KV Cache 占用越多
# 重要:这个值设得太大会浪费显存
# 建议设为你实际需要的最大长度
# 3. Block 大小
--block-size 16 # 每个物理块存储的 token 数
# 16(default): 细粒度,碎片少
# 32/64: 计算效率高,但碎片更多
# 4. KV Cache 量化
--kv-cache-dtype auto # auto(FP16) / fp8
# fp8 显存减半,精度损失极小
不同场景的推荐配置
场景 1: 短请求 + 高并发(如 Chat API)
--max-model-len 4096
--gpu-memory-utilization 0.95
--max-num-seqs 256
--enable-prefix-caching # 大部分请求共享系统提示
场景 2: 长文档处理(如 RAG / 代码分析)
--max-model-len 32768
--gpu-memory-utilization 0.90
--max-num-seqs 32
--kv-cache-dtype fp8 # 长序列下 KV Cache 巨大,必须量化
场景 3: 严格低延迟(如实时翻译)
--max-model-len 2048
--gpu-memory-utilization 0.80 # 留更多余量给计算
--max-num-batched-tokens 512 # 小 batch 保证低延迟
KV Cache 显存估算公式
KV_Cache_Memory = 2 × L × H_kv × D × S × B × dtype_size
其中:
L = num_hidden_layers
H_kv = num_kv_heads (GQA 中可能小于 num_attention_heads)
D = head_dim
S = max_seq_len
B = max_batch_size
dtype_size = 2 (FP16) / 1 (FP8)
示例(LLaMA-3-70B, FP16):
L=80, H_kv=8, D=128, S=8192, B=16
KV Cache = 2 × 80 × 8 × 128 × 8192 × 16 × 2
= 4,294,967,296 bytes ≈ 4 GB ← 看起来不大?
不对!这是单层的!要乘 80 层:
≈ 320 GB ← 需要多张 A100
常见问题
Q: 为什么我设了 max-model-len=4096,但显存用超了?
A: vLLM 预分配 KV Cache 块,即使当前请求很短,也预留了最大长度空间
Q: gpu-memory-utilization 调高后有 OOM?
A: 模型权重 + KV Cache + 临时 buffer > 显存
尝试调低,或减少 max-model-len
Q: block-size 设多大最好?
A: 16 是最通用的选择。如果你的请求都非常⻓且统一,可以试试 32
与其他系统对比
实践与对比KV Cache 管理的好坏,直接决定了推理系统的吞吐和效率。
主流推理系统对比
| 特性 | vLLM | TensorRT-LLM | SGLang | TGI |
|---|---|---|---|---|
| KV Cache 分配 | 分页(PagedAttention) | 预分配连续块 | 分页 + RadixAttention | 预分配连续 |
| 内存碎片 | 极低(分页) | 中等(块内碎片) | 极低(分页) | 高 |
| 前缀共享 | APC(自动,按块 hash) | 需手动指定 | RadixAttention(基于树) | 不支持 |
| 写时复制 | ✓ | ✗ | ✓ | ✗ |
| 量化支持 | FP8/INT8 | FP8/INT4/INT8 | FP8 | FP8/INT8 |
| 调度策略 | 贪心批处理 | 静态批处理 | 贪心 + 预见 | 简单调度 |
vLLM vs TensorRT-LLM
TensorRT-LLM(NVIDIA 官方)的优势:
- 更激进的 kernel 优化(使用 NVIDIA 内部 API)
- 更丰富的量化支持(INT4、FP4)
- 与 NVIDIA 硬件深度绑定
vLLM 的优势:
- PagedAttention 的内存效率更高
- 社区更活跃,更新更快
- 更容易定制和扩展
- 开源许可证更友好
vLLM vs SGLang
SGLang 的 RadixAttention:
- 不是按固定 block 共享前缀
- 而是构建一个前缀树(Radix Tree)
- 可以共享任意长度的公共子串
- 理论上比 APC 更灵活
实际对比:
- 简单共享前缀场景:两者持平
- 复杂重复模式(多轮对话频繁切换):SGLang 略优
- 社区和生态:vLLM 更成熟
选择建议
选 vLLM 如果你:
- 需要高吞吐的 LLM API 服务
- 请求共享大量相同前缀
- 需要活跃的社区和丰富的文档
- 想快速部署和迭代
选 TensorRT-LLM 如果你:
- 对延迟有极致要求
- 使用 NVIDIA 最新硬件(H100/B200)
- 需要 INT4 等低精度量化
选 SGLang 如果你:
- prompt 模式复杂多变
- 多轮对话中有大量上下文切换
监控与调试
实践与对比"我的显存去哪了?"——监控 KV Cache 是定位推理性能问题的第一步。
vLLM 统计信息
vLLM 启动时会输出 KV Cache 的配置信息:
#INFO 04-28 10:00:00 cache_engine.py:42] GPU KV cache size: 65.54 GB
#INFO 04-28 10:00:00 cache_engine.py:47] Number of GPU blocks: 51400
#INFO 04-28 10:00:00 cache_engine.py:48] Number of CPU blocks: 10280
运行时通过 API 获取统计:
GET /metrics
→ vllm:num_requests_running # 当前运行请求数
→ vllm:num_requests_waiting # 等待请求数
→ vllm:gpu_cache_usage_percent # GPU KV Cache 使用率
→ vllm:cpu_cache_usage_percent # CPU 交换空间使用率
→ vllm:num_preemptions_total # 被抢占的请求数(越高说明内存越紧张)
关键指标解读
gpu_cache_usage_percent:
0-70% 健康,还有空间接受更多请求
70-90% 较高,建议监控
90-95% 紧张,可能很快触发 preemption
95%+ 高危,大量请求在等待或交换
num_preemptions_total:
0 完美,不需要抢占
<10 可接受
>10 频繁抢占 → 需要调整配置
>100 严重问题!降低 max_num_seqs 或增加 GPU
诊断内存问题
问题 1: 显存不足,但 gpu_cache_usage 很低
→ 可能是模型权重太大
→ 检查 max-model-len 是否设得太大
→ 检查是否开启了不必要的量化
问题 2: gpu_cache_usage 高但吞吐低
→ KV Cache 不够用,请求被频繁交换
→ 增加 GPU 显存或减少 max_num_seqs
→ 启用 KV Cache 量化
问题 3: 频繁 preemption
→ max_num_seqs 设得太高
→ 请求长度波动太大
→ 降低 max_num_seqs 或启用 APC
问题 4: 长序列 OOM
→ max-model-len 超出 GPU 能力
→ 启用 FP8 量化
→ 使用 TP 将模型分布到多卡
调试工具
# 查看 GPU 显存使用
nvidia-smi
# 查看每个进程的显存
nvidia-smi --query-compute-apps=pid,used_memory --format=csv
# vLLM 内部状态(通过 OpenAI API)
curl http://localhost:8000/v1/metrics | grep vllm
# 查看 Block Manager 状态
# 启动时添加 --log-requests
python -m vllm.entrypoints.openai.api_server \
--model ... \
--log-requests # 打印每个请求的 KV Cache 使用情况
调优检查清单
□ KV Cache 是否占用了 90% 以上的可用显存?
(检查 gpu_cache_usage_percent)
□ 是否有大量 preemption?
(检查 num_preemptions_total)
□ 是否开启了 Prefix Caching?
(如果请求共享前缀)
□ 是否使用了 FP8 KV Cache?
(减少显存占用最简单的方法)
□ max-model-len 是否合理?
(不要设得比实际需要大太多)
□ gpu-memory-utilization 是否合适?
(太大容易 OOM,太小浪费空间)