想在一台单卡机器上跑 LingBot-Vision,显存够不够,不取决于模型名字,而取决于三个你能直接改的变量:输入分辨率、推理精度、批量大小。这三者里任何一个翻倍,显存峰值都可能跟着变。所以顺序应该是先测出真实基线,再决定单卡直跑、降级配置跑,还是换机器;反过来先降精度、后发现问题其实出在分辨率上,会白折腾一轮。
固定两个变量、只动一个,用 nvidia-smi 分别在加载后、首次前向、连续前向之后采三个时点的显存峰值,做三组对照记录。分辨率通常对激活占用影响最直接,批量次之,精度主要影响权重和部分算子实现。单卡吃紧时的降级顺序建议是:先压批量,再压分辨率,然后换 bf16/fp16,最后才考虑分块推理或换卡。梯度检查点只在反向传播时省显存,纯推理开启基本看不到变化,别把它当成省显存的手段。
用 nvidia-smi 记录不同输入尺寸下的显存峰值
先拿到基线,再谈优化。让推理脚本真正跑起来,同时在另一个终端持续采样。下面两条命令按需要选一条:轮询 CSV 便于事后看趋势,dmon 输出更紧凑,适合盯着终端看。
nvidia-smi `--query-gpu`=timestamp,memory.used,memory.total,utilization.gpu `--format`=csv -l 1 | tee mem_watch.csv
nvidia-smi dmon -s mu -d 1
采样时机要卡在三个位置,缺一个都可能误判:一是模型加载完成后静置十秒左右,这时看到的是权重加 CUDA 上下文的基线;二是第一次前向结束,激活的峰值通常出现在这一步,OOM 也多发生在这里;三是连续跑十到二十次之后,用来判断显存是否持续往上走,也就是有没有缓存膨胀或泄漏。
如果脚本里能改代码,再用 PyTorch 侧的口径交叉验证一次,注意 nvidia-smi 的 memory.used 是设备视角,包含上下文和分配器缓存,通常比 max_memory_allocated 大,写记录时要注明用的是哪个口径,否则换台机器对不上。
import torch
torch.cuda.reset_peak_memory_stats()
# 加载模型、跑一次前向
print('allocated MiB:', torch.cuda.max_memory_allocated() / 1024**2)
print('reserved MiB:', torch.cuda.max_memory_reserved() / 1024**2)
记录表至少包含这些字段:采样时间、GPU 型号与总显存、驱动/CUDA/PyTorch 版本、精度、输入分辨率 W×H、批量、加载后 used、首次前向峰值 used、连续前向峰值 used、是否 OOM。字段看起来多,但缺了版本号,下次换环境复现时数字对不上会很难查。
按输入分辨率、精度、批量列估算表
三组对照,每次只动一个变量,其余两个钉死。分辨率按预处理实际支持的档位取,不要凭空造一个 448 之外的尺寸;精度按你的加载方式支持的范围取;批量取 1、2、4、8 就够看出斜率。
| 对照组 | 固定条件 | 变量取值 | 观测峰值(MiB) | 是否 OOM |
|---|---|---|---|---|
| 分辨率 | bf16 / batch 1 | 224×224 | 待填 | 待填 |
| 分辨率 | bf16 / batch 1 | 336×336 | 待填 | 待填 |
| 分辨率 | bf16 / batch 1 | 448×448 | 待填 | 待填 |
| 分辨率 | bf16 / batch 1 | 512×512 | 待填 | 待填 |
| 精度 | 448×448 / batch 1 | fp32 | 待填 | 待填 |
| 精度 | 448×448 / batch 1 | bf16 | 待填 | 待填 |
| 精度 | 448×448 / batch 1 | fp16 | 待填 | 待填 |
| 批量 | 448×448 / bf16 | 1 | 待填 | 待填 |
| 批量 | 448×448 / bf16 | 2 | 待填 | 待填 |
| 批量 | 448×448 / bf16 | 4 | 待填 | 待填 |
看哪个变量的行之间落差最大,那个就是你的主约束。经验上,激活占用会随分辨率对应的像素量明显上升,视觉类模型在被切分成 patch 之后,分辨率翻一倍带来的激活增长往往超过权重本身;批量接近线性叠加;精度主要压权重,对激活的影响要看具体算子和是否用了融合算子,不要预设它一定按比例下降。
切换半精度与梯度检查点观察显存变化
精度的开关通常在加载阶段,不是在推理阶段。以下骨架按你自己的加载方式替换类名和参数即可:
model = YourModel.from_pretrained(path, torch_dtype=torch.bfloat16).cuda()
# 或者加载后转换
model = model.to(torch.bfloat16)
# 推理时的自动混合精度上下文
with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
out = model(inputs)
切换后容易踩的两个坑:输入张量 dtype 没跟着换,会直接报 dtype mismatch;以及 fp16 在某些动态范围较大的算子上出现 NaN 或输出异常,bf16 一般不需要 loss scaling。所以对比记录除了显存数字,还要看同一张输入图跑出来的输出是否出现 NaN、数值范围是否明显偏离。
梯度检查点要单独说清楚:它是用反向时重新计算换掉激活的保存量,纯前向推理本来就不保存反向激活,因此在推理脚本里开启它,显存峰值通常看不出变化。可以把开关位置记下来,但不要指望它解决推理 OOM。如果你的流程里包含微调或蒸馏,它才有意义。
model.gradient_checkpointing_enable() # 仅在有反向传播时才有意义
对比记录时,把权重占用、首次前向峰值、连续前向峰值三行分别填上开启前后的值。如果权重明显下降但首次前向峰值没怎么动,说明你的瓶颈在激活而不在权重,接下来的取舍重点应该放在分辨率和批量上。
单卡跑不动时的降级顺序
按代价从低到高排,前面能解决就不要跳到后面:
- 降批量。代价是吞吐下降、并发请求排队变长,对单次结果质量没有影响。这是代价最小的一步,先做。
- 降分辨率。代价是小目标和细粒度识别的能力下降,需要在你的业务样本上确认可接受。它往往是最能真正省下显存的一步,但也最影响效果,不要随手改。
- 换精度(fp32 换 bf16/fp16)。代价是数值范围和输出稳定性需要重新比对;权重占用会降,激活占用不一定同比例降。
- 分块或切片推理,把大图切开分别处理再拼接。代价是切边处的结果可能不一致,工程复杂度上升。
- 换机器或多卡。代价是成本和部署复杂度;多卡还要先确认推理框架是否支持模型切分,不是插上卡就能用。
判断走哪条路的依据是 OOM 出现的时点。如果加载阶段就爆,问题在权重,优先考虑换精度或换显存更大的卡;如果加载正常、前向中途爆,问题在激活,优先压批量和分辨率。用显存换到主机内存这类做法只能当临时救急,延迟会变得不可预估,不要写进正式部署方案。
把结论写成可复现的配置清单
把最终选定的配置和当时的显存数字写在一起,放进部署 README。下次换卡、换版本或换输入尺寸时,按同样的三变量顺序重跑一遍,就能知道差异来自哪里。
model: LingBot-Vision
dtype: bfloat16
input_size: 448x448
batch: 2
gradient_checkpointing: false
gpu: 型号 / 总显存 MiB
driver_cuda_torch: 版本号
mem_after_load: __ MiB
mem_first_forward_peak: __ MiB
mem_after_20_iters_peak: __ MiB
sample_cmd: nvidia-smi `--query-gpu`=timestamp,memory.used `--format`=csv -l 1
清单里的每一项都要能被验证:配置项对应启动参数或环境变量,显存数字对应采样命令的输出,版本号对应环境里能查到的值。留空的显存数字不要靠估,跑一次采样补上即可。这样一来,下次读者遇到显存不够,先翻这份清单,就知道该动分辨率还是该动精度。