KV Cache 优化:从分页、压缩到系统级调度
大模型推理的成本结构正在发生一个隐蔽的转移。当上下文从 4K 走向 128K、从单轮对话走向多轮 Agent 会话,模型权重的显存占用逐渐变成一个固定成本,而 KV Cache 才是那个随负载动态膨胀、直接决定并发上限和单位 token 成本的变量。一个 128K 上下文的请求,其 KV Cache 可以轻松超过模型权重本身的数倍。围绕这个变量的优化,已经从早期的“工程技巧”演化为一个分层明确的技术栈:空间上消除碎片,表示上压缩位宽与头数,时间上让分页的价值在连续批处理中兑现,位置上把缓存从单卡扩展到跨实例的池化资源。
本文按这一逻辑展开,重点讨论每一层在解决什么约束、收益的量级,以及当前生产或前沿方案中被验证和未被验证的部分。
一、空间层:PagedAttention 消除碎片,但不消除总量
朴素 KV Cache 的核心问题不是“缓存太大”,而是“缓存分配得太笨”。传统做法为每个请求按最大可能长度预分配一块连续显存。在输出长度高度不可预测的 LLM 推理中,这造成两类浪费:内部碎片——预分配 2048 token 实际只生成 200 token,90% 的显存空转;外部碎片——不同请求的连续块之间产生无法利用的缝隙,整体显存利用率长期停留在 20%–40%。
PagedAttention 的解法直接翻译自操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block(通常 16、32 或 64 个 token 一组),每个请求维护一张 block table,将逻辑 token 位置映射到物理 block ID。block 在物理内存中无需连续,按需分配、动态增长。这一设计将显存利用率从 40% 量级推升至接近饱和,vLLM 的实测数据是 96%。
实现上的关键数据结构是 batch 级的三个 int32 张量:kv_indptr 记录每个请求的 page 前缀和,kv_indices 是扁平的物理 page ID 列表,kv_last_page_len 标记最后一个 page 的填充程度。paged attention kernel 通过这些间接寻址完成注意力计算,而不要求 KV 在逻辑上连续。一个简化的 block table 构建逻辑如下:
# 每个请求维护逻辑→物理的 block 映射
block_table = [] # shape: [num_requests, max_blocks_per_req]
block_pool = BlockAllocator(total_blocks, block_size)
for req in batch:
blocks = block_pool.allocate(req.num_tokens)
block_table.append(blocks) # 物理 block ID 列表
# kernel 侧:通过 block_table 间接寻址
for logical_pos in range(seq_len):
phys_block = block_table[req_id][logical_pos // block_size]
offset = logical_pos % block_size
k = K_pool[phys_block, offset] # 非连续物理地址
Block 大小是一个工程权衡:较小的 block 减少尾块浪费、支持更细粒度的 prefix 共享,但增加 page table 更新频率和 kernel 间接寻址开销;较大的 block 减少元数据开销,但尾块浪费更明显。16 是常见默认值,Hopper 及之后的架构上则倾向于 64 以上以获得 TMA 友好性。
但 PagedAttention 解决的是碎片问题,不是总量问题。当每个请求的实际 KV 占用本身就在线性增长时,分页只能让显存“用得更满”,不能让它“用得更多”。
二、表示层:减少每 token 的字节数
如果分页是把仓库整理得更紧凑,表示层优化则是直接缩小每个货物的体积。两条主线并行:一条在模型结构上减少需要缓存的 KV 头数,另一条在数值格式上降低每个元素的位宽。
GQA 与 MLA:结构性的缓存缩减。 标准多头注意力(MHA)为每个 Query 头配备独立的 K/V 头,KV Cache 大小与 n_heads 成正比。分组查询注意力(GQA)让一组 Query 头共享同一组 K/V 头,KV Cache 的大小因此降为原来的 n_kv_heads / n_heads。以 32 个 Q 头、8 个 KV 头的配置为例,缓存直接缩减到四分之一,70B 模型上对应数十 GB 的节省。MQA 是 GQA 的极端特例(n_kv_heads = 1),推理速度更快但质量损失更大;GQA 在两者之间取平衡,已成为 Qwen2 等现代模型的标配。
GQA 在实现上的核心操作是 repeat_kv:在注意力计算前将 KV 头沿 head 维度复制 n_rep = n_heads / n_kv_heads 次,以与 Query 头数对齐。这一复制是逻辑上的,实际存储的仍然是压缩后的 n_kv_heads 个头:
def repeat_kv(x, n_rep):
# x: [batch, seq_len, n_kv_heads, head_dim]
if n_rep == 1:
return x
return (x[:, :, :, None, :]
.expand(*x.shape[:3], n_rep, x.shape[-1])
.reshape(*x.shape[:3], x.shape[2] * n_rep, x.shape[-1]))
MLA(Multi-head Latent Attention,DeepSeek-V2/V3 采用)走得更远:它将 K 和 V 联合压缩为一个低秩 latent,进一步压缩了缓存体积,但也引入了新的执行路径复杂性——latent 的 MQA 吸收路径和头展开路径在计算强度与缓存分片方式上不同,对硬件 compute-to-bandwidth 比敏感。
FP8 量化:位宽减半的实际收益与边界。 FP8(e4m3)KV Cache 量化已经是 vLLM 生产环境的标准配置。它将每 token 的缓存成本降至 BF16 的约 54%,在 Llama-3.1-8B 上带来约 14.9% 的输出吞吐提升和中位 ITL 降低,同时在 128K 长上下文上恢复 97%–98% 的 baseline AUC。--kv-cache-dtype fp8 启用后,不仅存储是 FP8,QK 和 ScoreV 的整个注意力计算也在 FP8 中执行。
但 FP8 的精度收益并非无条件。vLLM 在 stress test 中发现了一个关键陷阱:Hopper 上 FP8 Flash Attention 3 kernel 在 128K needle-in-a-haystack 任务中,精度从 BF16 的 91% 骤降至 13%,根因是 Tensor Core 的 FP32 累加精度不足。修复方案是 two-level accumulation,修复后长上下文精度恢复到接近 baseline,同时保留 decode 速度优势。此外,对于 head_dim=256 的模型,FP8 的 prefill 性能目前仍略逊于 BF16;对于带小 sliding-window 层的混合注意力模型,更优的做法是跳过这些层的量化。INT4 KV 量化能将缓存再压缩到约 25%,但目前在推理质量上仍处于研究阶段,uniform INT4/FP8 在复杂推理任务上已经表现出精度退化,不能直接视为 FP8 的下一站。
三、时间层:分页的价值由调度兑现
PagedAttention 本身不产生吞吐提升。它只是把 KV Cache 的管理粒度从“请求级连续块”细化为“页级离散块”。真正让这些页“流动”起来的是 Continuous Batching。
静态批处理要求一批请求同时开始、等长输出、等齐到达,这在 LLM 推理中意味着三重浪费:短输出请求为长输出请求 padding 算力,早到请求为凑批等待,GPU 在 batch 边界空转。Continuous Batching 把调度粒度从“整个 batch”降到“每个 decode iteration”:每一步结束后,完成的请求立即释放其 KV 页并返回,新到达的请求(无论是 prefill 还是 decode)即刻加入当前活跃集合。PagedAttention 的非连续页管理让这种动态进出成为可能——新请求只需分配若干空闲页写入 block table,不需要重排或搬迁已有请求的 KV。
华为云的一篇工程分析给出了两者的叠加效果:吞吐从 120 token/s 提升至 2400 token/s(20 倍),显存利用率从 40% 到 96%,P99 延迟反而下降 35%。这个数字不能单独归因于分页或调度,而是“空间约束被解除后,调度器终于可以把 GPU 喂饱”的联合结果。
四、位置层:当单卡放不下时,缓存需要新的拓扑
Agentic 工作负载正在让“KV Cache 是单实例本地资源”这一假设失效。Codex/SWE-bench Pro 的 trace 分析显示,多轮 Agent 会话的上下文在第 30 轮可增长到约 80K token,最长超过 180K,但每轮新增 token 通常只有几百到几千;输入输出比约为 131:1,94.2% 的输入 token 命中可缓存前缀。这意味着绝大多数 prefill 计算是在重复处理已经见过的前缀。
本地 KV offload 到 CPU DRAM 可以缓解容量压力,但无法解决跨实例的缓存失效:当 router 将会话下一轮调度到另一个 vLLM 实例时,该实例从未见过前缀,只能从头重算。vLLM 与 Mooncake 的集成将 KV Cache 放入分布式池,在 agentic trace 上实现了 3.8 倍吞吐、46 倍 TTFT 降低和 8.6 倍端到端延迟降低,cache hit rate 从 1.7% 提升至 92.2%。动态 prefix-tree 路由在跨区域部署中也将全局 KV cache 命中率从 6.4% 拉升至 82.3%。
这一层的本质变化是:KV Cache 从“推理引擎的内部状态”变成了“跨实例可路由、可寻址的共享数据资产”。prefill-decode 分离进一步强化了这一趋势——compute-bound 的 prefill 和 memory-bound 的 decode 被拆分到不同节点池,KV Cache 必须经过网络迁移才能让 decode 节点继续生成,于是 KV 传输带宽和压缩(如 SplitZip 的无损压缩)成为新的瓶颈变量。
五、一个尚未闭合的张力:块级管理与 token 级回收
PagedAttention 的分页粒度是 block(16–128 token),但近期 KV 驱逐算法(H2O、SnapKV、StreamingLLM 等)的决策粒度是 token。当一个 block 内只有部分 token 被判定为“可驱逐”时,整个 block 仍然被占用,已死的 token 无法释放其物理页——这被称为 intra-block fragmentation。vToken 通过 token-level virtualization 在逻辑 token 视图和物理 block 之间加一层间接,异步重排活跃 token 以回收页,在 vLLM 上实现每请求保留 block 数减少 27.2%–72.3%,SLA 约束吞吐最高提升 1.37 倍。PagedEviction 则选择适配 paged 布局的 block-wise 驱逐策略,不修改 CUDA attention kernel 即可集成。这一方向的共同信号是:分页架构的下一轮优化不再发生在 block 内部,而是发生在 block 管理与上层驱逐策略的接缝处。
小结
KV Cache 优化的技术栈已经形成了清晰的分工:PagedAttention 解决“空间怎么放”,GQA/MLA 和 FP8 解决“每 token 存多少”,Continuous Batching 解决“时间上怎么流动”,分布式 KV 池解决“放不下时去哪”。四层之间存在依赖关系——没有分页的非连续管理,动态批处理无法高效释放和复用显存;没有连续批处理,分页释放的页也无处可去。而当前的前沿张力集中在粒度对齐和跨节点拓扑上:block 级分页与 token 级驱逐之间的缝隙,以及单实例缓存与多实例 Agent 负载之间的错配,是接下来一段时间最值得关注的工程切口。
- 点赞
- 收藏
- 关注作者
评论(0)