批量跑图文理解之前,先把单张跑明白:固定一张图,记录它的输入宽高、分段耗时(读盘解码、预处理、前向、后处理、写盘)和显存峰值。三列数据在手,后面出现越跑越慢或中途中断时,才能判断是预处理在 resize 上反复消耗、推理段受动态尺寸拖累,还是中间变量没释放导致显存持续上涨。SenseNova-Vision 的调用参数和返回结构以你环境里的 SDK 为准,下面给的是可替换的通用接入骨架。
适用场景:图文批量任务正在调参、尚未固定批大小。操作动作:挑 10 到 20 张尺寸不同的图,逐张跑单次推理,记录宽高、预处理、前向、后处理、写盘五段耗时和显存峰值。验证方式:同一张图连跑两次,首次含模型加载和编译预热要单独丢弃,从第二次起看耗时是否收敛。风险边界:单张基线只说明单条路径的耗时结构,不能直接外推批量吞吐;批量仍要按分批循环实测,显存峰值要在批量循环里另行采集。
给单张推理加计时打点
计数点要卡在真正耗时的边界上,而且必须在 CUDA 同步之后读时间,否则拿到的是 kernel 入队时间,不是执行时间。下面的骨架放在批量脚本的公共函数里,替换 read_image、preprocess、model、decode_output 四个占位实现即可。
import time, torch
def run_one(model, preprocess, image_path):
t = {}
t0 = time.perf_counter()
image = read_image(image_path) # 读盘 + 解码
t1 = time.perf_counter()
inputs = preprocess(image) # 缩放 / 归一化 / 搬到设备
if torch.cuda.is_available():
torch.cuda.synchronize()
t2 = time.perf_counter()
with torch.inference_mode():
out = model(**inputs)
if torch.cuda.is_available():
torch.cuda.synchronize() # 异步 kernel 不同步,infer 会偏小
t3 = time.perf_counter()
result = decode_output(out) # 后处理 / 结果解析
t4 = time.perf_counter()
t['height'], t['width'] = image.height, image.width
t['read'] = t1 - t0
t['prep'] = t2 - t1
t['infer'] = t3 - t2
t['post'] = t4 - t3
t['total'] = t4 - t0
if torch.cuda.is_available():
t['peak_mem_mb'] = torch.cuda.max_memory_allocated() / 1024 ** 2
torch.cuda.reset_peak_memory_stats()
return result, t
各段含义要分开看:read 段受图片格式和磁盘影响,大图 PNG 明显比小 JPG 慢;prep 段混合了缩放、归一化和数据搬移,是统一尺寸策略最直接作用的地方;infer 段是模型前向,如果这段占比高,缩小批量或降低分辨率才有意义;post 段和写盘要单独计时,否则在批量里会被推理时间盖住。第一次调用往往包含模型加载、算子编译和显存池初始化,必须先跑一次并把那条记录丢掉。
统一输入尺寸,避免每张图各自缩放
每张图各自缩放会让耗时不可比,也让动态 shape 反复触发重算。常见三种策略,对比时用同一组图分别跑一遍单张记录表,重点看 prep 耗时的离散程度和峰值显存,而不是只看平均值。
- 直接拉伸到固定宽高:耗时最稳定、显存最好预估,批内不需要 padding。代价是改变宽高比,对含小字、表格、坐标轴的图片要抽检输出质量。
- 短边对齐 + 长边上限:等比缩放,质量更稳,但最终尺寸仍有多种取值,infer 耗时分散,批内还要 padding 到最大尺寸。
- 分桶:预先约定几档分辨率(例如短边 448、896、1344 这类档位,具体档位按你的模型和显存定),图片归到最接近的桶里,同一批只用同一个桶。这样既保留比例,又让批内尺寸一致;代价是桶边界附近的图片分辨率会被抬高或压低。
观察方法:把三种策略的 prep 耗时、infer 耗时、峰值显存分别列在同一张表里对照,如果 prep 耗时忽高忽低且与图片原始尺寸强相关,说明缩放没被约束住;如果 infer 耗时随原图尺寸线性上升而不是阶梯状,说明尺寸还没真正统一。
写一个分批循环并限制并发
批大小是显存占用的主开关,先小后大,别一上来就拉满。每批结束后显式删除中间变量并清缓存,比反复调参数更能压住显存堆积。
import gc, os, torch
def run_batches(paths, model, preprocess, out_dir, batch_size=4):
os.makedirs(out_dir, exist_ok=True)
manifest = os.path.join(out_dir, 'done.jsonl')
done = load_done_batches(manifest) # 返回已完成(status=ok)的批起始下标
for i in range(0, len(paths), batch_size):
if i in done:
continue
chunk = paths[i:i + batch_size]
results = []
try:
for p in chunk: # 先串行,别叠多进程并发
res, t = run_one(model, preprocess, p)
results.append({'image': p, 'result': res, 'timing': t})
name = f'batch_{i:06d}_{i + len(chunk):06d}.jsonl'
write_jsonl_atomic(os.path.join(out_dir, name), results)
append_jsonl(manifest, {'batch': i, 'size': len(chunk), 'status': 'ok'})
except Exception as e:
append_jsonl(manifest, {'batch': i, 'size': len(chunk), 'status': 'failed', 'error': str(e)})
finally:
del results # 释放本批中间变量
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
并发要单独控制:多进程或多线程加载器把同时驻留的图片数量抬上去,显存占用往往比 batch_size 本身更难预判。先用串行循环把 batch_size 找到能稳定跑完的值,再考虑加一个受控的预取队列。
把结果写成可续跑的文件结构
输出按批落盘,文件名里带上下标区间,续跑时能直接对齐到原批次。目录大致如下:
out/
batch_000000_000004.jsonl # 每行一个 {image, result, timing}
batch_000004_000008.jsonl
batch_000008_000012.jsonl.failed # 失败批:同名加 .failed,保留已写出的行
done.jsonl # 已处理清单,逐行追加
清单文件用 JSONL,一行一条批次记录,便于被截断时逐行跳过:
{"batch": 0, "size": 4, "status": "ok"}
{"batch": 4, "size": 4, "status": "failed", "error": "CUDA out of memory"}
续跑逻辑只认清单:读出 status 为 ok 的批下标集合跳过;failed 的批次重跑前先删掉旧的 .failed 文件,避免新旧混在一起。写文件采用先写 .tmp 再改名的方式,防止进程被中断时留下半截文件却被当成成功批。
用记录表判断瓶颈落在哪一环
把单张记录表和批量循环里的分段耗时放在一起看,几条经验判定规则可以直接用:
- 读盘与解码段长期占大头,且随图片格式波动明显:瓶颈在 IO,先把大图预转成统一格式、放到本地盘,再谈推理。
- 预处理段占大头,且耗时与原始宽高强相关:缩放策略没收住,改用固定尺寸或分桶,或者提前离线把图片缩放好。
- 前向段占大头,且批量后单张均摊耗时反而上升:先缩 batch_size,再考虑降分辨率;不要指望清缓存能救推理时间。
- 同批内越跑越慢、峰值显存逐批抬升:中间变量或缓存没释放,检查循环里是否有引用没断、是否把上一批的输出挂在了模型对象上。
- 后处理和写盘段占大头:把写盘改成攒批写或异步写,但注意这只是挪走等待时间,不是提升推理效率。
- 单张耗时正常、批量后远超线性叠加:多半是批内尺寸不一致导致 padding 浪费或反复重算,回到上一节统一尺寸。
判定时建议用「是否超过总耗时的一半」这种粗略分界先做二分,再逐步细分;具体比例要结合你的机器、图片分布和模型配置确认,别把别人的经验值当成固定阈值。