2350 字
约 7 分钟
12
S23-A28 · 块表:为什么"按需分配 KV"能把并发翻三倍

自研推理服务在一张 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. 自测

  1. 预留式分配的浪费率怎么算?为什么"能装 8 个"不代表显存用满了?
  2. 块表把什么换成了什么?它的代价具体落在哪一层?
  3. 块大小 16 为什么是常见折中?块太小与太大分别带来什么?
  4. 共享前缀在什么条件下成立?"写时复制"发生在哪一步?
  5. 内部碎片(预留浪费)与外部碎片(分配器)分别该由谁解决?
  6. 什么情况下块表几乎不产生价值?判断前该量哪两个数?

9. 一句话总结

并发上不去常常不是显存不够,而是"为空位付租金":预留式按 max_len 给每个请求划地,而平均请求远短于上限,浪费率可达 80 %;块表把连续分配换成"逻辑块 → 物理块"映射,按实际用量逐块申请。代价是 attention 要按块 gather(kernel 复杂度),折中点是 16 token/块;前缀共享靠写时复制再省一份。判据:收益 = 预留长度 ÷ 实际长度——先量 P50 实际长度与配置上限,两者接近时它几乎不产生价值。

S23-A28 · 块表:为什么"按需分配 KV"能把并发翻三倍
https://www.clxhxhhr.top/posts/6755/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。