先给一个可执行的判断顺序:显存够不够,先按你手上权重文件的实际体积换算出一个“底”,再把 KV cache 按准备开放的上下文长度加上去,两项之和才是需要预留的显存。量化权重压的是那个固定的底,上下文长度影响的是随请求线性增长的那部分。短上下文、单请求时权重占大头;长上下文或几个人同时用时,KV cache 会反过来吃掉更多显存。所以顺序是:先按文件算权重,再按 config.json 算缓存,最后用 nvidia-smi 和加载日志对一遍,不要拿模型名字里的参数规模直接当显存。
判断路径:权重文件总字节 ÷ 每参字节数得到参数量,再乘精度字节数,得权重占用;KV cache 用 层数 × KV头数 × head_dim × 上下文长度 × 2 × 元素字节数 × 并发数 估。短上下文瓶颈通常在权重,长上下文或高并发时瓶颈转到 KV。取舍顺序是先压上下文、再降并发、然后才换更低量化,最后才考虑换硬件。每一步都用 nvidia-smi 周期采样加一次上下文减半对照实验验证,没验证过就不要下结论。
先看权重文件实际体积和精度
不要从模型名字猜显存,直接看下载下来的文件。以 safetensors 为例,权重目录里所有分片加起来,就是需要读进显存的那部分字节数。
du -sh ./model_dir
ls -l ./model_dir/*.safetensors | awk '{s+=$5} END {print s/1024/1024/1024 " GiB"}'
cat ./model_dir/config.json | head -40
通用算式:参数量 ≈ 权重文件总字节 ÷ 每参字节数。bf16 或 fp16 每参 2 字节,int8 每参 1 字节,int4 每参约 0.5 字节;量化权重要额外带 scale 之类的元数据,实际建议按 0.5~0.6 字节估更稳。反过来,已知参数量时,权重显存 ≈ 参数量 × 每参字节数,再加上运行时固定开销(CUDA context、推理框架自身、tokenizer、临时缓冲)。这部分开销建议在权重体积之外单独留一截余量,具体多少随框架、驱动和是否启用图优化不同,需要用下一节的采样命令确认。
举个纯算式演示,不是实测:如果某个 int4 权重包总大小 4 GiB 左右,按 0.5 字节/参反推,大约对应 80 亿量级的参数量;同一个模型换成 bf16 原始权重,文件体积会翻几倍,显存底也跟着翻。这也是换量化往往立竿见影的原因——它动的是固定成本,而不是随请求增长的部分。
把 KV cache 按上下文长度算进去
KV cache 是推理过程中为每个请求保存的历史键值,上下文长度翻倍,这部分占用基本翻倍。骨架公式如下:
KV 字节 ≈ 2 × num_hidden_layers
× num_key_value_heads
× head_dim
× seq_len
× bytes_per_element
× batch_size
公式里的值全部从模型目录的 config.json 读,不要凭印象填:num_hidden_layers 是层数;num_key_value_heads 是 KV 头数,配置里没有这个字段时就取 num_attention_heads;head_dim 一般等于 hidden_size ÷ num_attention_heads,有的配置直接给了 head_dim。bytes_per_element 通常按 2 字节(fp16/bf16)算,除非你显式开启了 KV cache 量化。用脚本读一遍最省事:
import json
cfg = json.load(open("./model_dir/config.json"))
layers = cfg["num_hidden_layers"]
kv_heads = cfg.get("num_key_value_heads", cfg["num_attention_heads"])
head_dim = cfg.get("head_dim", cfg["hidden_size"] // cfg["num_attention_heads"])
# 把 layers、kv_heads、head_dim 代回上面的公式,再乘你要开放的 seq_len
这正是长上下文比量化更吃显存的原因:权重只加载一次,是个常数;KV cache 随上下文长度和并发请求数线性增长,长上下文还会推高激活值和临时缓冲。量化权重降低的是每参字节数,一般不改变 KV 的元素字节数(除非同时量化 KV cache),所以对“单请求、超长上下文”这类场景帮助有限。判断时可以先按目标长度算出 KV 占用,再和权重占用比一比,谁大就先动谁。
起服务后盯显存峰值和加载日志
前面的估算只是量级判断,落地要靠采样。开服务前起一个采样,把占用按秒记进文件,跑完再找峰值:
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total \
`--format`=csv,noheader,nounits -l 1 | tee vram.log
# 只留数字时找峰值
awk -F', ' '{print $2}' vram.log | sort -n | tail -1
日志重点看三处:启动阶段打印的模型参数规模与 dtype;加载完成后框架自报的显存占用;以及请求进来时的增长曲线。显存不足的典型特征是报错行里出现“Tried to allocate X MiB”,且此前 memory.used 已经贴近 memory.total,说明不是分配策略问题,而是真的不够。定位时直接过滤关键字:
grep -iE "out of memory|OutOfMemoryError|Tried to allocate|CUDA error" server.log | tail -20
如果报错发生在服务启动阶段、还没接请求,问题在权重加固定开销,压上下文没有意义;如果启动正常、一接长请求就崩,把注意力转到 KV cache 和并发数上。
做一次上下文减半的对照实验
同一段固定输入、同一并发(先按 1 跑),只改上下文上限,跑两次:第一次给足你计划上线的长度,第二次减半。不同框架参数名不一样,vLLM 常是 `--max-model-len`,llama.cpp 是 -c/`--ctx-size`,按你实际用的框架替换。每次用同一份采样脚本记录,别用两种方法测两次。
# 第一次
启动参数: max_len = 计划上线的长度
采样: nvidia-smi ... -l 1 | tee vram_len_full.log
记录: 峰值显存 / 是否成功返回 / 首 token 时间
# 第二次,除上下文减半外其余不变
启动参数: max_len = 上一步的一半
采样: nvidia-smi ... -l 1 | tee vram_len_half.log
记录: 同上
判断规则很直接:减半后峰值明显下降,且短上下文下同样的输入能正常返回,说明瓶颈在 KV cache,优先压上下文、降并发;峰值几乎不动,说明瓶颈在权重和固定开销,此时缩上下文换不来空间,方向应该是换更低量化精度、换更小参数规模的权重,或者加卡。两次都失败、报错都停在加载阶段,就更确认是权重问题。
跑不动时的降级顺序
- 先缩上下文长度。把对外暴露的最大长度降到业务真实够用的值,而不是框架默认上限。验证方式:按上一节做减半对照,观察峰值是否下降、目标输入能否完整返回。
- 再降并发数与批大小。调低同时处理的请求数或 batch 上限。验证方式:并发 1 与目标并发各跑一次并记录峰值;若峰值随并发近似线性上升,说明 KV 按请求累加,降并发确实有效。
- 然后才动量化精度。从高位宽换到 int8、int4 一类。验证方式:换完先重新按文件体积算一遍权重占用,再用一组固定问题对比换前换后的回答是否还能接受——量化会改变输出,不能只看显存数字。用 CPU offload 或系统内存兜底能让你先跑起来,但速度明显变慢,只当临时手段,不是容量方案。
- 最后换硬件或换更小参数规模的权重。验证方式:先用第一节的算式算出目标规模在目标精度下的权重占用,把目标上下文长度的 KV 也算进去,两者相加不超过单卡可用显存再动手,避免换了卡仍然卡在上下文档位上。
这套顺序的共同点是每一步都可回退、都可观测:先动配置里能改的,再动模型文件,最后才动硬件。