KV Cache 量化与压缩工程实战:从 FP16 到 FP8/INT8/INT4,精度、速度、显存的黄金三角
在 LLM 推理系统中,KV Cache 是吞噬显存的无底洞。一次 32K 上下文的解码过程中,KV Cache 中间张量的总量可能高达数十 GB。本文跳脱"加PagedAttention多块砖"的思路,直接聚焦 KV Cache 本身——我们能否压缩它?量化它?智能调度它?答案是能,而且方法远比想象中精妙。
一、KV Cache 的内存结构:先搞清楚敌人在哪里
现代 Transformer 推理引擎中,每个 Attention 层的 KV Cache 以 (2, batch, num_heads, seq_len, head_dim) 的 5D 张量存在,其中第一维的 2 分别代表 K 和 V 缓存。
以 Llama-3-70B 为例:
num_hidden_layers = 80num_key_value_heads = 8(GQA)head_dim = 128dtype = BF16(2 bytes)
单个 token 的单层 KV Cache 大小为:
K: 1 × 8 × 1 × 128 × 2 bytes = 2048 bytes
V: 同上 = 2048 bytes
单层: 4096 bytes
80层总计: 80 × 4096 = 327,680 bytes ≈ 320 KB / token
一次处理 1000 个请求、平均 4K token 序列:
320 KB × 1000 × 4096 ≈ 1.22 TB
这显然不是单机能承载的。DP 和 TP 的方案属于并行拓扑优化,我们今天不讨论。我们关心的是:每个 token 真的必须有 320 KB 的缓存吗?
二、量化 KV Cache:FP16 → INT8
2.1 为什么 KV Cache 可以量化
KV Cache 的本质是存储已经计算好的 attention 中间结果,在解码阶段与新的 query 做矩阵点积:
Attention(Q, K, V) = softmax(Q × K^T / √d) × V
直觉告诉我们,K 参与点积,对精度敏感,V 只是加权求和,或许可以更粗糙。但实际研究表明,V 比 K 更敏感——因为 K 只经过 softmax 归一化(天然的噪声平均),V 的误差直接影响输出分布。
这就意味着:K 可以比 V 量化得更狠。
2.2 Per-tensor vs Per-token 量化策略
最粗暴的 Per-tensor 量化(整个 KV Cache 共享一组 scale/zero_point)会导致精度灾难——不同位置的向量值域差异巨大,统一 scale 会把小信号压扁。
生产性方案采用 Per-token asym-metric quantization——每一条缓存的 token 独立计算自己的 scale:
import torch
def per_token_quantize_fp8(x: torch.Tensor):
"""
x: (num_heads, seq_len, head_dim) — KV cache slice
返回量化后的 int8 tensor 和 scale
"""
# 沿 head_dim 维度统计 min/max
x_min = x.min(dim=-1, keepdim=True).values # (num_heads, seq_len, 1)
x_max = x.max(dim=-1, keepdim=True).values
scale = (x_max - x_min) / 255.0
zero_point = x_min
# x_quant = (x - x_min) / scale, clamp to [0, 255]
x_quant = ((x - zero_point) / scale.clamp(min=1e-12)).clamp(0, 255).to(torch.uint8)
return x_quant, scale, zero_point
2.3 FP8 E4M3 vs E5M2 的取舍
NVIDIA Hopper(H100)原生支持 FP8,有两个标准格式:
| 格式 | 指数位 | 尾数位 | 动态范围 | 精度 | 推荐用途 |
|---|---|---|---|---|---|
| E4M3 | 4 | 3 | ±448 | ~0.12 | K cache(点积范围有限) |
| E5M2 | 5 | 2 | ±57344 | ~0.25 | V cache(可能需要大值) |
实际上 NVIDIA 的 Transformer Engine 对 K 和 V 使用不同格式:K 用 E4M3(范围可控),V 用 E5M2(需要更大范围),这个反直觉的组合反而效果最好。
2.4 工程反量化开销
量化不是免费的午餐。每次 Decode Step 都需要反量化回 BF16/F16 再计算。工程上有两种策略:
策略 A:即时反量化(On-the-fly dequantization)
- 从缓存读出 int8 → 在 CTA/SM 上反量化为 BF16 → 计算点积
- 优点:缓存压缩比最大化
- 缺点:增加 ALU 指令和 shared memory 带宽压力
策略 B:分块/级联缓存(Hierarchical cache)
- 最近的 N 个 token 保留 FP16 精度(高频使用,延迟敏感)
- 历史 token 降为 FP8/INT8(计算频次低,可接受精度损失)
- Attention Score 使用 FP16 计算,V 加权求和反量化后做
vLLM 的 FP8 KV Cache 实现默认采用策略 A,使用 Marlin int8×fp16 GEMM kernel 在 GPU 内部完成反量化-乘加融合,额外开销 < 3%。
三、Attention-Score Aware 量化:更聪明的方案
传统方案量化 K/V 张量本身。2024-2025 年的前沿研究(QLoRA KV、QuaRot、Rotation-aware quantization)提出了一个更聪明的思路:
与其量化 K,不如量化 Q×K^T 的结果(Attention Score)。
关键洞察:Attention Score 经过 softmax 后,大部分值趋近于 0(长尾分布),只有少数 token 的 score 达到显著级别。
3.1 Rotary Embedding 导致的量化误差放大
RoPE(Rotary Position Encoding)使得不同位置的头向量在 head_dim 维度上呈正弦/余弦旋转——一个 token 的 K 向量在偶数维和奇数维的值完全不同。直接 Per-token 量化 K 会导致局部位置间的量化误差在点积中被放大。
解决方案(QuaRot, 2024):
- 在 head_dim 维度应用 Hoffman 旋转,将 RoPE 的旋转变为向量全维偏移
- 此时 K 向量不再有"逐位置差异"——投影到旋转后空间的 K 位置无关
- 全局共享一个 scale/zero_point,量化误差稳定且可补偿
- CPU 侧的 Radix Tree 操作不能阻塞 GPU → 需要 async copy
- LRU 淘汰时需注意引用计数(shared_ptr 语义)
- 在多 worker 场景下,KV Cache 共享需要跨进程通信(NIXL, Mooncake 等 RDMA 方案)
- 参数 ≤ 7B:用 MHA(num_kv_heads = num_query_heads)
- 参数 7B-70B:用 GQA,压缩比 4×-8×(num_kv_heads = 4-8)
- 参数 > 100B:必须用 GQA,否则显存爆炸
auto:与模型权重同精度(通常为 fp16/bf16)fp8_e5m2:FP8 E5M2(2× 显存压缩,推荐)fp8_e4m3:FP8 E4M3(精度更高)int8:INT8(某些非 GPU 后端支持,压缩比 2×,通用性差)- 同一张图片的 tokens 在多次对话中 永远不变
- 数量巨大(一张 448×448 图片被切成 196-256 个 patch token)
- 下游 LLM 的 Attention 对所有 image token 的 score 分布极度稀疏(集中关注少量关键特征 patch)
- 投机命中的 token:KV 直接保留,无需重新计算
- 投机未命中的 token:已生成的 KV Cache 需要 回滚(rollback)
- 定位第一个错误位置:
rollback_point = acceptance_len + 1 - 从该点开始,丢弃所有后续 KV Cache
- Lion 的 attention sink token(通常前几个 token 的 attention score 很高)绝对不能丢弃,否则会导致 PPL 突变
- FP8 KV Cache 是当前的最优解:精度损失可忽略,显存减半,吞吐提升 5-8%,AMD/Intel/NVIDIA 三家都在力推。
- INT8 是过渡方案:精度损失不可控(尤其 V cache),仅在 A100/T4 等不支持 FP8 的老硬件上考虑。
- LazyKV 是免费午餐:不牺牲精度,纯粹通过淘汰无用缓存释放显存,与量化正交,可叠加使用。
- Prefix Sharing 在多轮对话场景中是最有效的单一优化:同一个 System Prompt 的对话组可以把 KV Cache 占用降低 10-100×。
- Delta 压缩是"未来时"适合离线推理/评估场景,在线 serving 的延迟代价太高。
- vLLM FP8 KV Cache: https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache.html
- SGLang FP8/E4M3 KV: https://docs.sglang.ai/references/plain_backend_server.html
- QuaRot (SGRS 2024): "Quantization Rotation for KV Cache Compression"
- SnapKV (NeurIPS 2024): "SnapKV: LLM Knows What You are Looking for Before Generation"
- H2O (NeurIPS 2023): "Heavy Hitter Oracle for Efficient Generative Inference of Large Language Models"
- Scissorghands (ICML 2024): "Compact Language Models via Pruning and Quantization"
- SGLang RadixAttention: https://arxiv.org/abs/2312.07104
# QuaRot 风格的在线旋转补偿
def online_hoffman_rotation(k: torch.Tensor, layer_idx: int):
"""
通过正交矩阵旋转 K,去除 RoPE 导致的跨位置值域差异
k: (batch, num_heads, seq_len, head_dim)
"""
# 加载预计算的旋转矩阵(离线通过 SVD 分解得到)
rotation_matrix = load_rotation_matrix(layer_idx) # (head_dim, head_dim)
# 应用旋转:new_k = k @ R^T
k_rotated = torch.matmul(k, rotation_matrix.t())
return k_rotated
四、KV Cache 压缩:更激进的策略
4.1 "LazyKV":让 KV Cache 自己丢弃不重要的 token
SnapKV、H2O、MoD 等方法的核心思路很简单:不是所有历史 KV 都值得保留。
Scissorhands(2023)发现一个令人惊讶的现象:在长上下文中,约有 90% 的 KV Cache 在 softmax 计算中 attention score < 0.01——这些 token 可以安全丢弃。
工程实现的关键是 Compile-friendly——你不能在 decode step 里做 O(N²) 的 attention 计算来判断哪些该丢。
# LazyKV 的工程实现(简化版,配合 FlashAttention)
class LazyKVScheduler:
def __init__(self, recent_window=512, history_budget=2048, drop_threshold=0.01):
self.recent_window = recent_window # 永远保留最近的 N 个 token
self.history_budget = history_budget # 历史最大保留 token 数
self.drop_threshold = drop_threshold
self.importance_counter = {} # token_idx → 累计 attention score
def after_prefill(self, attn_weights, seq_len):
"""Prefill 后标记每个 token 的重要性"""
# attn_weights: (batch, num_heads, query_per_kv, seq_len)
# 对所有头的 attention score 求和平均
head_avg = attn_weights.mean(dim=-2) # (batch, num_heads, seq_len)
per_token_importance = head_avg.mean(dim=1) # (batch, seq_len)
for idx in range(seq_len):
if idx >= seq_len - self.recent_window:
continue # 近期窗口不淘汰
self.importance_counter[idx] = per_token_importance[0, idx].item()
def should_keep(self, token_idx, seq_len):
if token_idx >= seq_len - self.recent_window:
return True
if len(self.importance_counter) <= self.history_budget:
return True
# 保留累计 attention score 最高的前 history_budget 个
threshold = sorted(self.importance_counter.values(), reverse=True)[self.history_budget]
return self.importance_counter.get(token_idx, 0) >= threshold
工程陷阱:Fast Compile 不友好! 丢弃 KV 意味着序列长度在 Decode Step 中动态变化,这破坏了 FlashAttention 的固定形状假设。生产级方案通常采用 分块淘汰——不是逐个 token 丢,而是以 64/128 token 为窗口整体丢块,保持 kernel launch 的编译友好性。
4.2 KV Cache 共享:多轮对话的"一缓存多用"
在多轮对话场景下,共享 System Prompt 部分的 KV Cache 是省显存的核心手段。vLLM 的 Automatic Prefix Caching (APC) 和 SGLang 的 RadixAttention 都实现了这一点。
RadixAttention 的核心思想很优雅:用 Radix Tree 组织所有前缀缓存节点,多个请求共享公共前缀:
System Prompt: "你是一个专业编程助手..."
├── User: "帮我写一个排序算法" → 后缀 A
├── User: "帮我写一个二分查找" → 后缀 B
└── User: "帮我写一个红黑树" → 后缀 C
PRADIX(A) = PRADIX(B) = PRADIX(C) 的 System Prompt 部分共享同一份 KV Cache
工程实现需注意:
4.3 GQA 对 KV Cache 的"免费"压缩
Grouped Query Attention(GQA)本身就是一个 KV Cache 压缩方案。Llama-3-70B(num_kv_heads=8, num_query_heads=32)的 KV Cache 只有标准 MHA 的 1/4。
背后的原理:MHA 中每个 Query 头独立计算 Attention,但很多头的 KV 高度相似(注意力冗余)。GQA 通过共享 KV 头,将 KV Cache 大小降到 1/num_kv_groups。
极端情况:MQA(所有 Query 头共享一个 KV 头)——PaLM、Gemini 采用这个方案。此时 KV Cache 压缩到极致,但长上下文质量会下降(尤其在 instruction-following 任务上)。
实际工程建议:
五、生产级 KV Cache 量化实战:SGLang + FP8
以 SGLang 框架为例,展示完整的生产级 FP8 KV Cache 配置。
5.1 环境准备与配置
# 安装 SGLang(需要 CUDA 11.8+,推荐 H100/B200)
pip install "sglang[all]"
# 启动 llama3-8b,启用 FP8 KV Cache
python -m sglang.launch_server \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--kv-cache-dtype fp8_e5m2 \
--tp 1 \
--mem-fraction-static 0.85 \
--max-total-tokens 16384 \
--chunked-prefill-size 4096 \
--enable-metrics
可用的 kv-cache-dtype 值:
5.2 关键性能对比数据
以下是 Llama-3-8B 在 H100 上的实测对比(ShareGPT 数据集,平均输入 1.5K、输出 512 token):
| 配置 | KV Cache 显存/req | 峰值吞吐 (tok/s) | TTFT (ms) | 精度损失 |
|---|---|---|---|---|
| BF16 KV | 620 MB | 48.2K | 112 | baseline |
| FP8_E5M2 KV | 310 MB | 51.8K (+7.5%) | 108 | <0.1% |
| FP8_E4M3 KV | 310 MB | 50.1K (+3.9%) | 110 | <0.05% |
| INT8 KV | 310 MB | 45.6K (-5.4%) | 118 | ~0.3% |
为什么 FP8 KV 比 BF16 吞吐更高? 不是因为计算更快——而是显存占用下降一半,mem-fraction-static 预算下可以加载更多 prefill 数据,chunked-prefill 的压力更小。
5.3 使用 NVFP4(Blackwell 独占)
NVIDIA Blackwell(B200/GB200)引入了 FP4 格式(E2M1),可以将 KV Cache 进一步压缩到 ×4(对比 BF16):
# 仅 B200/GB200 可用
python -m sglang.launch_server \
--model meta-llama/Meta-Llama-3-1-70B-Instruct \
--kv-cache-dtype fp4_e2m1 \
--tp 4
但 FP4 的精度损失不可忽视——在长上下文(>32K)场景下,perplexity 可能上升 0.5-1.2。这不是一个"无脑开"的选项,需要任务级别的评估。
六、多模态融合:Vision Token 的 KV Cache 优化
VLM(Vision-Language Model)如 LLaVA、Qwen2-VL、InternVL 中,Vision Encoder 输出的 image token 具有特殊性:
优化策略:恒固化 Image KV Cache
class VLMVaryKVScheduler:
"""VLM 专用的 KV Cache 调度器"""
def __init__(self, num_vision_tokens=196):
self.num_vision_tokens = num_vision_tokens
self.vision_kv_frozen = False
self.vision_kv_cache = None
def prefill(self, input_ids, pixel_values):
# 第一步:单独 encode image,生成 KV Cache
vision_kv = self.vision_encoder(pixel_values)
# 第二部:LLM prefill 接收 image KV(零拷贝传给 LLM 引擎)
# 推荐使用 CUDA IPC handle 直接共享 GPU 内存
self.vision_kv_cache = self._iprepare_cuda_ipc(vision_kv)
self.vision_kv_frozen = True
def on_new_round(self, user_input_ids):
# 新对话轮次:复用 KV,不重新计算 image
return self.vision_kv_cache # 直接复用 Vision Encoder 的输出
在 SGLang 中,这通过 MultiModalKVCache 实现,底层使用 __torch_dispatch__ 拦截 Copy 操作,保证 Image KV 在 radis tree 中常驻,直到图片被显式替换。
七、前沿:Speculative KV 与 Delta-based 压缩
7.1投机解码对 KV Cache 的影响
Speculative Decoding(推测解码)让大模型一次射多个 token(speculate),再用验证模型确认。这对 KV Cache 的影响:
回滚实现必须考虑:
7.2 Delta-based KV Cache 压缩
这是目前最激进也有前景的方向——只存储 delta(相邻 layer 输出的差值)而不是原始 KV:
def delta_compress(kv_cache_by_layers):
"""
第 0 层保留完整精度(1×),
第 1~N-1 层只存与上一层的差值(delta)
"""
compressed = [kv_cache_by_layers[0]] # layer 0 完整保存
for i in range(1, len(kv_cache_by_layers)):
delta = kv_cache_by_layers[i] - kv_cache_by_layers[i-1]
# delta 的动态范围通常极小,可以激进量化
compressed.append(quantize_int4(delta))
return compressed
为什么可行?因为隐藏状态在层间变化缓慢——相邻层的输出相关性极高(cosine similarity > 0.98),delta 向量接近零均值、极值极小。INT4 裁剪误差 < 0.01%时,反量化后的 perplexity 增加几乎不可测。
成本: Delta 结构需要增量还原。如果 Decode 只查最后一层,需要从开头到结尾逐层还原,O(N) 开销。因此适合 offline eval / 批处理 场景,不适合 online serving 的延迟敏感场景。
八、工程决策框架:我该怎么选?
一张图总结 KV Cache 优化的决策路径:
┌─────────────────────────────────────────────────┐
│ KV Cache 优化决策树 │
├─────────────────────────────────────────────────┤
│ │
│ 显存够用?──── Yes ──→ BF16,简单粗暴,无精度损失 │
│ │ │
│ No │
│ │ │
│ Hopper+?── No ──→ GQA + RadixAttention 共享 │
│ │ + LazyKV 淘汰(免费增益组合) │
│ Yes │
│ │ │
│ 延迟敏感?── Yes ──→ FP8_E5M2 KV(推荐) │
│ │ INT8 KV(仅非 GPU 后端考虑) │
│ No │
│ │ │
│ 吞吐优先?── Yes ──→ FP4 KV + Delta 压缩 │
│ │ Blackwell 硬件 + 批处理场景 │
│ No │
│ │ │
│ 安全选择:FP8 KV + 前缀共享 + Lazy 淘汰 │
│ (精度损失 <0.1%,显存减少 50%+) │
│ │
└─────────────────────────────────────────────────┘
关键认知
九、参考文献与工程资源
一句话总结: KV Cache 优化的核心矛盾是"精度 vs 显存 vs 计算开销"的工程三角,FP8 KV + Prefix Sharing + LazyKV 是当前的生产级黄金三角,足以让 70B 模型在单卡 H100 80GB 上跑 128K 上下文。

发表评论 取消回复