KV Cache 优化:从分页、压缩到系统级调度

举报
yd_275638200 发表于 2026/11/05 01:05:16 2026/11/05
【摘要】 大模型推理的成本结构正在发生一个隐蔽的转移。当上下文从 4K 走向 128K、从单轮对话走向多轮 Agent 会话,模型权重的显存占用逐渐变成一个固定成本,而 KV Cache 才是那个随负载动态膨胀、直接决定并发上限和单位 token 成本的变量。一个 128K 上下文的请求,其 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 负载之间的错配,是接下来一段时间最值得关注的工程切口。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。