自研推理服务在一张 80G 卡上只能跑 8 个 32k 长度的并发请求;换成带块表(paged KV)的实现之后,同样硬件跑到 26 个。代码没改,模型没换,变的是KV 缓存的分配方式。
根因不在"显存不够",在预留式分配的浪费:为了支持最长 32k 的请求,服务给每个请求预留了 32k 的空间;而实际请求平均只有 6k——每个请求浪费 80 % 的预留。块表把这笔浪费还了回来:不按上限预留,按实际用量逐块申请。
1. 预留式的浪费率是算出来的
| 请求实际长度 | 预留长度 | 浪费率 |
|---|---|---|
| 2k | 32k | 94 % |
| 6k | 32k | 81 % |
| 16k | 32k | 50 % |
| 30k | 32k | 6 % |
// 一次真实的显存预算(80G 卡,8B 模型 FP16)
权重 16G + 激活 4G + KV 池 58G
预留式(32k):每请求 4G 预留 → 理论 14 个,实际跑 8 个(碎片 + 峰值)
块表式:按 6k 实际分配 0.75G/请求 → 理论 77 个,实际 26 个(调度与波动限制)
// 缺口不是"算错",是"预留未用"加"末块未满"两层浪费叠起来的
第二行就是真实流量里的常态:大部分请求远短于上限,而预留式必须按上限给。于是"能装 8 个"不是因为 8 × 32k 用满了显存,而是 8 × 32k 的预留用满了显存——实际用掉的可能只有三分之一。
// 同一张 80G 卡,两种分配方式的实际可用并发(平均 6k 请求)
预留式(32k): 8 个 → 显存按 32k×8 占满,实际只用了 6k×8
块表式(16 块): 26 个 → 按实际 6k 分配,多出的空间给了更多请求
// 结论:不是"挖出了新显存",是"不再为空位付租金"
2. 块表把"连续"换成"映射"
| 方案 | KV 存放 | 代价 |
|---|---|---|
| 预留连续 | 每个请求一段连续显存 | 内部碎片大 |
| 块表(paged) | 固定大小的物理块,逻辑块 → 物理块映射 | attention kernel 要按块 gather |
第二行的代价是真实的:KV 不再连续,注意力计算要多一次"按块索引"。所以块表不是免费的午餐,它是"用 kernel 复杂度换显存利用率"。而它划算是因为:浪费的显存直接等于能跑的并发数,而 kernel 的开销只增加几个百分点。
// 一次 decode step 的访存路径差异
连续布局: base + offset 一次寻址 → 读一整段 KV
块表布局: 块表[逻辑块] → 物理块号 → 段内偏移 → 拼多个段
// 后者多一层间接寻址,但可被 kernel 优化掉大部分
3. 块大小的取舍:碎片与寻址的平衡
| 块大小(token/块) | 内部碎片 | 寻址开销 | 典型选择 |
|---|---|---|---|
| 8 | 最低 | 高(块表大、gather 多) | 短请求为主 |
| 16 | 低 | 中 | 最常见 |
| 32 | 中 | 低 | 长请求为主 |
| 64+ | 高(末块浪费大) | 最低 | 罕见 |
第二行的浪费是"最后一个块没用满":平均浪费半块。所以块越小,浪费越小;但块表项变多、gather 次数变多。16 是工程折中,也是多数实现默认值。
4. 共享前缀:写时复制让"同一 prompt 的 N 个请求"只付一份
当 N 个请求共享同一段 prompt(同一问题的多个采样、同一 system prompt 的批量请求),它们的前缀块可以指向同一份物理块,直到某个请求要写自己的新块时才复制。
| 场景 | 无共享 | 有共享 |
|---|---|---|
| 同 prompt 采样 8 次 | 8 份前缀 KV | 1 份 + 8 份增量 |
| 同 system prompt 批量请求 | N 份 | 1 份 + N 份对话部分 |
// 前缀共享的判定:前 k 个「块」逐位相同才能共享
def shareable_blocks(a, b, block=16):
n = min(len(a), len(b))
k = (sum(1 for i in range(n) if a[i] == b[i])) // block // 对齐到块边界
return k
// 关键:共享在「块」粒度上判定,不是 token 粒度
第二行在"批量摘要"类场景里非常常见:一个 2,000 token 的 system prompt 被 100 个请求共用,共享后前缀部分只占一份。
// 一次 Copy-on-Write 的时机
T1 请求 A 写入块 P1..P3(prompt 部分)
T2 请求 B 命中同一前缀 → 块表指向 P1..P3,引用计数 +1
T3 B 开始生成 → 需要新块 P9,写入自己的内容(P1..P3 仍只读共享)
// 关键:共享只在前缀"只读"时成立,一旦要写就复制
5. 与"显存碎片"是两个问题
| 问题 | 层面 | 表现 | 解法 |
|---|---|---|---|
| 外部碎片 | 分配器层 | 明明有总量但分配不出连续段 | 块表 / 内存池 |
| 内部碎片(预留浪费) | 调度层 | 每请求按上限预留 | 按需分配 |
| KV 池耗尽 | 容量层 | 并发上限低 | 加卡 / 量化 / 缩短上下文 |
第二行是本篇讲的:它是"调度策略"的问题,不是"分配器"的问题。所以"换一个更好的分配器"解决不了它——必须改"按什么粒度申请"。
第三行则提醒:块表把预留浪费还回来之后,真正的上限变成了"总 KV 池 ÷ 平均实际用量",而这个上限仍由显存总量决定。所以"并发翻三倍"不是"显存变成三倍",而是"同样显存能装下更多真实请求"——一旦请求形状变化(平均长度上升),这个倍数会立刻缩水。,真正的上限变成了"总 KV 池 ÷ 平均实际用量",而这个上限仍由显存总量决定。不要把"块表把并发翻三倍"理解成"显存变三倍"。
6. 排错:五种"上了块表没变快"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 并发提升但吞吐没涨 | 单请求变慢(gather 开销) | 调块大小或换 kernel |
| 长请求场景收益很小 | 浪费率本来就低 | 预期内,不必优化 |
| 短请求场景反而更差 | 块太小、块表开销占比高 | 调大块 |
| 共享前缀没生效 | 前缀不是逐 token 相同 | 固定 system prompt 顺序 |
| 显存仍不够 | 模型权重 + 激活占大头 | 量化或换小模型 |
第四行与前缀缓存的条件一致:任何一处不同(时间戳、用户名顺序)都会让共享失效。块表给你能力,前缀一致性仍然是使用者的责任。
7. 边界:什么情况块表不划算
| 场景 | 为什么 | 说明 |
|---|---|---|
| 请求长度都很接近上限 | 浪费率天然低 | 收益有限 |
| 极短请求(< 1 块) | 块表开销占比高 | 直接连续分配 |
| 单请求独占整卡 | 无并发可提升 | 不需要 |
| kernel 不支持 paged attention | 只能走连续 | 换实现 |
| 显存已被权重占满 | 没有 KV 空间可回收 | 先解决权重 |
判断口径一句话:块表的收益 = "预留长度 ÷ 实际长度"这个比值——平均请求越短、上限越高,收益越大;两者接近时它几乎不产生价值。所以判断该不该上,先量两个数:P50 实际长度与配置的 max_len。
8. 自测
- 预留式分配的浪费率怎么算?为什么"能装 8 个"不代表显存用满了?
- 块表把什么换成了什么?它的代价具体落在哪一层?
- 块大小 16 为什么是常见折中?块太小与太大分别带来什么?
- 共享前缀在什么条件下成立?"写时复制"发生在哪一步?
- 内部碎片(预留浪费)与外部碎片(分配器)分别该由谁解决?
- 什么情况下块表几乎不产生价值?判断前该量哪两个数?
9. 一句话总结
并发上不去常常不是显存不够,而是"为空位付租金":预留式按 max_len 给每个请求划地,而平均请求远短于上限,浪费率可达 80 %;块表把连续分配换成"逻辑块 → 物理块"映射,按实际用量逐块申请。代价是 attention 要按块 gather(kernel 复杂度),折中点是 16 token/块;前缀共享靠写时复制再省一份。判据:收益 = 预留长度 ÷ 实际长度——先量 P50 实际长度与配置上限,两者接近时它几乎不产生价值。