7AA7 博客

大模型推理加速:KV Cache、PagedAttention 与连续批处理

何遇 UID:112
2026-10-03 17:04
19 1 0

自回归解码在重复算什么

解码第 t 个 token 时,注意力要用到前 t 个位置全部的 K 和 V。朴素实现每个 step 把整个前缀重新过一遍网络,第 t 步的代价是 O(t),生成 n 个 token 的总代价是 O(n²)。更浪费的是,第 t 步真正新算出来的只有第 t 个位置的 q、k、v,前 t-1 个位置的中间结果和上一轮完全相同。

于是把历史的 K、V 存下来,每个 step 只算新 token 的 q/k/v,再把新 k/v 追加进缓存。这就是 KV Cache。注意力部分的单步代价从 O(t·d) 降到 O(d),生成总代价从 O(n²) 降到 O(n)。

代价是显存。每层的每个 KV 头都要为序列里每个 token 存两个向量:

KV Cache 字节数 = 2 × L × H_kv × d_head × S × B × P

L      : 层数
H_kv   : KV 头数(GQA 下小于 Query 头数)
d_head : 每个头的维度
S      : 序列长度
B      : batch 大小
P      : 每个元素的字节数(fp16 为 2,fp8 为 1)

代一组常见配置:L=32,H_kv=8,d_head=128,fp16 存储。每 token 的缓存量是 2 × 32 × 8 × 128 × 2 = 131072 字节,约 128 KB。单个 8k 上下文的请求就要约 1 GB。并发 32 条,光 KV Cache 就是 32 GB,和权重本身一个量级。

所以长上下文服务的显存瓶颈往往不在权重而在 KV Cache,也解释了为什么 GQA / MQA 这么受欢迎——把 H_kv 从 32 降到 8,缓存直接砍到四分之一。

预分配的连续显存会造成双重浪费

早期实现给每个请求按 max_seq_len 预留一整块连续显存。这里有两种碎片:

内部碎片:请求实际只生成 300 个 token,却占着 8192 的坑,剩下的 7892 个位置谁也用不了。

外部碎片:不同请求长度不一,回收后留下大小不一的空洞。来一个新请求需要连续 2048 的空间,显存里明明有 3000 空闲,但分散在五块里,分配失败。

这两者叠加,实际利用率常常只有一半左右。

PagedAttention 借的是操作系统虚拟内存那套思路:把 KV Cache 切成固定大小的 block(比如每个 block 装 16 个 token),按 block 分配,逻辑序列在物理上不必连续,用一张 block table 做映射。

逻辑序列: [blk 7][blk 12][blk 3]   ← 物理上散落
block table: [7, 12, 3, ...]

内部碎片从”整条预留长度”降到”平均半个 block”,几乎可以忽略;外部碎片彻底消失,因为任何空闲 block 都能被用上。

分页还顺手打开了两个优化。一是前缀共享:多条请求带同样的系统提示词时,那段 KV 只存一份,靠引用计数管理,写时复制。二是 beam search 场景下不同候选共享公共前缀,也是同一套机制。

prefill 和 decode 卡在不同的地方

一个请求分两个阶段,瓶颈完全不同。

prefill 阶段一次性处理整个 prompt,token 之间可以完全并行,计算形态是大矩阵乘矩阵(GEMM),算术强度高。这一阶段是算力受限的,GPU 利用率能跑得很满,耗时大致正比于 prompt 长度。

decode 阶段每步只产出一个 token,计算形态退化成矩阵乘向量(GEMV)。每生成一个 token,都要把全部模型权重和整个 KV Cache 从显存读一遍,却只做极少浮点运算。算术强度极低,是带宽受限的。GPU 的算力大部分在空转等数据。

用 roofline 模型的语言说:prefill 落在算力屋顶下方,decode 撞在带宽屋顶上。

这个差异有个直接推论——decode 阶段增大 batch 几乎是免费的。因为瓶颈在读权重,batch 从 1 加到 32,权重读取次数不变,多出来的计算量相对很小。于是单请求延迟几乎不变,吞吐却能涨一个数量级。这就是为什么”把 batch 做大”是 decode 阶段最有效的手段,也是 MQA/GQA 有效的原因:减少 KV 头数直接减少每步要读的字节数。

静态批处理的空转

经典做法是把请求攒成一个固定 batch 一起跑,跑完一起返回。问题是请求的输出长度差异极大:有的 20 个 token 就结束,有的要 800 个。batch 里最快的那条跑完后,它的槽位一直空着,GPU 却还在为它保留显存并按 batch=8 的规模做计算,直到最慢那条结束。

实际观察到的浪费比例取决于长度分布——长度越不均匀,空转越严重。长尾输出特别多的场景,静态批处理能把有效吞吐砍掉一半以上。

连续批处理改的是调度粒度

连续批处理(continuous batching,也叫 inflight batching)把调度单位从”请求”降到”单次迭代”。每个 decode step 都重新组装一次 batch:

step t   : [A][B][C][D]    ← D 生成完毕,立即退出,显存归还
step t+1 : [A][B][C][E]    ← E 刚到,prefill 后立刻加入
step t+2 : [A][B][C][E][F]

请求完成即退出,新请求到达即加入,槽位不空转。吞吐提升幅度取决于输出长度的方差,长度越参差收益越大。

这要求 KV Cache 能被动态分配和回收,所以连续批处理和分页管理基本是绑定出现的——没有分页,槽位无法按需增长和快速释放。

吞吐和首 token 延迟是此消彼长的

把 batch 做大能提吞吐,但会伤首 token 延迟(TTFT)。原因是 prefill 是算力密集的:新请求进来要做一次完整的 prefill,如果 batch 里已经有很多 decode 在跑,这次 prefill 要么排队等,要么插进去把这一步的时间拉长。batch 越大,抢占越明显。

具体表现为两个指标的拉扯:

  • 大 batch → 吞吐高,但新请求排队久,TTFT 上升
  • 小 batch → TTFT 低,但 GPU 在 decode 阶段吃不饱,吞吐低

还有个隐性冲突:一次长 prompt 的 prefill 独占一个 step,会让它同批的所有 decode 请求都停顿——decode 本来每步只要十几毫秒,插进一次几百毫秒的 prefill,用户感知到的输出就”卡了一下”。

几种缓解思路:

chunked prefill 把长 prompt 切成若干块,每块和 decode 混在同一个 step 里跑。单步时间变得平稳,decode 不再被长尾停顿打断,代价是 prefill 自身的总耗时略有上升。

按 SLO 分池:交互类请求走低 batch、低延迟的队列,离线批处理走高 batch、高吞吐的队列。两者不争同一块 GPU。

prefill / decode 分离部署:两个阶段用不同的并行策略和硬件池,各自调优。适合规模化部署,代价是 KV Cache 要在两者之间传输。

投机解码换的是另一件事

前面几招都是在”同样的计算,少做一点或者做得更满”。投机解码(speculative decoding)换的是并行度:decode 阶段每步只能产出一个 token,是因为自回归依赖——第 t+1 步必须等第 t 步的结果。

做法是用一个远小于主模型的 draft 模型连续生成 γ 个候选 token,然后把这 γ 个候选和前缀一起交给主模型做一次前向,并行验证每个位置。主模型在每个位置都会给出自己的预测,接受与 draft 一致的前缀,从第一个不一致的位置开始重新采样。

收益来自把 γ 次串行前向压成 1 次并行前向。接受率越高、γ 越大,加速越明显;draft 模型越准,接受率越高。代价是每次验证都要多跑一个 draft 模型,且要额外维护一份 draft 的 KV Cache,显存有增加。当任务本身输出高度可预测(代码补全、格式化输出)时接受率很高,效果最好;输出高度随机时收益会被摊薄。

它和前面几招不冲突,可以叠加:连续批处理负责填满 GPU,投机解码负责压缩单请求的串行步数。

实测时容易忽略的几件事

测吞吐要区分”总 token 吞吐”和”单请求延迟”。前者好看不代表后者可用,反过来也一样。报数时把并发数、输入输出长度分布一起报出来,否则数字没有意义。

KV Cache 的命中率对长对话业务影响巨大。多轮对话里,历史轮次的 KV 如果被回收,下一轮要重新 prefill,延迟直接翻几倍。所以会话级的前缀缓存策略有时比算法优化更管用。

量化要分清对象。权重量化省的是加载带宽,KV Cache 量化省的是显存和每步读取量,两者收益场景不同。KV 量化到 fp8 通常损失很小,因为注意力对 K/V 的精度不如权重敏感。

546浏览量
19赞
1评论
1参与用户
10 分钟阅读时间

评论(1)

  • 硅屿 · 2026-10-03 19:23

    已经很晚了,看到这篇又精神了。

    回复