主题
显存与 KV 缓存:PagedAttention 解决什么
一句话:KV cache 是推理并发的真正瓶颈,传统按最大长度连续预分配会把大量空间锁死在“预留但用不到”上;PagedAttention 把 KV 切成固定大小的块、用块表做离散映射,按需分配,把浪费从整段预留压缩到最后一块——但收益多大取决于长度分布,不是无条件九成。
为什么需要
自回归解码每一步都要用到之前所有 token 的 Key 和 Value。如果每步重算,复杂度会从平方级变成立方级,所以必须缓存。问题在于缓存怎么分配:按最大长度预分配,请求一进来就占满 max_model_len,而实际对话平均只有几百 token;要求连续内存,总量够但碎片多时新请求仍然分配失败;相同 system prompt 的多个请求还要各存一份。三者叠加,传统 serving 里真正用于有效 KV 的显存常常只有总量的两三成(经验值,取决于预留长度与长度分布)。并发上不去,不是 GPU 算不动,是显存被预留、碎片和重复占住了。
核心机制
从连续分配到分页
分页把每个序列的 KV 切成固定大小的逻辑块(常见 16 个 token 一块),物理块可以散落在显存任意位置,块表记录“逻辑块 → 物理块”的映射。分配时只按当前需要给块,不再按最大长度预留;序列增长时追加新块,块与块之间不要求连续。这直接把内碎片从“整段预留”压缩到最后一块:最后一块最多浪费 block_size − 1 个 token。外碎片也不是消失,而是不再要求连续大块——任何空闲块都能被任何序列使用;但块表、引用计数和分配器本身仍有元数据开销。
碎片率怎么算才不骗人
“PagedAttention 把利用率提到九成以上”这句话有前提,换个负载就不成立。分页后剩下的开销有两类:一是最后一块装不满,序列长度不是块大小整数倍时最后一块有空洞,块大小 16 时平均浪费约 8 个 token;二是元数据与分配开销,块表、引用计数、哈希索引、空闲块链表都要占内存和 CPU 时间,这部分不随块变小而线性下降。
同一个“浪费率”换个分母,数字能差十倍:
| 场景 | 计算 | 结果 | 分母是什么 |
|---|---|---|---|
max_model_len=4096,平均 200 token | 1 − 200/4096 | 约 95% | 预留长度 |
| 通用在线负载 | 经验值 | 约 40%–60% | 总 KV 池 |
| 200 token 序列的最后一块 | 8/200 | 约 4% | 有效 token |
| 34 token 短请求的最后一块 | 14/48 | 约 29% | 已分配 slot |
95% 只适用于“预留长度远大于实际长度”这一种场景,它衡量的是“不再按 4096 预留”省了多少,不是通用利用率;通用负载通常落在 40%–60%(经验值,随块大小、长度分布和前缀命中率变化)。
4% 和 29% 也不能直接相减,因为分母不同:4% 是 8 个 slot 的浪费除以 200 个有效 token,29% 是 14 个 slot 的浪费除以 48 个已分配 slot。统一到有效 token 口径,34 token 请求的浪费约 14/34≈41%;统一到已分配 slot 口径,200 token 请求约 8/208≈3.8%。短请求占比越高,分页的相对开销越明显,这也是块大小必须按负载调的原因。
块表与引用计数
每个请求有一张块表,记录它用到的物理块 ID;每个物理块有引用计数。新请求写入新块时计数为 1;多个请求共享同一前缀时,共享块的计数增加;请求结束或替换块时计数减一,归零才真正回收。没有引用计数,就无法判断一个物理块还有没有人在用——过早回收会让其他请求读到被覆盖的 KV。
写时复制:共享不等于可写
两个请求共享同一段前缀的物理块,如果其中一个要继续生成,不能直接往共享块里写,否则会污染另一个请求。五步:
- 写入前看目标块的引用计数,大于 1 说明还有人共享。
- 分配一个新物理块。
- 把原块内容复制到新块。
- 修改本请求的块表,指向新块。
- 在新块上写入,原块计数减一,其他请求继续读原块。
写时复制的代价是复制那一刻的一次显存拷贝,收益是共享期间省下的成倍空间。前缀越长、并发越高,收益越大;前缀很短或每个请求都不同,收益接近于零,还可能增加哈希和块表开销。
前缀缓存怎么命中
引擎把每个块的 token 序列做哈希,维护“哈希 → 物理块”的索引;新请求按块逐个查,第一块命中就继续查第二块,某一块没命中则后面的块不可能命中,停止查找。命中的块引用计数加一,块表直接指向它们,未命中的部分重新分配并计算。命中是自动的,不需要客户端传会话 ID;多轮对话、固定 system prompt、few-shot 示例和代码补全的固定上下文都是常见命中场景。
块大小怎么选
| 块大小 | 优点 | 缺点 |
|---|---|---|
| 小(如 8 / 16) | 碎片少,共享粒度细 | 块表更长,管理开销高 |
| 大(如 32 / 64) | 块表短,元数据少 | 最后一块浪费多,共享粒度粗 |
常见默认值是 16(以引擎版本为准),是碎片和管理开销的折中。短请求多、前缀共享多的场景可以更小;长序列为主、共享少的场景可以更大。改块大小要重新测吞吐和显存,不能只看理论碎片率。
显存估算与并发上限
text
可用 KV 显存 = 总显存 - 权重 - 固定开销 - 激活峰值
并发上限 ≈ 可用 KV 显存 / (平均序列长度 × 每 token KV)举例:80 GB 卡,13B FP16 权重约 26 GB,固定开销和激活留 6 GB,可用 KV 约 48 GB;每 token 0.8 MB,平均序列 500 token,则单请求约 400 MB,理论上限约 120 条。再乘上前面的有效占比(通用负载约 40%–60%),实际能稳定跑的大约 50–70 条。量化权重能提升并发也是同一个道理:权重从 26 GB 降到 13 GB,省下的 13 GB 直接变成 KV 容量。
抢占:显存不够时牺牲谁
请求增长到超过空闲块时,调度器必须腾地方。换出把某个请求的 KV 复制到 CPU,保留进度但占 CPU 内存和 PCIe 带宽;重算丢弃 KV,之后从已有 token 重新计算,省 CPU 内存但浪费算力,长序列代价高。选哪条取决于序列长度、CPU 内存和互连带宽;频繁抢占都是容量不足的信号,应该先降并发或加显存,而不是把抢占当常态。
实战场景
场景:短请求池把 4% 当碎片率,1,200 路并发打穿 40 GB KV 池
现象:文档打标服务(13B FP16,80 GB 卡,块大小 16,gpu_memory_utilization=0.90,可用 KV 40 GB,共约 3,050 块),70% 请求有效长度 34 token。容量评审用“最后一块只浪费 8/200≈4%”的结论,认为池子还有 96% 可用,把 max_num_seqs 从 900 提到 1,200。晚高峰到 1,180 路时 KV 使用率 98%,抢占 27 次/分钟(经验值,需校准),P99 打标延迟从 0.42 s 涨到 3.6 s,40 万条工单积压到次日。同一周,长文池(平均 1,536 token)按 29% 折减采购了 6 台,上线后使用率只有 54%。
排查路径:先统一分母(读现有 block 指标,零成本),再看块大小与长度分布(配置可回滚),扩池花钱且周期长,放最后。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 显存总量不足 | 900 路时 KV 使用率 88%、零抢占,请求结束 free blocks 立即回升 | 排除:容量有确定边界,是边界算错 |
| 碎片率口径 | 4% 的分母是 200 个有效 token;本池 34 token 请求的分母是 48 个已分配 slot,浪费 14/48=29% | 确认:两个分母混用,把 29% 读成 4% |
| 块大小与长度分布 | 直方图 70% 请求不超过 40 token,块 16 下每请求固定吃 3 块 48 slot;长文池 1,536 token 恰为 96 块整 | 确认:短请求被块粒度放大,长文池几乎无空洞 |
| max_num_seqs | 1,200 × 39.3 MB = 47 GB,超出 40 GB 池容量 18%;1,180 路时 used blocks 触顶 | 确认:配置按错口径定,超配 18% |
对症做法:口径统一到“已分配 slot”,用每请求 slot 数算容量;块大小降到 8,把最后一块的浪费从 14 slot 压到 6 slot。
python
for bs in (16, 8): # 同一批 34 token 请求,只换块大小
slots = -(-34 // bs) * bs # 向上取整到整块
per_req = slots * 819_200 / 1e6 # 每请求 MB
print(bs, slots, f"{(slots-34)/slots:.0%}", round(per_req, 1), int(40_000 / per_req * 0.9))
# 16 48 29% 39.3 915
# 8 40 15% 32.8 1098
ENGINE = dict(block_size=8, max_num_seqs=1000, gpu_memory_utilization=0.90)29% 与 4% 的差别只在分母:统一到已分配 slot 后,块 16 的每请求占用是 48 slot,容量 = 可用 KV / (slot 数 × 每 token KV);块 8 把浪费压到 6 slot,同池多装 20% 请求,代价是块表长度翻倍。
复盘要点:判据是“报碎片率必须同时报分母,按有效 token 还是按已分配 slot,两个数既不能相减也不能互换”。更早的信号是每请求 slot 数:块 16 下 34 token 请求稳定占 48 slot,这个值从上线第一天就在,把它和块大小画在一张图上,29% 一眼可见;只盯 KV 使用率,看到 98% 时已经进入抢占。
场景:前缀缓存命中后串租户,8 小时漏掉 96% 空闲块
现象:多租户客服集群开启前缀缓存,命中率 62%,8 小时内 free blocks 从 3,050 掉到 120(约 366 块/小时),期间 2 万次会话里出现 7 次回答带出其他租户的订单号(经验值,需校准)。泄漏与串话同源:共享块既没被正确计数,也没被正确复制。合规要求 24 小时内给根因。
排查路径:先看 prompt 是否干净(读 trace,零成本),再关前缀缓存做对照(重启 40 s,可回滚),最后才读块管理代码(成本最高)。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 应用层拼错上下文 | trace 里 messages 只有本租户,串话回答中的订单号不在本请求任何输入里 | 排除:prompt 干净,串话发生在 KV 层 |
| 显存或配置漂移 | 总块数 3,050 恒定,无版本与参数变更;活跃序列仅 40 条(120 块),used blocks 却停在 2,930 | 排除:不是总量或配置 |
| 前缀缓存共享路径 | 关闭前缀缓存后 8 小时 free blocks 稳定在 2,950,串话 0 例 | 确认:故障随共享块出现 |
| 引用计数与 COW 判定 | swap-in 重建块表时对共享块再 +1;写入判定用“本请求块表唯一”而非 refcount>1 | 确认:计数虚高造成泄漏,判定错对象造成串话 |
对症做法:写入共享块前只看块自身的引用计数,refcount 与块表在同一临界区成对变更,swap-in 只重建映射、不新增副本。
python
def append_kv(seq, pos):
blk = seq.block_table[pos // BLOCK]
if blk.ref_count > 1: # 只认块自身计数,不看“本请求是否唯一”
new = alloc_block()
copy_blocks(blk, new) # 先复制,再改表,最后写
seq.block_table[pos // BLOCK] = new
blk.ref_count -= 1
new.ref_count = 1
write_kv(seq.block_table[pos // BLOCK], pos % BLOCK, kv)
# swap-in:按块 ID 重建块表,不复制、不额外 +1;换出时每个块只减一次正文五步 COW 的第一步是“看目标块的引用计数”,判定对象必须是块自身的 refcount;refcount 与块表必须成对变更,多一次 +1 块就永不回收,判定换成“本表唯一”则共享块被原地写,两个 bug 同一个根因。
复盘要点:判据是“任何写 KV 的路径都必须先看块自身的 refcount,引用计数与块表变更必须在同一临界区成对发生”。更早的信号是 free blocks 的斜率:命中率 62% 时每小时掉 366 块,第一天就在掉,把“free blocks 8 小时跌幅超过 5%”设成告警,比串话事故早 7 小时。
场景:96K 合同请求被抢占 23 次,40 分钟没跑完
现象:合同解析池混跑 8K 摘要与 96K 全文分析(GQA 模型,每 token KV 192 KiB),swap_space=8。一条 96K prompt、输出 4K 的请求进入池后 40 分钟只输出 600 token,10 分钟内被抢占 23 次(经验值,需校准),累计重算 8 次;客户端 60 s 超时后重试 3 次,每次都把池子重新占满,同池其他请求 TTFT P99 从 0.9 s 涨到 7.2 s。
排查路径:先看入口是否在重试(读日志,零成本),再看块表是否真泄漏(对比 free 与活跃序列),最后才改 swap 与准入(需重启)。
| 排除对象 | 判定证据 | 结论 |
|---|---|---|
| 客户端超时重试 | 同一 trace_id 有 3 次入口,每次都在池内重新排队;429 比例为 0 | 排除:重试是结果并放大拥堵 |
| 显存泄漏 | 每次抢占后 free blocks 立即回升,used 与活跃序列数吻合 | 排除:没有泄漏 |
| swap 容量 | swap 使用量恒定打满 8 GB,evict 日志里 96K 序列换出失败、回落 recompute | 确认:单条 19.3 GB 是 swap 的 2.4 倍 |
| 抢占策略与块表 | 23 次抢占中 21 次选中该请求(FCFS 从最近进入运行的序列选),重算后块表重建、进度归零 | 确认:长序列被反复清零 |
对症做法:长请求单独分池,max_model_len 收到 32K,swap 按单条 KV 配,超长请求走准入排队而不是进抢占循环。
yaml
pools:
long_ctx: # 长请求单独池,块表能装下整条序列
max_model_len: 32768 # 32K × 192 KiB ≈ 6.4 GB/条
max_num_seqs: 3
swap_space: 24 # 3 × 6.4 GB,够整条换出再换回
preemption_mode: swap # 32K 换出约 0.3 s,比重算 6.5 s 的 prefill 便宜
admission:
queue_over_tokens: 32768 # 超出直接排队,不进抢占循环
queue_timeout_s: 12096K × 192 KiB = 19.3 GB,是 swap 的 2.4 倍,换出第一步就必然失败;32K 的 6.4 GB 换出约 0.3 s,重算同样的 prefill 要 6.5 s(经验值,需校准),两条成本都随长度线性增长,所以修法不是换抢占策略,而是把单条序列压到 swap 装得下的区间。
复盘要点:判据是“抢占次数 / 完成请求数大于 1 就是驱逐循环,先查 swap 与单条序列 KV 的比值,不要先调 batch”。更早的信号是 swap 使用率:它第一天就 100%,evict 尝试在 10 分钟内出现过 3 次,把“swap 使用率超过 90% 且 evict 大于 0”设成告警,比首字超时早约 25 分钟。
最小上手
用引擎自带指标验证分页效果,不要凭感觉:
text
启动后记录:GPU blocks total/free/used、KV cache usage %、prefix cache hit rate、preemption count
压测:固定并发,逐步加长 prompt
观察:used blocks 是否接近 total,抢占是否上升判据:稳定运行时 KV 使用率应保持在高位但不持续打满;抢占次数接近零;相同前缀的第二轮请求,prefill 时间和 KV 占用明显下降。
生产动作
- 按 KV 定并发:用显存公式估算,再用压测修正,不要照抄
max_num_seqs默认值。 - 长上下文单独分池:长短请求混跑会让长请求占满块,短请求排队;按长度或场景分池。
- 监控碎片与抢占:块利用率、空闲块数、抢占次数、换出量一起看。
- 前缀缓存按场景开关:固定 system prompt、多轮对话收益大;每个请求都不同的场景可能只增加开销。
- 版本升级重测:块管理、哈希和调度策略会随引擎版本变化,升级后重跑同一负载。
- 保留余量:不要把
gpu_memory_utilization设到极限,CUDA context 和通信 buffer 会随负载波动。
边界
- 分页不减少总计算量:它提升显存利用率,从而提升并发;单请求的注意力计算仍然随长度增长。
- 共享有前提:只有 token 完全一致的前缀才能命中,语义相同但措辞不同不算。
- 写时复制有瞬时开销:高并发下复制会带来显存带宽压力,收益要用命中率验证。
- 块大小不是越小越好:块表、哈希和分配器都有固定成本,小到一定程度反而变慢。
- 抢占不是免费:swap 占 CPU 内存和带宽,recompute 浪费算力,频繁发生说明容量规划有问题。
讲清楚
KV cache 决定推理并发,传统连续预分配同时产生内碎片、外碎片和重复存储。PagedAttention 把 KV 切成固定大小的块,用块表做离散映射,按需分配,把浪费压缩到最后一块;再用引用计数和写时复制支持安全共享,前缀一致的请求可以复用同一组物理块。但“利用率九成”只在 max_model_len 远大于实际长度时成立,通用负载通常只有四到六成;4% 和 29% 这类数字分母不同,不能混着比较。块大小、前缀缓存和抢占策略都要按场景调,核心判据是 KV 使用率、抢占次数和命中率。分页解决的是显存效率,不减少单请求计算;真正的容量规划仍要回到“可用 KV 显存除以每请求 KV”这条公式。
适用:vLLM 等采用 PagedAttention 的推理引擎;块大小与默认值以实际版本为准 | 初稿 2026-10