分页式 KV Cache:把推理服务写成内存分配器

从 PagedAttention 的块表、共享与连续批处理出发,理解高并发推理为何首先是一场 KV 显存管理问题。

展开文章目录

自回归推理每生成一个 token,都要读取历史 K/V。KV Cache 让历史投影不必重算,却把一次计算优化变成了持续增长的显存账本。服务端的矛盾因此很直接:权重是固定的,请求长度和并发却在变化。

连续分配为什么会浪费

如果系统在请求开始时按最大上下文预留一整段连续显存,短请求会留下大量空洞;如果按实际增长追加,又容易出现外部碎片,且多个请求无法自然共享前缀。传统分配器把“逻辑上下文”与“物理页”绑定得太紧。

对每个请求,逻辑 KV 大小为:

Breq=2×L×Hkv×d×b×TB_{\text{req}} = 2 \times L \times H_{\text{kv}} \times d \times b \times T

这里 TT 是已生成 token 数。并发为 NN 时,总量近似乘以 NN。在长上下文场景,这部分很容易达到数十 GiB。

块表如何工作

PagedAttention 借鉴虚拟内存:每个序列拥有一组逻辑块;块表把逻辑块映射到物理块。物理块可以不连续,也可以被多个序列共享,例如共享系统提示词或相同的 beam 前缀。

调度器需要同时维护:

状态含义
逻辑块上下文按固定 token 数切分后的编号
物理块GPU 上实际可写入的 KV 槽位
引用计数有多少序列正在使用该块
空闲池可分配的块集合

当序列结束,物理块引用计数下降,归零后回到空闲池。连续批处理可以在某个请求生成结束后立刻接纳新请求,而不必等待整个批次结束。

收益来自分布,不来自魔法

分页最有价值的场景是长度高度不一致、共享前缀较多、并发持续变化的服务。它把“预留但没用”的显存转成可复用块,提升同时活跃的序列数。相反,如果所有请求长度完全一致、无共享前缀,块表的主要贡献会退化为更细粒度的容量管理,收益取决于实现开销。

块越大,元数据越少,但尾部浪费越大;块越小,分配更紧,但块表与调度开销更高。这个权衡可以用 KV 容量账本 观察。

不能忽略的边界

分页解决碎片与共享,不解决注意力计算本身的 HBM 往返;它也不自动降低每 token 的 KV 字节数。GQA、量化、滑动窗口与 KV 驱逐可以与分页叠加,但每一种都会改变质量、兼容性和实现复杂度。

真正可靠的服务评估应同时报告上下文长度、并发、KV 精度、块大小、GPU 型号、请求长度分布和尾延迟。只看一个“吞吐提升倍数”,无法判断它是否能迁移到你的工作负载。

把块表算成一张预算

假设每个 token 的 KV Cache 为 128 KiB,块大小为 16 token,那么一个满块约占 2 MiB。8K 上下文需要约 512 个逻辑块;如果 16 个请求同时活跃,仅块内容就接近 16 GiB。这个计算没有把块表、引用计数、临时激活和分配器对齐算进去,因此真实容量只会更紧。

块表本身通常很小,但它改变了失败模式。系统不再只需要回答“还剩多少显存”,还要回答:空闲块是否连续、最大请求还需要多少块、正在进行的前缀共享是否会延长块的引用周期。一个看似还有几 GiB 空闲的实例,也可能因为缺少足够大的连续块或块池被长请求占满而拒绝新请求。分页真正带来的能力,是让这些问题从不可见的碎片变成可度量的计数。

可以用下面的近似量判断容量余量:

可用并发≈KV budget−reservebytes per token×context length\text{可用并发} \approx \frac{\text{KV budget} - \text{reserve}} {\text{bytes per token} \times \text{context length}}

其中 reserve 必须覆盖权重、激活、通信 buffer 和 allocator 元数据。把 reserve 当作零,是这个公式最常见的错误。对于不同长度的请求,应该使用长度分位数而不是平均值,因为一个超长请求就可能占掉大量块。

调度器决定收益上限

分页把分配单位变小,但如果没有连续批处理,收益会被批次同步抵消。一个批次里只要有一个长请求,其他短请求结束后空出的块也要等到整个批次推进才能高效复用。连续批处理允许完成序列先离开、新序列进入,配合块回收,才能让显存占用更贴近实际活跃工作量。

调度时至少需要看三个量:

观测量回答的问题常见误判
活跃块数当前真正占用了多少缓存只看模型权重占用
块池等待时间新请求为什么不能启动把排队误判为模型计算慢
每请求尾延迟长请求是否拖住整体目标只看平均 token/s

如果服务同时追求吞吐和交互体验,可以采用分层策略:交互请求限制最大上下文和单步预算,离线批处理允许更长上下文和更激进的块复用。PagedAttention 不应被当成一个全局开关;块大小、抢占和回收策略应该跟工作负载一起配置。

共享前缀不是免费的

系统提示词、few-shot 模板或固定文档前缀可以让多个请求共享物理块。共享前需要确认内容完全相同、位置编码和注意力 mask 一致,并且生命周期管理不会在其中一个请求修改块时污染其他请求。写时复制(copy-on-write)可以解决后续分叉,但也会在长前缀被修改时增加一次分配和复制成本。

共享收益可以用“避免读取的字节数 × 请求数”估算,但必须扣除块表查找、引用计数和可能的复制成本。对于前缀很短或每个请求都不同的场景,共享逻辑的控制开销可能大于收益。

一份可执行的上线检查表

  1. 用真实请求长度分布做容量规划,记录 P50、P95、P99;不要只用固定长度基准。
  2. 在目标框架中读取块大小、块池总量、峰值活跃块数和拒绝原因。
  3. 测试长请求、前缀共享和突发并发的组合,而不只是单请求吞吐。
  4. 对块大小做至少三档敏感性测试,同时记录吞吐、尾延迟和质量影响。
  5. 明确 OOM、块池耗尽和超时分别返回什么错误,以及客户端如何重试。
  6. 如果启用了 GQA、量化或滑动窗口,把每个变化对字节公式的影响单独说明。

最终可以把 PagedAttention 总结成一句话:它把 KV 显存从“某段连续内存”变成“可调度的资源账本”。这句话只有在调度、容量和尾延迟都被测量时,才是真正的工程结论。

评测协议与复现边界

复现这类优化时,最容易犯的错误是只跑一个“最大并发”或“固定长度”的基准,然后把结果写成通用加速比。建议把评测拆成四组:短请求主导、长请求主导、混合长度、共享前缀。每组都固定模型、精度、硬件、批上限和采样参数,并记录请求到达分布。否则,分页改善的究竟是碎片、批处理还是缓存命中,根本无法区分。

一个可比较的报告中至少要有以下字段:

字段为什么需要
P50/P95/P99 首 token 延迟判断尾延迟是否被长请求拖垮
输出 token/s 与请求/s区分解码速度和调度吞吐
活跃序列数与峰值块数说明容量是否真的被利用
拒绝率与失败原因识别块池耗尽和 OOM
质量基准确认量化和上下文策略没有伤害任务结果

如果论文报告了某类 GPU、某种精度和某个基准上的吞吐提升,我们的复现实验只能回答“在相同条件下能否观察到同方向的变化”,不能直接把倍数搬到另一张卡。尤其是不同 GPU 的 HBM 带宽、SM 数量和 kernel 版本会改变瓶颈位置。服务层优化与 kernel 优化应该分开测量:前者看块池、排队和并发,后者看单步执行时间和显存流量。

对小团队而言,最有价值的第一版不是实现一个完整的分页内核,而是先做一个块预算监控面板:记录每个请求的逻辑块数、共享块数、峰值块池、拒绝原因和尾延迟。只要这些问题能被稳定观测,后续无论是接入 vLLM 还是自研调度器,都能用数据而不是直觉判断优化是否成立。

还有一个容易忽略的边界是取消与抢占。客户端断开连接后,服务是否及时释放块池?高优先级请求抢占低优先级请求时,已经写入的 KV 块能否保留或被安全回收?如果没有明确的引用计数和取消协议,分页系统会在“看起来没有 OOM”的情况下持续泄漏容量。上线前应人为制造取消、超时和重连,检查块池能否回到稳定水位。分页的工程质量,最终体现在这些失败路径上,而不是正常请求的平均吞吐。

此外,块池容量应按“最坏可接受服务”而不是“实验峰值”设计。一个 128K 上下文请求可能在短时间内占满大量块;调度器若无上限,就会让其他交互请求排队。为单个请求设置上下文上限、为块池设置高水位告警,并为超限请求提供明确错误,是比事后扩容更可靠的做法。

一个成熟的容量规划还应区分“容量峰值”和“分配延迟”。即使总块数够用,块池锁竞争、连续块搜索和请求排队也可能让首 token 时间显著变长。评测时可以让一部分请求提前释放、一部分继续生成,并观察新请求是否能在目标时间内拿到第一个块。把这类调度指标加入监控,比单纯展示 GPU 显存占用曲线更能解释用户体验。

对于多租户服务,还要设定配额和公平性:单个长请求不能无限占用块池,重要租户可以拥有独立预算。配额会改变可用并发,必须重新计算容量公式。若这些边界没有明确,PagedAttention 只能提供更好的底层分配工具,无法自动保证服务质量。

服务上线前可以把这些指标并列展示:每 token KV 字节、块大小、峰值活跃块、最大并发、拒绝率、首 token P95 和每请求成本。任何一项缺失,都会让分页收益难以解释。容量、调度和尾延迟同时达标,才是一次真正的服务优化。

这套观测面板也可以作为下一次容量规划的历史基线,帮助团队区分模型变化与调度变化。

当缓存容量、排队时间和拒绝率同时出现异常时,应先定位是块池、调度还是下游计算,而不是直接扩大显存。三者混淆会让扩容成为昂贵且无效的习惯。

先定位瓶颈,再决定是否扩容,是容量工程最基本的纪律。

把这条纪律写进值班手册,能减少很多昂贵的误判。

当容量、调度和尾延迟都有独立指标时,值班人员才能快速判断应该扩容、限流还是调整块策略,而不是把所有异常都归因于模型变慢。

解释图逻辑上下文如何落到物理 KV 块
  1. 01请求token 序列
  2. 02逻辑块连续编号
  3. 03块表映射物理页
  4. 04KV 页非连续分配
  5. 05调度器共享与回收

分页把连续上下文需求与物理显存分配解耦,收益由请求长度分布和块大小共同决定。

证据清单

来源与适用范围

  1. 论文
  2. 文档
  3. 代码仓库

限定条件:基于 PagedAttention 论文与 vLLM 文档的机制分析;吞吐数字不在本页复述为通用结论。

本文遵循 CC BY-NC-SA 4.0

站点代码采用 MIT。所有模拟结果都标注了假设,不替代目标环境实测。

继续阅读归档