2493 字
约 8 分钟
11
S23-A29 · 显存不够时先杀谁:KV 淘汰的两笔账

一个推理服务的 KV 池满了,新请求进来必须"抢"一块空间。默认策略是淘汰最久未使用的序列(LRU),听起来很合理——但上线后出现两种怪现象:P99 出现 3 秒尖刺,以及某些会话反复被截断,用户打字打到一半被告知"上下文已超限"。

原因在同一个地方:淘汰不是免费的。被淘汰的序列如果还想继续,就要从头重算 prefill;而"淘汰谁"这个决定,直接决定了重算成本由谁承担、以及哪些用户会被反复伤害。


1. 淘汰的代价是"重算 prefill"

淘汰对象 直接代价 隐藏代价
已结束但 KV 未释放 无 无(应当立即释放)
空闲的长会话 下次输入时重算 重算时间 = 序列长度 × 单价
正在 decode 的短请求 丢弃部分输出 用户可感知的截断
刚被淘汰又回来的请求 重复淘汰 抖动

第三行是 P99 尖刺的来源:淘汰一个正在 decode 的请求,它要么被截断(用户看到不完整回答),要么重排、重算、再排队——一次淘汰可能变成一次 3 秒的等待。

// 一次 32k 会话被淘汰后再回来的成本
重算 prefill:32,000 token × 0.08ms/token ≈ 2.6 秒
而它原本只剩 200 token 就生成完了(约 3 秒)
// 结论:淘汰"快完成的长会话"是最亏的一笔

2. 三种淘汰策略,取舍不同

策略 保护谁 伤害谁
LRU(最久未使用) 活跃的新会话 思考间隔长的长会话
按剩余长度优先(先杀快完成的?不) — —
按重算成本 / 剩余收益比 长会话、快完成者 新的短请求(需排队)
抢占式(preemption + 重算) 高优先级请求 低优先级(可恢复)

第一行与第四行是"公平性"与"效率"的两种取向:LRU 公平但短视,抢占按优先级分配但会让低优请求反复重来。生产上通常先用 LRU 兜底,再叠加两条保护规则(快完成、已很长)。

第二行要说明白:"先杀快完成的"是反直觉的错误——快完成意味着"再给它 3 秒就能交付",杀掉它等于把已付出的成本全丢掉。正确方向是先杀"重算便宜 + 剩余价值低"的。

第三行的判据可以写成一个比值:

代价 = 重算成本(≈ 已完成 token 数) / 剩余收益(≈ 剩余 token 数)

比值越大越该杀——即"已经很长、但还需要很长"的会话,重算极贵;而"很短、剩余也很短"的,重算便宜。

// 三个候选,按"重算成本 / 剩余收益"排序
A: 已 30k,还需 8k   → 3.75   ← 最不该杀(重算贵)
B: 已 2k, 还需 1k   → 2.0    ← 可杀
C: 已 26k,还需 200  → 130    ← 千万别杀(马上就好)
// 注意 C:数值最大,但它是"快完成",杀了等于扔掉 26k 的成果

3. 抢占:让"重算"可控而不是随机

机制 做法 效果
全量重算 淘汰整个序列,重新 prefill 简单,但代价最大
分块保留 只释放尾部块,保留前缀块 恢复时只需续算
挂起(swap 到 CPU/磁盘) 移到慢速内存 恢复快、但占带宽
拒绝新请求 排队而非淘汰 保护在跑的,牺牲吞吐

第二行是块表(paged KV)带来的关键能力:因为 KV 是分块映射的,可以只释放尾部。前缀块保留后,恢复时只需对新增部分做 prefill——这让"抢占"从"全损"变成"损失一小段"。

第四行是最保守也最容易被忽视的选项:宁可让新请求排队,也不要杀掉在跑的。它的代价是首 token 延迟变高,收益是已投入的计算不浪费。在长会话为主的场景里,这往往是最优解。

// 一个可用的抢占配置(把"杀谁"写成显式规则)
kv_eviction:
  preempt_mode: recompute        # 或 swap
  victim_order:                  # 从上往下尝试
    - completed_not_released     # 1 已结束未释放(零成本)
    - short_low_remaining        # 2 短且剩余少
    - long_stalled               # 3 长但长时间无新 token(用户走神)
  protect:
    - near_completion: remaining < 512      # 快完成的不杀
    - high_priority_tenant                  # 高优租户不杀
  fallback: queue_new_request              # 都不该杀 → 让新请求排队

4. 抖动:为什么同一个会话被反复淘汰

现象 机制
同一会话被踢两次 淘汰后立刻回来,又选它(仍是最久未用)
空闲长会话反复重算 每次回来都触发一次 2–3 秒 prefill
高峰期整体变慢 淘汰率上升,重算占满算力

第一行的解法是给刚被淘汰的序列一个"冷却保护期"(例如 30 秒内不再被选为目标)。否则 LRU 会在"它最久未用"和"它刚回来"之间反复横跳。

第三行是系统性风险:淘汰会自我放大——淘汰越多,重算越多,算力越紧,淘汰越多。所以淘汰率本身要作为监控指标,而不是等到 P99 恶化才发现。

// 一次自我放大的形状
显存压力上升 → 开始淘汰 → 重算占用算力 → 有效吞吐下降 → 队列变长
             → 显存压力更大 → 淘汰更频繁
// 断点:淘汰率 > 每秒 5 次 或 重算 token 占比 > 20% 时,应拒绝新请求而不是继续淘汰

5. 什么该被监控(而不是靠 P99 猜)

指标 含义 阈值参考
kv_cache_usage 池占用率 > 90 % 预警
preemption_count/s 淘汰频率 > 5/s 说明过载
recompute_tokens/s 重算浪费量 占总 token > 20 % 即异常
平均序列长度 决定淘汰的代价 越长越要保护
淘汰后重试率 抖动程度 上升说明策略有问题

第三行是最有信息量的:它直接量化了"被浪费的算力"。如果 20 % 的算力花在重算被淘汰的序列上,那"加卡"不如"改淘汰策略"。

// 三条命令确认是不是淘汰导致的问题
$ curl -s localhost:8000/metrics | grep -E 'kv_cache_usage|preemption|recompute'
$ grep -c "preempted" server.log | tail -1
$ nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader
// 判据:util 高 + kv 高 + preemption 频繁 → 淘汰策略问题,不是算力不足

6. 排错:五种"显存不够"的误判

现象 八成原因 处理方向
OOM 但 KV 占用不高 权重/激活/碎片 查显存构成,不是淘汰
P99 尖刺 + 淘汰频繁 淘汰了快完成的会话 加 near_completion 保护
同会话反复被截断 无冷却保护期 淘汰后 30s 内不再选它
吞吐随并发下降 重算占比过高 改分块保留或改排队
加卡后仍抖动 淘汰策略没变 先改策略再加卡

第五行是这类问题的通用规律:如果根因是策略,加资源只会把同一个问题放大到更贵的规模。而判断"是不是策略问题"只需要一个数:重算 token 占比。


7. 边界:什么情况不必精细淘汰

场景 为什么 说明
请求都很短(< 2k) 重算便宜 LRU 足够
QPS 很低 池子几乎不满 不触发淘汰
无长会话(单轮问答) 淘汰即释放 不需要保护
已用块表 + 大池 压力小 默认策略即可

判断口径一句话:淘汰策略的价值 = 重算成本 × 淘汰频率——短请求或无长会话时,这个乘积很小,精细策略没有意义。而一旦出现"长会话 + 高并发",保护"快完成"与"已很长"的两类序列就是最划算的两条规则。


8. 自测

  1. 淘汰的直接代价与隐藏代价分别是什么?为什么"快完成的会话"最不该杀?
  2. "先杀重算便宜的"这个直觉为什么对,而"先杀快完成"为什么错?
  3. 分块保留为什么能让抢占从"全损"变成"损失一小段"?
  4. "让新请求排队"什么时候优于"淘汰在跑的"?
  5. 抖动为什么会被自我放大?用什么指标能在恶化前发现它?
  6. 判断"是算力不足还是淘汰策略问题"看哪个数?

9. 一句话总结

淘汰不是免费的,它的代价是重算 prefill——所以"杀谁"本质是在分配重算成本。两条硬规则:保护"快完成"(剩余 < 512 token)与"已经很长"的序列,先杀"短且剩余少"的;淘汰率上升要加冷却保护期,否则同一会话会被反复横跳地踢出去。判据:重算 token 占比 > 20 % 就是策略问题,不是算力不足——此时加卡只会把同一个问题放大到更贵的规模。

S23-A29 · 显存不够时先杀谁:KV 淘汰的两笔账
https://www.clxhxhhr.top/posts/6763/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。