先把单图推理跑通再谈多图——MiniCPM-V 4.7 在消费级显卡上的显存分配

文章导读
单张图能推理、第二张就显存不足,通常不是权重突然变大,而是图片张量、上下文缓存和首次分配的工作区叠加到了同一块显存上。判断顺序建议是:先把单图稳态占用测准,再在同一进程里追加第二张图看增量,最后才决定降权重精度还是降输入规模。下面给的是可执行的观察步骤和降配顺序,读数一律以自己的 nvidia-smi 和 torch 显存接口为准。
📋 目录
  1. A 跑通单图输入并记录稳定后的显存占用
  2. B 在同一进程里追加第二张图观察增量
  3. C 把图片张量与上下文缓存分开观察
  4. D 按降配顺序逐项尝试:量化、输入分辨率、上下文长度
  5. E 把能稳定跑多图的配置固化成启动参数
A A

单张图能推理、第二张就显存不足,通常不是权重突然变大,而是图片张量、上下文缓存和首次分配的工作区叠加到了同一块显存上。判断顺序建议是:先把单图稳态占用测准,再在同一进程里追加第二张图看增量,最后才决定降权重精度还是降输入规模。下面给的是可执行的观察步骤和降配顺序,读数一律以自己的 nvidia-smi 和 torch 显存接口为准。

单图能跑,说明权重与最低显存基本对齐;第二张图 OOM,多半来自图片张量、上下文缓存和首次分配工作区的叠加。建议先在同一个进程里做 1 图与 2 图的显存读数对照,再按量化、输入分辨率、上下文长度的顺序逐项降配。读数要同时看 nvidia-smi 与 torch.cuda.memory_allocated,不同显卡、驱动和权重版本下的阈值并不通用。

跑通单图输入并记录稳定后的显存占用

第一个坑是把加载峰值当成常态占用。加载阶段会有 CUDA context、算子工作区等一次性开销,用 max_memory_allocated 看到的数字往往比稳态高。建议用 reset_peak_memory_stats 把峰值清零,再分三个时间点采样:权重加载完成后、输入张量搬到 GPU 后、generate 返回后。同时在另一个终端跑 nvidia-smi -l 1 交叉确认,避免只信进程内读数。

import torch
from transformers import AutoModel, AutoProcessor  # 实际类名按权重仓库的用法替换

MODEL_PATH = "./MiniCPM-V-4_7"   # 换成自己的权重目录

def mem(tag):
    alloc = torch.cuda.memory_allocated() / 1024**3
    peak  = torch.cuda.max_memory_allocated() / 1024**3
    print(f"[{tag}] allocated={alloc:.2f}G peak={peak:.2f}G")

torch.cuda.reset_peak_memory_stats()
model = AutoModel.from_pretrained(MODEL_PATH, trust_remote_code=True,
                                  torch_dtype=torch.bfloat16).cuda().eval()
processor = AutoProcessor.from_pretrained(MODEL_PATH, trust_remote_code=True)
mem("加载后")      # 采样点 1

prompt = "描述这张图"
img1 = "a.jpg"
inputs = processor(images=[img1], text=prompt, return_tensors="pt").to("cuda")
mem("推理前")      # 采样点 2

out = model.generate(**inputs, max_new_tokens=128)
mem("推理后")      # 采样点 3

del inputs, out
torch.cuda.empty_cache()
mem("释放后")

processor 的调用签名各版本可能有差异,替换成权重仓库里给的写法即可。三个读数里,「加载后」到「推理前」的差值大致对应图片张量和 prompt 的占用,「推理前」到「推理后」的峰值包含 KV 缓存和输出长度带来的增长。这个基线没测稳之前,后面的对照没有意义。

在同一进程里追加第二张图观察增量

不要用两次启动进程来比较 1 图和 2 图。第一次启动包含进程级的一次性分配,会把第二张图真实增量掩盖掉。正确做法是同一个进程内先跑完 1 图,再组装 2 图输入,比较打包前和打包后的 allocated。

先把单图推理跑通再谈多图——MiniCPM-V 4.7 在消费级显卡上的显存分配
torch.cuda.reset_peak_memory_stats()
base = torch.cuda.memory_allocated()

inputs2 = processor(images=[img1, img2], text=prompt, return_tensors="pt").to("cuda")
after_pack = torch.cuda.memory_allocated()
print("打包增量G =", (after_pack - base) / 1024**3)

out2 = model.generate(**inputs2, max_new_tokens=128)
print("峰值G =", torch.cuda.max_memory_allocated() / 1024**3)

判读方式:如果「打包增量」很小而峰值涨幅很大,问题主要在 KV 缓存和生成步数;如果打包本身就吃掉明显一块,说明单张图的视觉张量占用偏高,降分辨率或切片数的收益会更大。多张图放进一次 batch 还是逐张送,读数差别通常也在这一步体现。逐张送属于绕过峰值的做法,不是性能优化方案,只在显存确实不够时作为兜底。

把图片张量与上下文缓存分开观察

两者要分开测,不能只看总和。做法是固定一个变量、改另一个变量:固定 prompt 和 max_new_tokens,把图片张数从 1 加到 2、3;再固定图片张数,把上下文长度按倍数拉长。每次记录打包前后的 allocated 与 generate 后的 peak,填进同一张表里对比走势。

图片张数上下文长度打包前 allocated打包后 allocated推理后 peak
1短 prompt记录值记录值记录值
2短 prompt记录值记录值记录值
1长 prompt记录值记录值记录值
2长 prompt记录值记录值记录值

如果图片张数翻倍带来的增量远大于上下文翻倍,降配就优先动图片输入相关的参数;反之则优先压缩 max_new_tokens 和保留的对话轮数。观察时注意 batch 里的 padding 也会占显存,长度差异大的多图输入容易被 padding 放大占用。

先把单图推理跑通再谈多图——MiniCPM-V 4.7 在消费级显卡上的显存分配

按降配顺序逐项尝试:量化、输入分辨率、上下文长度

建议按下面顺序逐项试,每改一项就重跑同一段脚本,只记录 allocated 和 peak,不要一次改多个参数。

  1. 量化。配置位置在模型加载处,把 dtype 换成 4bit 或 8bit 的量化配置(如 BitsAndBytesConfig 的 load_in_4bit、bnb_4bit_compute_dtype)。可调范围主要是 bf16 / 8bit / 4bit 三档。这一档对「加载后」读数影响最大,需要确认视觉塔部分是否也被量化,否则省下的可能没预期多。
  2. 输入分辨率与切片数。配置位置在 processor 侧,常见字段是图像边长和最大切片数。切片数直接决定视觉 token 数量,多图场景下这项通常比单图更敏感。可调范围从默认值往下逐档减,先降到 1 张切片看是否能过。
  3. 上下文长度。配置位置在生成参数和会话管理里,主要是 max_new_tokens、历史轮数、system prompt 长度。这一档压的是推理后峰值,对「打包后」读数几乎没有影响,所以放在图片相关项之后调。
  4. 并发与批量。把一次请求里的图片数限制住,超出的图拆成多次单图调用,属于兜底手段,只在前面几档都降到位仍 OOM 时使用。

把能稳定跑多图的配置固化成启动参数

试出可用组合后,把它写进配置文件或启动参数,避免每次靠重启改代码试错。

# config.yaml
model_path: ./MiniCPM-V-4_7
dtype: bfloat16          # 显存紧张时改 4bit
load_in_4bit: false
max_slice_nums: 1        # 多图时优先降这里
max_new_tokens: 256
max_images_per_request: 2
python run_infer.py `--config` config.yaml `--images` a.jpg b.jpg

复现验证要做一次:用这个配置连跑两遍同样的两图请求,对比「加载后」的 allocated 和两图推理后的 peak 是否稳定在接近水平,并确认没有触发逐张降级逻辑。如果两次读数波动明显,先检查是否有别的进程占用显卡,再确认 max_images_per_request 与批处理代码是否一致。