大模型推理提速笔记:KV Cache、量化与连续批处理

推理优化很容易一上来就问「用哪个框架最快」,但更有效的顺序是先弄明白瓶颈在哪:是显存不够放权重,是 decode 阶段访存太慢,还是批处理把短请求拖住了。这篇记一套排查思路和对应的手段。
一、两个阶段,两种瓶颈
一次生成可以拆成:
- Prefill:把整段输入喂进去,算出所有 token 的注意力状态。这是计算密集型(compute-bound),并行度高,GPU 利用率通常不错。
- Decode:一个 token 一个 token 往外吐。每一步都要把此前所有层的 K、V 读一遍,计算量很小但访存量巨大,属于典型的带宽瓶颈(memory-bound)。
这解释了为什么「首 token 延迟」(TTFT)和「每 token 延迟」(TPOT)要分开看,也解释了为什么长上下文会明显拖慢生成速度——KV Cache 随序列长度线性增长,每步要读的数据越来越多。
二、KV Cache 的显存账
KV Cache 的占用可以按下面的公式估算:
bytes = 2 × layers × kv_heads × head_dim × seq_len × batch × bytes_per_elem
└ K 和 V 各一份 └ GQA 下不等于 attention heads
写成一个能直接改参数的小函数:
def kv_cache_bytes(layers, kv_heads, head_dim, seq_len, batch=1, dtype_bytes=2):
return 2 * layers * kv_heads * head_dim * seq_len * batch * dtype_bytes
# 示例参数:32 层 / 8 个 KV head / head_dim 128 / 4096 长度 / fp16
n = kv_cache_bytes(32, 8, 128, 4096)
print(n, 'bytes =', n / 1024**2, 'MiB') # 536870912 bytes = 512.0 MiB
注意这是单条请求的开销,并发 16 路就是 8 GiB,还没算权重。所以做容量规划时,并发数 × 最大上下文长度往往比模型参数量更早把显存吃满。
对应的优化手段:
| 手段 | 解决什么 | 代价 |
|---|---|---|
| GQA / MQA | 减少 KV head 数,直接压 Cache | 模型结构决定,推理侧改不了 |
| KV Cache 量化 | 把 fp16 压到 8bit / 4bit | 长文本下可能有质量损失 |
| 前缀缓存 | 复用共享 system prompt 的 KV | 需要按前缀组织请求 |
| 滑动窗口 / 淘汰 | 限制 Cache 无限增长 | 会丢失远处上下文 |
前缀缓存(prefix caching)是性价比最高的一项:如果大量请求共享同一段系统提示或同一份文档,把这段 KV 算一次存起来复用,首 token 延迟能显著下降。前提是路由层面尽量让同前缀的请求落在同一实例上。
三、权重量化:先分清量化了什么
「量化」这个词至少指三件不同的事:
- 权重量化:把模型参数从 fp16 压到 int8 / int4,主要省显存、也省一部分访存;
- 激活量化:连中间激活一起压,收益更大但更容易掉点;
- KV 量化:只压缓存,对长上下文和高并发最有效。
选型时别只看「几 bit」,还要看是否对敏感层做了混合精度保护、是否需要在校准集上跑一遍。一个务实的做法是:先用 weight-only 的低位量化把模型塞进单卡,跑一遍自己的评测集,确认质量可接受,再考虑更激进的方案。公开榜单上的困惑度数字和你的业务分布未必一致。
四、连续批处理与调度
静态批处理有个经典问题:一组请求里最长的那条没生成完,其余已完成的请求就得占着位置等,算力空转。
连续批处理(continuous batching / iteration-level scheduling)把调度粒度从「一批请求」降到「每一次 decode 步」:谁生成完了就立刻释放槽位,把排队的新请求填进来。同样的硬件下吞吐通常能提升一大截,尤其当请求长度差异很大时。
配置时值得调整的几个参数:
# 概念性配置(不同推理框架字段名不同)
max_num_seqs: 64 # 单实例最大并发序列
max_model_len: 8192 # 最大上下文,直接决定 KV 预留
gpu_memory_utilization: 0.90 # 留给 KV Cache 的显存比例
enable_prefix_caching: true
enable_chunked_prefill: true # 长 prefill 切片,避免阻塞 decode
max_model_len 建议按实际需要的 p95 长度设,而不是拍脑袋拉满——预留越多,能容纳的并发就越少。chunked_prefill 则用来避免一个超长输入把所有正在生成的请求一起卡住,对尾延迟很有帮助。
五、投机解码的思路
让一个小模型先草拟若干个 token,大模型一次并行验证,全对就整段接受,错了就从第一个不一致处回退。因为 decode 阶段的瓶颈是访存而不是算力,一次验证多个 token 相当于把「每步读一遍权重」摊薄了。它不改变输出分布(接受/拒绝的采样设计保证了这一点),但需要额外的草稿模型,且只在草稿命中率高时才有收益——代码生成、格式化输出这类可预测性强的场景最合适。
排查顺序小结
- 先分开记录 TTFT 与 TPOT,确认瓶颈在 prefill 还是 decode
- 按公式算一遍 KV Cache 显存,判断并发受限于容量还是带宽
- 打开前缀缓存,把共享 system prompt 的请求聚到一起
- 开连续批处理,按 p95 长度设上下文上限
- 量化前准备自己的评测集,量化后必须回归
- 投机解码只在草稿命中率可观测时启用
优化到最后会发现,大部分收益来自调度和数据组织,而不是换一个更玄的算子。先把指标埋全,再动手改配置。