推理服务的第一个硬约束不是算力,是显存。卡算力不够,最多是慢;显存不够,是直接起不来或者跑一半 OOM。
而"显存不够"几乎从来不是一句"模型太大了"能解释的。同一张卡上跑同一个模型,把上下文从 2K 提到 32K、把并发从 4 提到 64,显存占用能翻好几倍,权重却一个字节都没变。所以必须先知道显存被谁拿走,才能判断该动哪一块。
1. 显存被谁占用
推理进程的显存大致分成四份,性质完全不同:
| 组成 | 是什么 | 随什么增长 | 可压缩性 |
|---|---|---|---|
| 权重 | 模型参数本身,只读常量 | 参数量 × 精度 | 高:降精度、换量化格式 |
| KV Cache | 每条请求已生成内容的注意力缓存 | 并发 × 上下文长度 | 中:压缩、换出、限长 |
| 激活与临时缓冲 | 前向计算的中间张量、通信缓冲 | 批大小 × token 数 | 中:批大小、chunked prefill |
| 框架预留 | 引擎自身的 workspace、CUDA context、显存池碎片 | 引擎版本、分配策略 | 低:换引擎参数、预留比例 |
一个快照拆成这四份,就能对上账:
总占用 ≈ 权重 + KV Cache 实际占用 + 激活峰值 + 框架预留
必须满足:总占用 ≤ 卡显存 × 可用比例 # 留余量,别按 100% 算
关键是权重是常量,KV 是变量。权重决定了"能不能装下这个模型",KV 决定了"装下之后能同时服务几个人"。所有容量问题的本质都是在这两者之间做分配。
2. 权重的估算方法
权重显存 = 参数量 × 每参数字节数
每参数字节数 = 位宽 / 8
| 精度 | 每参数字节数 | 备注 |
|---|---|---|
| FP32 | 4 | 推理基本不用 |
| FP16 / BF16 | 2 | 常见基线 |
| FP8 | 1 | 需要硬件与引擎同时支持 |
| INT8 | 约 1 | 常伴随 scale/zero-point 的少量额外开销 |
| INT4 | 约 0.5 | 实际略高,量化元数据与部分保留层会加回一点 |
按模型 config 与卡规格核对的三个注意点:
- 参数量要按 config 数出来,不要用名字里的数字猜(embedding 与 lm_head 是否共享、MoE 的激活参数与总参数不是一回事);
- INT4 不等于精确的 0.5 倍,量化元数据、未量化的层(如 norm、部分 attention)、embedding 常常保持高精度;
- 权重之外还有加载开销,从磁盘加载到显存的过程会峰值占用,冷启动阶段要单独留量。
3. KV Cache 的估算方法
KV Cache 是每条请求、每一层、每个历史 token 都要存一对 K 和 V。公式:
每 token KV 字节 = 层数 × 2 × KV头数 × 头维度 × 每元素字节数
# 2 = K 和 V 各一份;每元素字节数按 KV 的精度(常与权重不同)
单请求 KV 字节 = 每 token KV 字节 × 上下文长度(prompt + 已生成)
总 KV 字节 ≈ 单请求 KV 字节 × 并发序列数
| 因子 | 由什么决定 | 常见误判 |
|---|---|---|
| 层数 | 模型深度 | 按 config 的 num_hidden_layers 取 |
| KV 头数 | 注意力结构 | GQA/MQA 下远小于注意力头数,别用 num_attention_heads |
| 头维度 | config 的 head_dim | 有的模型 head_dim ≠ hidden/heads |
| 每元素字节 | KV 精度 | KV 量化后与权重精度可以不同 |
| 上下文长度 | 实际输入+输出 | 用平均值会低估峰值,按 P99 长度算 |
这里的每一个数都要按模型 config 与卡规格核对。最容易被忽略的是第三条:GQA 模型的 KV 头数可能只有注意力头数的几分之一,直接拿头数代入会高估好几倍,然后得出"这卡跑不了"的错误结论。
4. 为什么长上下文与高并发同时吃 KV
KV 的总量是"长度 × 并发"的乘积,两者一起涨不是相加而是相乘:
| 场景 | 上下文 | 并发 | 相对 KV 总量 |
|---|---|---|---|
| 短对话 | 短 | 低 | 1×(基准) |
| 长文档问答 | 长 | 低 | 随长度线性放大 |
| 高并发短请求 | 短 | 高 | 随并发线性放大 |
| 长上下文 + 高并发 | 长 | 高 | 两者相乘,最先打爆 |
这就是"上下文一开长,并发就得砍"的原因。反过来说,任何"支持 128K 上下文"的宣传,都要追问一句:在多少并发下支持。单条请求跑满长上下文,和 32 条并发都跑满长上下文,需要的显存差一个数量级。
5. 激活与临时缓冲的量级变化
| 阶段 | 激活量的决定因素 | 量级特征 |
|---|---|---|
| decode 单步 | 批大小 × 隐层维度 | 小,但每步都有 |
| prefill | 批内总 token 数 × 隐层维度 × 层数(含中间张量) | 大,随 prompt 长度陡增 |
| 大 batch prefill | 上述再乘批内请求数 | 最大,OOM 常发生在这里 |
| 通信缓冲 | 并行度、all-reduce 实现 | 多卡时不可忽略 |
两个实践结论:OOM 常常发生在 prefill 而不是 decode;限制单批 token 总数(而不是只限批内请求数) 是控制激活峰值最直接的手段。很多引擎用 max_num_batched_tokens 这类参数来管这件事。
6. "装不下"的四种应对与各自代价
| 应对 | 做法 | 收益 | 代价 |
|---|---|---|---|
| 降精度 | 权重 FP16 → INT8/INT4 | 权重显存大幅下降 | 质量风险,需要评估集回归 |
| 缩上下文 | 限制最大输入/输出长度 | KV 线性下降 | 长文档类需求直接不可用 |
| 减并发 | 降低并发序列上限 | KV 线性下降 | 吞吐下降、排队变长 |
| 加卡 | 张量并行/流水并行分摊 | 单卡压力下降 | 通信开销、成本上升、复杂度上升 |
顺序建议:先用"减并发"确认是否真的装不下(很多人是在压测脚本里开了不现实的并发),再考虑"缩上下文",然后才是"降精度",最后才是"加卡"。因为前三者都是配置改动,加卡是架构改动。
7. 显存与吞吐的关系
剩余显存直接决定能放多少请求,而能放多少请求决定吞吐上限:
可并发序列数 ≈ (卡显存 × 目标利用率 − 权重 − 激活峰值 − 预留) / 单请求 KV 字节
吞吐上限 ≈ 可并发序列数 × 每序列每秒产出 token 数
注意这是上限,不是实际值:并发上去了,带宽被分薄,每序列的出词速度会下降,实际吞吐通常低于这个乘积。所以"显存越多吞吐越高"只在没到带宽饱和之前成立。饱和之后再加并发,只是让队列变长。
8. 怎么读一个显存占用快照
$ nvidia-smi --query-gpu=memory.total,memory.used,memory.reserved --format=csv
$ nvidia-smi --query-compute-apps=pid,used_memory --format=csv
# 引擎侧通常还有更细的口径(名字按引擎版本核对)
# vLLM: 日志里的 GPU blocks / KV cache blocks、前缀缓存命中
# TensorRT-LLM: build 阶段的 max batch / max tokens 预算
按四份对账的顺序:
| 步骤 | 拿到什么 | 对不上的常见原因 |
|---|---|---|
| 1 | memory.total |
卡被别的进程占了(训练残留、别的服务) |
| 2 | 权重字节(按参数量算) | 参数量数错、量化后仍按 FP16 算 |
| 3 | KV 实际占用(引擎日志/块数) | 前缀缓存把复用部分也算了进去 |
| 4 | 激活峰值 | 只在稳态采样,没抓到 prefill 峰值 |
| 5 | 差值 = 框架预留 + 碎片 | 碎片被当成"泄漏" |
最后一行值得强调:显存池碎片会让"理论够用"变成"实际 OOM"。解决办法是看引擎的块利用率指标,而不是反复调小并发去凑。
9. 一个预算表模板
把这张表填满,容量问题基本就清楚了。数字必须来自真实 config 与卡规格:
| 项 | 符号 | 取值来源 |
|---|---|---|
| 模型参数量 | P | config / 权重文件 |
| 权重精度字节 | b_w | 部署精度 |
| 权重显存 | P × b_w | 计算 |
| KV 每 token 字节 | k | 层数 × 2 × KV头数 × 头维度 × 字节 |
| 目标上下文长度 | L | 业务 P99 长度 |
| 目标并发 | C | 峰值并发 |
| KV 总需求 | k × L × C | 计算 |
| 激活与预留 | A | 批大小 × 经验比例,实测校准 |
| 单卡需求 | P×b_w + k×L×C + A | 与卡显存比 |
| 剩余 | 卡显存 × 利用率 − 上式 | 正数才可行 |
# 一行核对
P*b_w + k*L*C + A ≤ 卡显存 * 利用率目标(如 0.9)
# 不成立时,按"减并发 → 缩上下文 → 降精度 → 加卡"的顺序调
10. 四个常见误解
| 误解 | 实际 |
|---|---|
| 只算权重 | 权重只决定能不能装下;并发能力由 KV 决定,长上下文场景 KV 常超过权重 |
| 以为 KV Cache 是固定值 | 它随上下文与并发实时变化,峰值才是预算依据 |
| 忽略框架预留 | CUDA context、workspace、碎片通常要占掉可观比例,按 100% 算必然 OOM |
| 以为 OOM 一定是模型太大 | 多数是并发×长度算漏了,或 prefill 激活峰值没算,或碎片累积 |
11. 排错:五种显存相关的故障
| 现象 | 原因 | 处理 |
|---|---|---|
| 启动时 OOM,但模型明显装得下 | 加载峰值 + 框架预留没算 | 留出加载余量,或先量化再加载 |
| 稳态正常,长 prompt 一来就崩 | prefill 激活峰值 | 限制单批 token 总数、开 chunked prefill |
| 运行几小时后 OOM | 碎片或 KV 未释放(断连未清理) | 看块利用率,检查取消路径 |
| 显存够但并发上不去 | 准入按请求数而非 KV 字节 | 改成按剩余 KV 容量准入 |
| 多卡时单卡 OOM | 负载不均或并行切分不均 | 检查并行配置与序列长度分布 |
12. 边界:这些估算什么时候不准
| 场景 | 说明 |
|---|---|
| MoE 模型 | 总参数与激活参数不同,权重显存按总参数算,算力按激活算 |
| 极长上下文(数十万 token) | KV 成为绝对主体,权重估算的意义下降 |
| 用了前缀缓存 | 复用部分的 KV 是共享的,按单请求独立估算会高估 |
| 托管 API | 你看不到显存,这套方法只能用来反推成本与上限 |
13. 自测
- 显存的四份组成里,哪一份是常量、哪一份随流量变化?这个区别为什么重要?
- 写出权重的估算公式,并说明为什么 INT4 不等于精确的 0.5 倍。
- KV Cache 公式里的"2"代表什么?GQA 模型最容易在哪一项上算错?
- 为什么长上下文和高并发同时出现时,KV 需求是相乘而不是相加?
- OOM 最常发生在 prefill 还是 decode?对应的控制手段是什么?
- 装不下时的四种应对,按代价从小到大怎么排?为什么这么排?