3.2 显存预算与调度调优:把 GPU 喂饱又不 OOM


文档摘要

3.2 显存预算与调度调优:把 GPU 喂饱又不 OOM 并行策略定好之后,模型已经稳稳摊在卡上了。但这时候你跑压测,往往会看到一个让人抓狂的现象:GPU 利用率只有 30%40%,吞吐远没到预期,可一旦把并发调高,立刻 OOM 崩掉。 问题不在并行,在于显存预算和调度没调好——GPU 一半时间在等数据、在空转,另一半时间又因为 batch 太大直接溢出。 这一节我们讲高并发调优里最值钱的一组旋钮:KV Cache 显存预算、Chunked Prefill、连续批处理上限,以及抢占与回收。它们共同决定了一个核心指标——在绝不 OOM 的前提下,你的 GPU 到底能被喂多满。 先算账:显存到底花在哪了 要调好,先得知道一张卡(以常见数据中心卡 80GB 显存为例)的显存是怎么没的。

3.2 显存预算与调度调优:把 GPU 喂饱又不 OOM

并行策略定好之后,模型已经稳稳摊在卡上了。但这时候你跑压测,往往会看到一个让人抓狂的现象:GPU 利用率只有 30%~40%,吞吐远没到预期,可一旦把并发调高,立刻 OOM 崩掉。 问题不在并行,在于显存预算和调度没调好——GPU 一半时间在等数据、在空转,另一半时间又因为 batch 太大直接溢出。

这一节我们讲高并发调优里最值钱的一组旋钮:KV Cache 显存预算、Chunked Prefill、连续批处理上限,以及抢占与回收。它们共同决定了一个核心指标——在绝不 OOM 的前提下,你的 GPU 到底能被喂多满

先算账:显存到底花在哪了

要调好,先得知道一张卡(以常见数据中心卡 80GB 显存为例)的显存是怎么没的。大致分给三块:

  • 模型权重:DeepSeek V4 的常驻专家权重,由并行度决定单卡份额(TP/EP 越大,单卡权重越小)。
  • 激活值(activations):前向过程中的中间张量,和 batch size、序列长度正相关。
  • KV Cache:每个请求每生成一个 token,都要缓存其 Key/Value 张量。高并发下,大量并发请求同时缓存 KV,这块会膨胀到非常可观,甚至成为显存的主要消耗者。
```mermaid pie title 单卡显存大致归属(高并发推理) "模型权重" : 35 "KV Cache" : 45 "激活值与其他" : 20 ```

关键认知:高并发时,KV Cache 往往比权重还吃显存。 谁的 KV 缓存能多塞,谁就能多扛并发。这就是为什么 gpu-memory-utilization 这个参数如此要紧——它本质上是在给 KV Cache 池划地盘。

一个 KV Cache 预算的算账示例

光说「KV 很吃显存」太虚,我们算一笔具体的账。假设某层每个 token 的 KV 占用(Key+Value 合计)约为:

  • 隐藏维度 4096,层数 60,KV 用半精度(每元素 2 字节),且做了 TP 切分;
  • 单卡视角下,每个 token 每层的 KV 约 = 2(K+V) × 4096 / TP × 2 字节;
  • 全 60 层下来,单 token 的 KV ≈ 60 × 2 × (4096/TP) × 2 字节。当 TP=8 时,约 60 × 2 × 512 × 2 ≈ 0.12 MB。

那么 KV Cache 池能容纳的「token 总数」≈(KV 池字节数)/ 0.12 MB。若 gpu-memory-utilization 留出 30GB 给 KV 池,则约可存 30×1024/0.12 ≈ 25 万 token 的 KV。如果每个并发序列平均长度 2000 token,那大约能同时容纳 100 多个序列。这个数字直接决定了 max-num-seqs 能设多高——它不是你拍脑袋定的,是显存算出来的

```mermaid flowchart LR A[gpu-memory-utilization 留出 KV 池] --> B[KV 池字节 ÷ 单token KV] B --> C[可容纳总 token 数] C --> D[÷ 平均序列长度] D --> E[≈ max-num-seqs 理论上限] ```

注意上面是简化模型,真实还要扣掉碎片(PagedAttention 用分页管理,仍会有页内浪费)、不同序列长度差异、以及激活值的峰值占用。但「先算上限再留 20% 余量」这个动作,能让你避开绝大多数 OOM。

gpu-memory-utilization:给 KV Cache 划地盘

vLLM 用 gpu-memory-utilization(简写 --gpu-memory-utilization,取值 0~1)告诉框架「显存里最多拿出百分之多少给我管」。框架会先预留权重和激活的空间,剩下的按比例划给 KV Cache 池。

  • 0.9:框架最多用 90% 显存,KV Cache 池更大,能扛更多并发序列。
  • 设得太高(如 0.95+):权重和激活一旦波动,直接 OOM。
  • 设得太低(如 0.6):KV Cache 池小,并发上不去,GPU 空转。

我的主张:不要一上来就设 0.95 搏命。稳妥起点是 0.85~0.90,压测时观察是否出现 OOM 或频繁抢占,再微调。如果你发现显存还剩一大截、并发却卡住不动,八成是这个值设保守了——往上调,往往立竿见影。

```mermaid flowchart LR A[ gpu-memory-utilization 设值 ] --> B[框架预留权重+激活] B --> C[剩余按比例给 KV Cache 池] C --> D{池够大?} D -->|是| E[并发高, GPU 忙] D -->|否| F[并发卡住, GPU 空转] ```

Chunked Prefill:让长输入不再「堵路」

这是高并发延迟优化的一个关键机制。传统 prefill(处理输入 prompt、生成首批 KV)是「一次性」的:一个超长 prompt 要算完才能把卡让出来做 decode,期间所有 decode 请求都被阻塞,尾延迟飙升。

Chunked Prefill 把长 prompt 切成小块(chunk),每块算完就腾出空隙插入 decode 请求,让 prefill 和 decode 在微观层面交错进行。代价是 prefill 本身稍慢一点,但整体吞吐和尾延迟显著改善——对「输入长、并发高」的场景几乎是必开项。

为什么它会改善延迟而非只是吞吐?关键在于 decode 阶段对延迟极度敏感:每个 decode 步通常只生成一个 token,本应毫秒级返回,但如果它被一个长 prompt 的 prefill 完全阻塞,就要等整个 prefill 跑完——一个 32k token 的 prefill 可能要几百毫秒甚至上秒,这一等就把所有在线 decode 请求的尾延迟直接拉爆。Chunked Prefill 把这块大任务切碎,让 decode 能「见缝插针」,等于把长 prompt 的延迟成本摊薄到多个小步里,每个 decode 请求每次只多等一个 chunk 的时间,而非一整段 prefill。

```mermaid sequenceDiagram participant GPU participant P as Prefill(长prompt) participant D as Decode(短请求) Note over GPU: 不开 Chunked: P 独占 GPU->>P: P 独占 GPU 算完 GPU->>D: 然后才服务 D Note over GPU: 开 Chunked: 交错 GPU->>P: P 算一块 GPU->>D: 插入 D 解码 GPU->>P: P 算下一块 GPU->>D: 再插入 D 解码 ```

实战建议:通过启用 chunked prefill 相关参数(名称随版本可能写作 enable-chunked-prefill 之类,请以官方文档为准),配合合理的最大 chunk 长度,让长 prompt 不再成为吞吐杀手。一个判断:如果你的服务里 prompt 普遍很长(如文档问答、代码补全),不开 Chunked Prefill,高并发下 P99 会难看到没法上线。它和 max-num-batched-tokens(一次批处理的总 token 上限)是联动的——chunk 大小受这个总上限约束,调的时候要一起看。

一个常见误区:有人担心开 Chunked Prefill 会让 prefill 本身变慢、拖长 TTFT。确实,把 prefill 切碎会引入一些调度开销,单次 prefill 的总耗时可能略增;但 TTFT 的感知主要来自「首个 token 何时返回」,而 Chunked Prefill 让首个 token 仍能在第一块 chunk 算完时就开始生成,配合 decode 交错,整体 TTFT 通常反而更稳。权衡上,长 prompt 高并发场景几乎总是「开比不开好」。

max-num-seqs 与 max-num-batched-tokens:并发的两道天花板

连续批处理(第二章 2.3 讲过)能把大量请求动态拼成一波。但拼多少是有上限的,由两组参数共同约束:

  • max-num-seqs:KV Cache 池里同时能容纳多少个序列。直接受 KV 池大小限制。
  • max-num-batched-tokens:一个调度步里总 token 数上限(含 prefill 的 prompt token + decode 的已生成 token)。它防止单步计算量过大把显存或算力打爆。
```mermaid flowchart TD A[max-num-seqs] --> B[受 KV 池大小约束] C[max-num-batched-tokens] --> D[受单步算力/显存约束] B --> E[两者取严, 决定实际并发] D --> E ```

调优陷阱:只抬 max-num-seqs 但忘了 max-num-batched-tokens,会出现「序列进来了却攒不到一波算」的尴尬;反之只抬后者不抬前者,KV 池先满、序列进不来。我的经验值:短序列场景 max-num-seqs 可推到数百;长上下文(如 32k+)每个序列占的 KV 巨大,两者都要按比例降,否则 KV 池秒满。

抢占与回收:并发爆了怎么办

当 KV Cache 池真的被占满、新请求又来了,vLLM 必须做抢占(preemption):把某些正在跑的序列的 KV 腾出来。腾的方式有两种,这是调优里最容易被忽视的取舍:

  • Swap(换出):把被抢占序列的 KV 从显存搬到 CPU 内存,恢复时再搬回来。不丢计算,但要内存带宽,且有内存容量上限。
  • Recompute(重算):直接丢弃 KV,等轮到时从头重新算 prefill。省内存,但浪费算力,被抢占的请求延迟暴增。
```mermaid flowchart TD Q[KV 池满, 新请求到来] --> P[触发抢占] P --> S[Swap: KV 换到内存] P --> R[Recompute: 丢弃重算] S --> S1[恢复快 占内存带宽] R --> R1[省内存 但延迟暴增] ```

判断:如果你的机器内存充足、且被抢占是偶发,Swap 更友好;如果内存也紧、且追求极限吞吐,Recompute 更简单但要做好尾延迟变差的预期。无论哪种,频繁抢占本身就是一个强烈信号——说明你的 max-num-seqs 或 gpu-memory-utilization 设得超出了硬件承载,该降,而不是硬扛。

监控什么:用指标指导调优而非瞎调

前面给的清单是「动作」,但动作要有反馈才叫调优。我建议你压测时至少盯四个指标,它们能直接告诉你卡在哪:

  • GPU 利用率(SM Efficiency / MFU):低于 50% 基本说明喂不饱,优先查 KV 池和 max-num-seqs;高于 95% 且延迟崩,说明过载,要降并发。
  • KV Cache 使用率:框架通常有显存/KV 池使用率指标。长期低于 60% 说明池子浪费、并发受限;频繁触顶 100% 说明并发到顶、要不放 KV 池(升 gpu-memory-utilization)要不降并发。
  • 抢占次数(preemption count):非零且持续增长,是并发超承运载的铁证,先回调 max-num-seqs。
  • TTFT / ITL / P99 延迟:TTFT 高多为 prefill 堵(查 Chunked Prefill);ITL(逐 token 间隔)高多为 decode 被挤(查并发与 batch 上限);P99 崩多为尾延迟被长尾请求拖垮。
```mermaid flowchart TD M[监控四指标] --> G[GPU利用率] M --> K[KV池使用率] M --> PR[抢占次数] M --> L[TTFT/ITL/P99] G -->|低| A[喂不饱: 抬并发] G -->|过高| B[过载: 降并发] K -->|触顶| C[并发到顶] PR -->|增长| D[超承运载 回调] ```

把这四指标和前面的速查表对照,调优就从「猜」变成了「读图下针」。我个人的习惯是:每改一个旋钮,就跑同一份压测脚本,截四指标的前后对比,连续做几轮,曲线自然告诉你甜点在哪里。

一套能直接用的调优清单

把上面串起来,给你一份高并发调优的实战顺序,建议照着跑压测、边测边调:

  1. 定并行:TP+EP 打底(见 3.1),确保权重放得下、通信合理。
  2. 算 KV 上限:按上节公式估出 KV 池能容纳的 token 数,反推 max-num-seqs 理论上限,留 20% 余量。
  3. 设 gpu-memory-utilization 起点 0.85~0.90,留余量防 OOM。
  4. 开 Chunked Prefill,尤其长 prompt 场景,压 P99;同时确认 max-num-batched-tokens 合理。
  5. 逐步抬 max-num-seqs,每抬一档跑一轮压测,观察 GPU 利用率、吞吐、P99、OOM/抢占频率。
  6. 盯抢占指标:频繁 Swap/Recompute 出现,说明到顶了,回调 max-num-seqs 或微调 gpu-memory-utilization。
  7. 量 GPU 利用率:停在 30%~40% 多半是 KV 池或 max-num-seqs 太保守;冲到 95%+ 且延迟崩,多半是并发过载。
```mermaid flowchart LR A[并行打底] --> B[算 KV 上限留余量] B --> C[util 0.85起 + Chunked] C --> D[抬 max-num-seqs 压测] D --> E{频繁抢占?} E -->|是| F[回调上限] E -->|否| G[找到甜点] ```

下面是一张「现象 → 原因 → 先动哪个旋钮」的速查表,是我自己排障时反复用的:

现象 最可能原因 先试的旋钮
GPU 利用率低、吞吐上不去 KV 池太小 / max-num-seqs 太保守 上调 gpu-memory-utilization、max-num-seqs
高并发 OOM KV 池或 batch 太大 下调 gpu-memory-utilization,或降 max-num-batched-tokens
P99 延迟飙升、长 prompt 明显 未开 Chunked Prefill 启用 chunked prefill
频繁抢占、部分请求极慢 并发超过 KV 池承载 回调 max-num-seqs,或升 gpu-memory-utilization(留余量内)
跨节点并行后吞吐反降 TP/EP 跨节点通信吃收益 收回节点内,PP 兜底

一个收尾判断:调优的终点在哪

很多人以为调优是「把吞吐推到无限高」,其实终点是一个平衡态:GPU 利用率稳定在 80%~90%、P99 满足业务 SLA、且连续压测不再出现 OOM 或频繁抢占。到了这个态,再加并发只会增加延迟风险,没有收益。所以「找到甜点」比「推到极限」更重要——这也是我把 max-num-seqs 强调为「压到临界再回调」而非「越大越好」的原因。

记住:本章给你的不是一组固定数字,而是一套「算预算 → 设起点 → 压测 → 读指标 → 回调」的闭环方法论。换硬件、换模型、换 vLLM 版本,这套方法都通用;死记某个博客里的「最佳参数」反而会害你。

最后点破一个新手误区:很多人把「OOM」当成纯显存问题,于是只去降 batch。但高并发下的 OOM 常常根子在「并行没选对」——比如 EP 没开导致专家权重全堆在少数卡,单卡 KV 池再怎么让也放不下全部并发。所以当你频繁 OOM 时,先回 3.1 复查并行,再回来动本节的显存旋钮,顺序别颠倒。

本节小结

高并发调优的本质,是在「显存预算」和「并发度」之间找甜点:gpu-memory-utilization 决定 KV Cache 池多大,max-num-seqs / max-num-batched-tokens 决定能同时喂多少个序列,Chunked Prefill 决定长输入不堵路,抢占机制决定爆了怎么兜底。四个旋钮联动调,才能把 GPU 从空转拉到饱和,又不踩 OOM 的雷。

给你的一句话带走:并行解决「算得动」,调度解决「算得满」;先按显存算清 KV 上限再设并发,打开 Chunked Prefill,把 max-num-seqs 压到临界再回调——这是高并发调优的三板斧。下一章我们将把这些原理落到真实部署案例里,看一个完整的生产级 DeepSeek V4 服务是怎么搭起来的。


发布者: 作者: 运行报错的菜鸟的小龙虾 转发
评论区 (0)
U