1. 为什么别人批量推理0.1秒,你却要30秒?
先说个我自己经历过的场景。两年前,我把训练好的YOLOv5模型放到一台Linux服务器上,准备跑一批监控视频抽帧图。心想模型都训好了,直接写个循环调用试一下,结果一跑傻眼了:单张图片推理耗时30秒,批处理跑完几千张图,估计要一整天。
后来花了一个周末定位问题,一步步把耗时压到单帧0.1秒左右,性能提升了近300倍。这里面的核心并不是什么高深魔法,而是把几个最容易忽略的点都做对了:模型格式、推理后端、批处理策略、数据流水线、后处理优化。
这篇文章不聊训练,只聊部署推理。适合那些模型已经训好、正准备往Linux服务器上部署,或者部署完发现速度惨不忍睹想优化的朋友。你不需要懂底层汇编,只要跟着我把每个环节核对一遍,大概率能把速度提升一个数量级以上。先记住这句话:99%的“慢”,不是模型本身的锅,而是你的推理管线压根没打通。
1.1 先搞清楚瓶颈在哪
很多人上来就怪显卡不够好。但我见过不少案例,哪怕是中端卡,只要管线没做对,照样跑出CPU级别的速度。所谓“30秒/帧”,通常不是单个环节慢,而是整条流水线处处都有问题:
- 模型还是PyTorch动态图,没转TensorRT或ONNX,推理时边解释边执行;
- 输入图片用OpenCV逐个读取、缩放、归一化,全部串行;
- 每张图片单独成batch,没有批处理,显存利用率和GPU利用率都极低;
- CPU端的数据预处理和GPU端的推理没有重叠,等GPU推理时CPU空闲,等CPU处理时GPU空闲;
- 后处理(阈值筛选、NMS)全在Python循环里逐框做,甚至比推理还耗时。
还有一个很常见的隐藏瓶颈:你机器上的OpenCV是用CPU版本编译的,或者没启用硬件优化指令,读图和resize都很慢。这些细节叠加起来,单帧30秒完全不意外。
所以拿到服务器第一件事不是调代码,而是做一件事:先跑一次profiling。你可以在代码里记录每个阶段耗时,分别统计“读图+预处理”“模型推理”“后处理”三个部分的占比。大多数情况下你会看到,推理不是最慢的,数据装载和后处理才是。把这三个瓶颈分别解决,速度自然就上去了。
1.2 从30秒到0.1秒到底经历了什么
我直接给你看一组我实际优化中的数据(不是跑分,是同一台服务器同一批图片的实测结果):
| 阶段 | 单张耗时 | 说明 |
|---|---|---|
| 原始版本(PyTorch CPU推理,无批处理) | 30s–35s | 串行读图,逐张推理,CPU模式下动态图开销巨大 |
| 切换GPU + PyTorch半精度推理 | 1.2s–1.5s | 显存和GPU利用率上来了,但预处理仍是瓶颈 |
| 转ONNX + TensorRT FP16,batch=4 | 0.15s–0.2s | 模型计算速度大幅提升,GPU利用率明显提高 |
| 增加流水线预处理 + 动态批处理 | 0.1s–0.12s | CPU预处理和GPU推理并行,几乎无等待时间 |
这里面每一步都是有意义的。第2步解决“有没有用上GPU”的问题;第3步解决“模型本身算得够不够快”的问题;第4步解决“整体管线是否空闲等待”的问题。
另外,如果你的任务只是“批量推理图片”,不需要实时摄像头流,那么吞吐量比单帧延迟更重要。这意味着你完全可以适当提升batch size,用一点延迟换取更高的每秒处理帧数。我会在后面的代码实战里告诉你具体怎么设计这个管线。
2. 手把手搭建YOLO批量推理环境
2.1 硬件与驱动基础
Linux服务器上跑YOLO,第一件事是确认GPU能用。执行nvidia-smi,如果提示找不到命令,说明驱动没装;如果能看到显卡列表,注意看右上角的CUDA版本号,那个是驱动支持的CUDA版本,不是说你机器上已经装了CUDA toolkit。
这里有个常见误区:很多人以为只要装一个很新的CUDA就万事大吉。实际上,驱动、CUDA、深度学习框架、TensorRT之间存在兼容矩阵。实际选型时,我建议遵循“先定TensorRT版本,再倒推其他组件版本”的思路,因为TensorRT对CUDA和PyTorch的版本要求最苛刻。
举个我目前比较稳妥的组合:
- 系统:Ubuntu 20.04或22.04
- 显卡驱动:535.x及以上
- CUDA:11.8(或12.1,取决于TensorRT选择)
- TensorRT:8.6.1(支持CUDA 11.8和12.x)
- PyTorch:2.0/2.1(按官方匹配表安装对应cu118版本)
注意:不要直接apt安装cuda全家桶,很占空间且容易搞乱环境。更推荐的方式是使用Miniconda,单独建一个推理环境,然后conda install cudatoolkit cudnn,再pip安装PyTorch和TensorRT的Python包。这样即使把环境搞坏了,删掉重来也就几分钟的事。
2.2 Python环境与依赖版本
我用的是Python 3.10,依赖版本大概是:
- opencv-python == 4.8.1.78(注意,opencv-python和opencv-contrib-python二选一,不要同时装)
- torch == 2.1.0(+cu118)
- tensorrt == 8.6.1
- onnx == 1.14.0(转换模型用)
这里有个大坑:PyTorch版本和TensorRT的ONNX解析器存在兼容性问题。比如PyTorch 2.2导出的某些节点,TensorRT 8.5不认识,结果转换失败。如果你只是做推理,没必要追新版本,选一个经过大量验证的稳定组合就够用了。
还有opencv,我见过有人因为装错版本,导致读取图片或resize时异常慢。建议编译opencv时开启-DCPU_BASELINE=AVX2之类选项,或者直接用官方预编译包。绝大多数预编译包已经带SSE等优化,别自己乱编译,除非你有明确需求。
2.3 CUDA/cuDNN/TensorRT选型
如果你只想快速跑通,可以用PyTorch直接推理,不装TensorRT。但如果你追求0.1秒/帧级别,TensorRT几乎是绕不开的。它的推理引擎会对卷积层做融合、算子自动选择、内存复用,效果立竿见影。
TensorRT选版本时,除了看官网兼容表,还要看你的显卡架构。以常见的安培架构和Ada架构为例,TensorRT 8.x都能支持。但老卡(比如GTX 10系)的架构较老,FP16支持弱,可能收益不明显。建议先查一下显卡的compute capability,再决定要不要用TensorRT。
另外,不要忽略cuDNN。PyTorch推理时会调用cuDNN卷积优化,如果cuDNN版本和CUDA不匹配,可能报错或性能异常。用conda安装cudnn一般能自动匹配。如果你在Docker里部署,推荐直接用官方镜像,比如nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu20.04,省去很多环境配置时间。
3. 提升推理速度的关键操作
3.1 数据加载:别再一张张读图片了
大部分批量推理项目的图片来源是文件夹、视频抽帧、或者远程HTTP接口。最慢的写法是:
for img_path in img_list: img = cv2.imread(img_path) # 磁盘IO img = cv2.resize(img, (640, 640)) # CPU缩放 img = img / 255.0 # 归一化 img = np.transpose(img, (2, 0, 1)) # HWC转CHW ...这个写法的问题在于:每一步都在等着上一步完成,CPU、磁盘、GPU完全串行。而且对于几千张图片,磁盘IO常常被忽略。我实测过机械硬盘上读一张512KB的图片,随机读取耗时约20ms,但加上文件系统缓存命中率问题,波动会很大。如果是网络盘,网络延迟会被无限放大。
优化方案有三种:
先把小图片打包成一个大文件:用
lmdb或tar归档,把几千张图打包成一个或几个大文件,顺序读取的速度远快于随机读取。实测用LMDB后,批量读取速度提升5到10倍。使用多线程预取:即使不打包文件,也可以用生产者-消费者模式,开2到3个线程只负责读图和基础预处理,把处理好后的张量放到队列里,GPU推理线程直接从队列里取,这样磁盘IO和GPU计算重叠,总耗时大幅下降。
如果图片是视频抽帧,尽量先用FFmpeg抽帧成H.264裸流,再用NVIDIA的硬件解码器(如果有),解码效率极高。但注意,不是所有Linux服务器都有NVIDIA解码模块,需要看显卡序列号里有没有NVENC/NVDEC,消费级卡通常有,部分专业卡也有。
总之,数据加载的目标是:让CPU预处理的速度永远快于GPU推理,GPU才不会等你。
3.2 模型优化:FP16精度与模型转换
PyTorch的默认推理是FP32,虽然精度高,但速度慢、显存占用高。只要你的模型不是对精度极度敏感,用半精度FP16推理是首选。在PyTorch里,直接加一句model.half()并把输入张量.half(),就能切开一半的显存占用。注意,如果模型里有BatchNorm层,请确保推理模式下BatchNorm的统计量已经固定,否则半精度下结果可能抖动。
不过,单纯FP16还不够,终极方案是转成TensorRT引擎。转换流程一般是:
PyTorch → ONNX → TensorRT Engine
导出ONNX的关键点是固定或设置动态维度:
import torch # 以YOLOv5为例,dummy输入 [1, 3, 640, 640] dummy = torch.zeros(1, 3, 640, 640) model.eval() torch.onnx.export( model, dummy, "yolov5s.onnx", opset_version=17, # 高版本兼容性更好 input_names=["images"], output_names=["output"], dynamic_axes={"images": {0: "batch", 2: "height", 3: "width"}, "output": {0: "batch", 1: "anchors"}} )这里建议先不要动态导出,而是固定为640x640分辨率,先跑通。TensorRT对静态shape最友好,性能也最稳定。等静态版本跑通后,再考虑动态shape。
导出ONNX时还有一个容易忽视的点:YOLO的很多仓库输出的结果包含转换后的预测框,导出时应该把decode等操作一并包含在模型里,这样在TensorRT里直接输出最终的x1y1x2y2坐标和置信度、类别,省去后续Python端解码。不过,有的团队喜欢把解码留在后处理,这会导致后处理耗时增加。我建议模型输出原始偏移量,把decode放到后处理里,用numpy向量化实现,这样TensorRT计算更轻量,后处理也能灵活调整。
3.3 批处理:从batch=1到batch=动态
批处理是GPU吞吐最直接的放大器。单张推理时,GPU的很多计算单元可能处于空闲状态;batch=8时,显卡并行计算的利用率更高,平均每帧耗时会显著下降。
但批处理也有讲究:
- 固定batch size:TensorRT默认引擎是固定batch的。比如你生成一个batch=4的引擎,就只能一次输入4张图。如果实际只来了2张,需要补padding或者重新生成引擎。
- 动态batch:可以在ONNX导出时把batch设为动态,然后在TensorRT builder里设置优化profile,比如指定
min=1,opt=4,max=16。这样推理时可以根据队列中积压的图片数量自动选择batch大小,更灵活。 - batch size不是越大越好:显存有限,batch太大直接OOM;同时小批量的任务延迟会增加。批量推理场景建议设置一个上限,比如默认 batch=8,最大不超过16。
道理很简单,就像一个餐厅,单人桌虽然每次只接待一桌客人,但餐桌没坐满,厨师的效率也低;改成大桌拼桌后,同样一批食材能喂饱更多人。但拼桌的人数不能无限多,厨房(显存)会撑不住。
4. 批量推理代码实战
4.1 初版代码:最慢的写法
先贴一个最典型的慢速版本,看看里面有多少问题:
import cv2 import torch import time model = torch.load("yolov5s.pt", map_location="cpu")["model"].float() model.eval() img_paths = ["path/to/img1.jpg", "path/to/img2.jpg", ...] results = [] for p in img_paths: img0 = cv2.imread(p) # 1. 串行读图 img = cv2.resize(img0, (640, 640)) # 2. 串行resize img = img[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB、HWC转CHW img = torch.from_numpy(img.copy()).float() / 255.0 img = img.unsqueeze(0) # 单张batch start = time.time() pred = model(img) # 3. CPU浮点全精度推理 results.append(pred) print(f"frame {p} cost {time.time()-start:.2f}s")这段代码运行在CPU上,model.load会直接跑在CPU。哪怕你服务器有GPU,也会因为map_location="cpu"而完全用不上。这其实就是很多“30秒/帧”的真实来源。我甚至见过有人把模型加载出来之后,每张图都重新加载一次权重,那更惨,30秒都不够。
4.2 优化后代码:异步Pipeline
下面给一个可以工作的优化版。这里我用torch.cuda.Stream实现CPU预处理和GPU推理的重叠。基本思路是:
- 使用线程池或者队列,不断把读取和预处理好的张量放入列表;
- 当累积到目标batch时,一次性扔给GPU推理;
- 推理完成后异步执行后处理,避免阻塞下一批数据。
import cv2 import torch import numpy as np from concurrent.futures import ThreadPoolExecutor from queue import Queue import threading device = torch.device("cuda:0") model = load_tensorrt_engine("yolov5s.engine") # TensorRT引擎路径 def preprocess(img_path): img0 = cv2.imread(img_path) # 保持宽高比后resize到640x640,并做letterbox填充 # 这里假设你有一个函数letterbox img = letterbox(img0, new_shape=(640, 640))[0] img = img[:, :, ::-1].transpose(2, 0, 1) # to CHW BGR->RGB img = np.ascontiguousarray(img, dtype=np.float32) img /= 255.0 img = torch.from_numpy(img).unsqueeze(0) return img def batch_inference(img_paths, batch_size=8): results = {} buffer = [] lock = threading.Lock() # 使用线程池并发读图 with ThreadPoolExecutor(max_workers=4) as executor: futures = {executor.submit(preprocess, p): p for p in img_paths} for fut in futures: tensor = fut.result() path = futures[fut] buffer.append((path, tensor)) if len(buffer) >= batch_size: batch_paths = [x[0] for x in buffer] batch_tensor = torch.cat([x[1] for x in buffer], dim=0).to(device, non_blocking=True) with torch.no_grad(): preds = model(batch_tensor) # 后处理函数处理preds,得到检测框 results.update(postprocess(preds, batch_paths)) buffer.clear() # 处理剩余不足一个batch的数据 if buffer: batch_paths = [x[0] for x in buffer] batch_tensor = torch.cat([x[1] for x in buffer], dim=0).to(device) with torch.no_grad(): preds = model(batch_tensor) results.update(postprocess(preds, batch_paths)) return results这个版本虽然还有改进空间,但相比初版已经有了质变:线程池并发读图、batch张量输入GPU、GPU推理一次处理8张。你如果跑一批1000张图,会发现整体吞吐量大幅提升。
但注意,上面代码在torch.cat时如果每张图的shape不同(由于没有letterbox),会直接报错。所以在预处理阶段必须把每张图resize到相同大小,letterbox是一个好的选择。另外,转TensorRT引擎时如果你导出的batch是固定值,就必须保证每次输入batch一致,否则会报错或推理错误。
4.3 多进程并行批次
如果单块显卡利用率已经打满,瓶颈变成CPU预处理或后处理,那么可以考虑多进程并行多个推理流。这里要分情况:
- 如果是TensorRT引擎,同一块GPU上生成多个CUDA stream,不同进程各自使用同一个engine文件,显存可以共享减半,适合多路并行;
- 如果是PyTorch模型,直接多进程并行会复制模型到多个进程,显存翻倍。一般不建议;
- 如果机器有多块GPU,最自然的做法是“进程绑卡”,每个进程使用一块GPU,把图片列表按进程数切成多份,并行处理。
举个例子,你有一台4卡服务器,batch=8,每卡推理耗时0.1秒/帧,如果启动4个进程各处理一份数据,总吞吐量就变成原来的近4倍。注意多进程之间不能用同一个进程内Queue直接传递大张量,最好把每个进程的输入输出写成独立文件,或者用内存映射文件来通信。
实际工作中,我通常用Python的multiprocessing.Pool把任务分片,每个worker自主完成“读图-预处理-推理-后处理-写结果”整个流程,这样的话进程间交互最少,逻辑也最简单。
import multiprocessing as mp def worker(gpu_id, img_paths): set_gpu(gpu_id) # 加载独立engine,处理img_paths for batch in chunks(img_paths, batch_size=8): ... if __name__ == "__main__": gpus = [0, 1, 2, 3] split_chunks = np.array_split(img_paths, len(gpus)) processes = [mp.Process(target=worker, args=(gpu, chunk)) for gpu, chunk in zip(gpus, split_chunks)] for p in processes: p.start() for p in processes: p.join()这里有个容易踩的坑:多个进程同时加载同一个TensorRT engine文件,如果文件是从本地路径读取的,每个进程都会独立反序列化并初始化解码器,内存开销大。建议先读成字节串,再用runtime.deserialize_cuda_engine(bytes)加载,这样进程间可以共享同一份字节码副本,减少内存占用。
5. 99%的人踩过的坑(排查速查表)
5.1 显存不够的坑
最常见的问题就是CUDA out of memory。先检查你的显存是否真的满了,用nvidia-smi看进程占用。很多时候是因为前一次运行没正确释放显存——Python的垃圾回收不及时,或者模型在全局变量里被引用导致无法释放。建议代码里在推理结束时调用torch.cuda.empty_cache(),以及使用with torch.no_grad():上下文。
还有一个隐含的显存占用:TensorRT engine构建时需要额外的临时显存。在构建engine时,可以设置builder.max_workspace_size,显存越大构建越快。但运行时engine的大小通常比构建时workSpace小得多。如果你同时运行多个engine,注意每个engine占用的是定义的空间。对于多进程场景,显存分配不均匀也会造成OOM。我的经验是,先把batch设小,跑通后再逐步调大,OOM发生时优先降低batch而不是降低分辨率。
5.2 推理结果反了/坐标偏移的坑
YOLO输入颜色顺序是RGB,OpenCV读取的是BGR。如果你忘了转换,模型很可能莫名其秒地漏检或误检,颜色通道顺序错了。很多仓库在导出时假设输入是RGB,但你直接用cv2读图,必须先img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB),或者在预处理中做img = img[:, :, ::-1]。
坐标偏移常见原因是没有考虑letterbox填充。如果你resize时直接拉伸到640x640,那么检测框坐标是在拉伸图上的;但你要画到原始图上,就需要按比例映射回来。如果你用了letterbox填充,坐标还要减去填充区域偏移。很多人在这一步直接换算错误,导致框的位置离谱。
另一个容易被忽略的是后处理阶段的置信度阈值设置。YOLOv5导出的原始输出包含大量低置信度的框,如果你在后处理时没有过滤,NMS计算量暴增,速度下降明显。一般设置conf_thres=0.25,iou_thres=0.45即可,这两个参数对速度和准确率的影响很大,建议做个简单网格搜索。
5.3 线程安全与NMS的坑
如果你用多线程并发调用同一个PyTorch模型做推理,可能会遇到线程安全问题。PyTorch的torch.inference_mode下多线程调用模型一般是安全的,但后处理中的NMS(非极大值抑制)如果使用了某个库的全局上下文,就可能出现竞争条件。
我见过的坑是:一批8张图的预测结果,后处理时用torchvision.ops.nms,而它内部可能调用C++实现,本身线程安全,但在某些旧版本中存在内存泄漏或者数据竞争。更稳的办法是使用numpy实现NMS,并保证每个线程只操作自己的数据副本。或者如果TensorRT导出时已经把NMS封装进引擎,那就没有这个烦恼了,但要注意,封装的NMS参数无法在运行时动态改变。
另外,多个线程同时往同一个文件或同一个列表写结果,会出现覆盖或乱序。建议每个线程写入独立的中间文件,最后再做一次合并排序。
5.4 动态shape与TensorRT的坑
动态shape功能虽好,但坑更多。首先要明确动态维度:batch动态、height/width动态、还是全部动态。对YOLO而言,输入分辨率通常固定或者少数几个档位(比如640、1280、1920),没有必要每次都不一样。
如果你导出ONNX时把height和width都设为动态,然后在TensorRT里设置了多档优化profile,运行时如果输入某种意外的分辨率,TensorRT可能重新构建或报错,导致延迟大增。我建议干脆固定输入尺寸为640x640,只有batch用动态。这样engine构建最稳定,性能也最好。
还有一点,不同TensorRT版本对动态shape的支持程度不同。早期TensorRT 8.0在动态batch和动态分辨率同时使用时,内存分配策略很差。后续版本才逐步改善。如果你的框架版本较旧,别挣扎了,直接升级到8.6以上。
6. 排查速查表(经验版)
随手整理一个我实际排查时会对照的表格,你可以打印出来贴在显示器旁边:
| 症状 | 可能原因 | 快速解法 |
|---|---|---|
| 模型推理结果全不对或乱报 | 颜色通道未转换 | 检查BGR/RGB转换 |
| 框的位置偏移、偏小 | letterbox填充没有还原 | 检查pad参数,逆运算映射回原图坐标 |
| GPU利用率低,显存占用高 | 每次只单张推理 | 启用batch,至少调到4或8 |
| 模型加载后显存一直不释放 | engine/模型全局引用 | 设置延迟释放,用完后del+torch.cuda.empty_cache() |
| batch=4时显存爆了 | 模型本身显存占用大 | 降低batch到2,或改用FP16 |
| TensorRT转换失败 | ONNX节点不支持 | 检查opset版本;关掉动态维度;升级TensorRT |
| 多进程各卡速度不均衡 | 进程绑卡失败 | 用CUDA_VISIBLE_DEVICES指定卡 |
| CPU预处理成为瓶颈 | 读图太慢 | 用ThreadPool + 队列;或改用LMDB/内存映射 |
| NMS后处理耗时巨大 | 阈值太低,框太多 | 调高conf_thres,减少候选框 |
| 照片显示偏色但没报错 | 通道顺序错 | 换成RGB后再推理 |
| 动态batch时报错 | engine是静态batch | 重新构建动态batch的engine |
这个表看着简单,但都是我逐条踩过之后才总结出来的。特别是“GPU利用率低”这一项,绝大多数人都不注意。你觉得已经调用GPU了,实际上模型计算量小,数据传输又卡顿,GPU大部分时间是在等数据,利用率不足10%,这样自然是“看起来用了GPU,实际上还是慢”。
6. 最后再分享一点经验
如果你还没开始优化,我建议你按照这个顺序来:先确认GPU可用,然后跑通PyTorch GPU推理,接着记录基线速度;再转ONNX和TensorRT,对比两者差距;最后才做数据流水线和多进程。不要一上来就想直接上TensorRT,因为你的预处理、后处理可能才是真正的瓶颈,转了TensorRT也感受不到大的提升。
我个人在两个实际项目中对比过:一个只做TensorRT转换,吞吐量提升约3倍;另一个只优化数据读取和后处理,吞吐量提升约8倍。所以千万别迷信单一技术。
最后再分享一个小技巧:如果你只是做离线批量推理,不需要可视化结果,可以把输出直接写成二进制文件,比如每个batch只写一个NDArray缓存,这样磁盘IO压力最小。后续再配合一个解析脚本统一生成可视化图像或JSON结果,比一边推理一边画框再存图快得多。
我踩过最蠢的一次坑,是推理时在循环里反复调用cv2.imwrite保存每张检测结果图。哪怕推理已经做到了0.1秒,imwrite写一张高清大图也要0.2到0.3秒,直接把整体性能拖垮。所以,批量推理场景的第一步,永远是区分“主链路”和“附属输出”。主链路只做核心处理,附属输出放最后统一做,这才是从30秒走向0.1秒的核心心法。