简介:面向需要将深度学习模型落地到实际硬件的算法工程师与研究人员,这份项目演示了如何借助 TensorRT 将 MobileViT 模型高效部署到 GPU 设备,内容覆盖模型转换、TensorRT 插件编写、精度校准与性能测试等关键环节,适合有一定深度学习基础、希望掌握工业级推理优化流程的学习者。MobileViT 兼顾轻量化与视觉特征提取能力,与 TensorRT 结合后可在低功耗设备上实现低延迟推理,实用性较强。压缩包共 51 个文件,包含 Python 脚本、TensorRT 插件 C++ 源码、模型权重、校准数据、说明文档及 PPT 总结,整体大小 64.25MB,文件分工明确,既有部署转换脚本,也有测试与校准工具,便于对照练习。已有 136 人学习下载。借助这套资料,可以快速上手 TensorRT 部署流程,理解层融合与自定义插件编写思路,轻松复用到类似视觉模型的加速项目中。整个项目从 ONNX 导出、TensorRT 引擎构建到自定义插件集成,路径完整,便于循序渐进学习。
1. 用 TensorRT 部署 MobileViT:先判断这个项目值不值得你花时间
你在深度学习实战项目里训好或拿到的 MobileViT 模型,在 PyTorch 里验证精度没问题,但一进算法部署阶段就容易露馅:单帧推理耗时高、GPU 利用率上不去、动态 batch 一开就报错。这不是模型不行,而是 MobileViT 这种 CNN+Transformer 混合结构对推理引擎的算子融合和内存排布非常敏感,直接导 ONNX 丢给 TensorRT,往往拿不到理想性能。
这篇东西解决的就是这个环节:把 MobileViT 从 PyTorch 一路送到 TensorRT,覆盖 ONNX 导出、计算图修剪、FP16/INT8 引擎构建、精度对比和并发路数估算。适合正在做算法部署项目实战、被“模型能跑但上不了线”卡住的人;也适合刚接触 TensorRT、想找一个非 YOLO 样本练手的工程师。后面所有步骤都按一套可复现的流程来写,参数怎么调、坑在哪里,一次说清。
2. MobileViT 的部署难点与选型理由:混合架构在 TensorRT 上为什么不能直接编译
2.1 MobileViT 模型结构里三个天然的部署隐患
MobileViT 的核心不是又一个轻量 CNN,而是把 MobileNetV2 风格的局部特征提取和 Transformer 的全局建模拼在一起。每个 MobileViT block 里,输入先经过 3x3 深度可分离卷积抓局部纹理,然后进入 Transformer 分支,把 H×W 空间展平成 token 序列做自注意力,最后再 reshape 回原来的空间形状。这个“展平-注意力-还原”的结构,在 ONNX 图里表现为大量 reshape、transpose、squeeze 节点。
从部署视角看,这里有三个天然的隐患。第一是计算图碎片化,小算子非常多,kernel launch 的开销占比远高于大卷积网络。第二是动态 shape 敏感,token 序列长度和 H/W 直接挂钩,动态 batch 下 TensorRT 的 shape 推导链稍微断一环,构建就直接失败。第三是算子精度问题,LayerNorm、SiLU 这些在 FP16/INT8 下的表现和卷积不一样,而 Transformer 分支里几乎每层都有 LayerNorm。
这对 TensorRT 意味着什么?TensorRT 是编译式优化,它在构建期要推导每个张量的形状、为每个层挑选 kernel 模板、做算子融合。图里 transpose/reshape 太多,形状推导链太长,任何一处推断失败,整个引擎就构建不出来。这就是为什么很多人直接“导出 ONNX → trtexec --fp16”会翻车,而且报错日志看不懂。
2.2 TensorRT 为什么能压住这个模型:层融合、低精度与动态 shape 编译
TensorRT 的价值不在“跑得更快”这种口号,而在三件具体的事。第一是层融合,conv+bn+激活融合成一个 kernel,MobileViT 这种碎片化计算图里,融合掉的是大量 kernel 启动和中间张量的显存读写。第二是低精度,FP16 在多数卷积上几乎无损,MobileViT 这种小模型把 FP16 开起来,显存占用和带宽压力都会明显下降;INT8 则需要在精度和加速之间做校准,不是无脑开。第三是动态 shape 的优化 profile 机制,它允许你给输入 batch 设置 min/opt/max 三档,构建期按 opt 形态选 kernel,运行时再按实际 batch 调度。
我在实际项目里选 TensorRT 的理由很简单:如果目标是 NVIDIA GPU 上稳定帧率的小模型推理,TensorRT 是唯一工程化程度足够高的选项。ONNX Runtime GPU 也能跑,但对这类混合架构的 kernel 融合能力弱,延迟抖动更大,多路并发时 CPU 占用也更高。TensorRT 一旦构建成功,engine 文件很小,加载快,适合封装成常驻服务。
2.3 实战项目通常包含哪几个部分
拿到一个算法部署项目实战压缩包,先别急着跑,先确认它是否具备三个要素:一是模型导出脚本,把权重转成 ONNX;二是引擎构建脚本,用 trtexec 或 Python API 生成 engine;三是推理与验证脚本,负责加载 engine、绑定输入输出、做精度对比。我一般会先看 README 里的环境要求,再核对 TensorRT、CUDA、PyTorch 的版本是否匹配,因为 engine 文件本身和这些版本强绑定,版本对不上后面全是坑。
完整的部署流程是固定的六步:PyTorch 导出 ONNX → 计算图修剪(把后处理和归一化相关节点摘出去)→ 构建 TensorRT 引擎(FP16 或 INT8)→ 序列化保存 engine → 集成推理服务 → 精度和性能回归。接下来的章节按这六步展开,每一步都有命令和参数说明。
3. 从 PyTorch 到 ONNX:导出命令与计算图修整细节
3.1 导出 MobileViT 的 ONNX:动态轴的设置与 opset 选择
导出前有两个决定要先做。一是输入尺寸:MobileViT 常用 256×256 或 288×288,如果你线上只跑固定尺寸,导出时就用固定 shape,后面省很多事;如果有多尺寸需求,最好只把 batch 维度设为动态,H/W 固定。二是归一化放哪边:如果预处理放在 GPU 前面(CPU 端完成),ONNX 里就不要带减均值除方差;如果你想在 GPU 上做预处理,就把归一化写进模型里,输入直接喂 0-255 的 float 张量。这个决定会影响后面 INT8 校准的输入范围,先定下来别中途改。
导出命令用 PyTorch 官方接口即可,关键是参数别漏:
import torch from mobilevit import MobileViT # 以你项目里的模型脚本为准 model = MobileViT(num_classes=1000) ckpt = torch.load("mobilevit_s.pt", map_location="cpu") model.load_state_dict(ckpt) model.eval() dummy = torch.randn(1, 3, 256, 256) torch.onnx.export( model, dummy, "mobilevit.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=17, ) print("export done")这里dynamic_axes只给 batch 维度,是因为 MobileViT 的 Transformer 分支要把 H×W 展平成序列,H/W 固定能显著降低 TensorRT 构建期的 shape 推导难度。opset_version=17是相对稳的选择,低于 13 时对 reshape/transpose 和 LayerNorm 这类算子的支持不够好。input_names 和 output_names 是给 TensorRT 绑定输入输出用的,后面 Python API 里要用同一个名字。
3.2 修剪计算图:把后处理从模型里摘出去
导出完先用 Netron 打开 ONNX 看一眼结构。多数分类模型末尾是 logits → Softmax,或者还带 ArgMax。我的习惯是后处理一律不放 TensorRT 引擎里,Softmax、ArgMax、阈值判断都放到业务服务的 CPU 端做。理由有三个:省一次 kernel 调用、避免 FP16 下 Softmax 的精度损失、方便上线后调整阈值而不必重新构建引擎。
用 onnx-graphsurgeon 修剪,核心是把输出张量重定向到 Softmax 的输入:
import onnx import onnx_graphsurgeon as gs graph = gs.import_onnx(onnx.load("mobilevit.onnx")) # 策略一:把输出重定向到 softmax 的输入,等价于把 softmax 从计算图里删掉 for node in graph.nodes: if node.op == "Softmax": graph.outputs = [node.inputs[0]] break # 策略二:如果模型末尾不是 Softmax,而是 ArgMax 等,先确认目标张量名再手动指定 graph.cleanup().toposort() onnx.save(gs.export_onnx(graph), "mobilevit_trim.onnx")graph.cleanup()会自动删掉不再被输出依赖的节点。注意如果你的模型脚本里 forward 返回的是(logits, aux)这种多输出,导出时要只保留主分类头,TensorRT 对于多余输出的处理会拖慢性能。
3.3 在 Ubuntu 上装 TensorRT 并验证环境
Ubuntu 上装 TensorRT,常见做法是去 NVIDIA 官网下载与当前 CUDA 版本匹配的 tar 包,解压后安装 Python wheel,再把 lib 目录加进动态链接路径。这种方式比 apt 安装好在版本可控,尤其在服务器和容器环境里,不会污染系统 Python。
# 解压 TensorRT tar 包后,安装 Python wheel pip install /path/to/TensorRT-*/python/tensorrt-*.whl # 运行时库加入动态链接路径,建议写进 ~/.bashrc 或容器 entrypoint export LD_LIBRARY_PATH=/path/to/TensorRT-*/lib:$LD_LIBRARY_PATH # 验证:打印 TensorRT 版本 python -c "import tensorrt as trt; print(trt.__version__)" # 验证:trtexec 是否可用 trtexec --helpTensorRT 版本必须和 CUDA、显卡驱动匹配,具体配对关系以官方文档为准。装完之后先跑trtexec --help,能看到参数说明就说明环境基本通了。如果 import tensorrt 报 libnvinfer.so 找不到,九成是 LD_LIBRARY_PATH 没生效。
4. 构建 TensorRT 引擎:两条路线与动态 batch 配置
4.1 trtexec 一行命令跑通最小引擎
环境确认没问题,先用 trtexec 跑一个最小引擎,验证计算图和参数设置是否可行。trtexec 是 TensorRT 自带的命令行工具,也是调试阶段效率最高的起点。下面是一个动态 batch 的 FP16 构建命令:
trtexec \ --onnx=mobilevit_trim.onnx \ --saveEngine=mobilevit_fp16.engine \ --fp16 \ --minShapes=input:1x3x256x256 \ --optShapes=input:4x3x256x256 \ --maxShapes=input:8x3x256x256--minShapes/--optShapes/--maxShapes三档分别对应优化 profile 的最小、最优、最大输入形状,optShapes应该是你线上最常见的 batch。TensorRT 在构建期会针对 opt 形状选 kernel,运行时输入越接近 opt,实际性能越好。--fp16表示卷积等算子优先用 FP16,但 TensorRT 内部不是无脑全转半精度,它会对部分算子自动保持 FP32,这正是 MobileViT 这类带 LayerNorm 的模型需要的。
跑完后 trtexec 会输出一段性能报告,里面有 GPU Compute Time 和 End-to-End Host Latency。GPU Compute Time 是纯 GPU kernel 耗时,End-to-End 包含数据搬运和 host 端开销,做容量规划时看后者更接近线上。
4.2 用 Python API 构建引擎:工作空间、FP16/INT8 与优化 Profile
trtexec 跑通了,就把构建逻辑搬进 Python 脚本,因为实际项目里要接自己的校准数据、要动态指定精度策略、要批量构建多档引擎。下面这段是 Python API 构建引擎的标准写法:
import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) builder = trt.Builder(logger) network = builder.create_network( 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH) ) parser = trt.OnnxParser(network, logger) with open("mobilevit_trim.onnx", "rb") as f: ok = parser.parse(f.read()) if not ok: for i in range(parser.num_errors): print(parser.get_error(i)) raise SystemExit("onnx parse failed") # 动态 batch 的优化 profile profile = builder.create_optimization_profile() profile.set_shape("input", (1, 3, 256, 256), (4, 3, 256, 256), (8, 3, 256, 256)) config = builder.create_builder_config() config.add_optimization_profile(profile) # 工作空间:构建时的临时显存上限,推理时不占满 config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30) # 1GB config.set_flag(trt.BuilderFlag.FP16) engine_bytes = builder.build_serialized_network(network, config) with open("mobilevit.engine", "wb") as f: f.write(engine_bytes)EXPLICIT_BATCH标志必须开,否则后续没法配置动态 shape。set_memory_pool_limit在工作空间大小上,给 1GB 是常见起步值;显存小的卡可以降到 512MB,但太小的 workspace 会让 TensorRT 放弃某些层融合,性能反而下降。注意不同 TensorRT 版本的 API 名有差异,旧版本用的是config.max_workspace_size,新版本是set_memory_pool_limit,写脚本时以你装的版本为准。
如果要用 INT8,构建时还要加config.set_flag(trt.BuilderFlag.INT8)并设置校准器,这步的坑单独在后面的避坑章里说。
4.3 engine 文件为什么不能跨机器复现
engine 是编译产物,只对同一 GPU 架构、同一 TensorRT 版本、同一 CUDA 版本有效。它和显卡的硬件特性绑定,比如 T4 和 A100 的 kernel 模板完全不同,同一份 engine 在 A100 上构建、拷到 T4 上加载,基本都会报错,运气好不报错也是非法访问。生产环境正确做法是:在每一类目标 GPU 上各自构建,或者把构建脚本和部署包一起发布,启动时检测到没有对应的 engine 文件就现场构建。
反序列化加载的代码很简单:
runtime = trt.Runtime(logger) with open("mobilevit.engine", "rb") as f: engine = runtime.deserialize_cuda_engine(f.read()) context = engine.create_execution_context()这里create_execution_context()得到的是推理上下文,一个 engine 可以创建多个 context,多路并发时共享同一个 engine,每路一个 context,这个要在部署时注意。
5. 避坑指南:MobileViT + TensorRT 最常见的五个翻车现场
以下五条按“现象 → 原因 → 解决”来写,都是这类项目里最常被问到的。前两条直接决定你能不能构建成功、精度对不对,后三条决定你上不上得了线。
5.1 FP16 引擎精度漂移:LayerNorm 是头号嫌疑人
现象:FP16 引擎的 top-1 精度比 PyTorch 掉 1 到 2 个点,对比 logits 发现误差集中在后几十层。原因:MobileViT 每个 Transformer 分支里都有 LayerNorm,FP16 下计算均值/方差时,小通道数的统计量精度损失被放大。解决:把 LayerNorm 相关层强制保持在 FP32:
for i in range(network.num_layers): layer = network.get_layer(i) name = layer.name.lower() if "layernorm" in name or "reducemean" in name: layer.precision = trt.float32 layer.set_output_type(0, trt.float32) config.set_flag(trt.BuilderFlag.OBEY_PRECISION_CONSTRAINTS)注意:不同 TensorRT 版本对 LayerNorm 的层命名可能不一样,有的直接叫 LayerNorm,有的被拆成 ReduceMean + Sub + Div 的组合,所以匹配layernorm的同时也要匹配reducemean,否则漏掉一层精度就拉不回来。
5.2 动态 batch 构建失败:reshape 推导出 -1
现象:trtexec 或 Python API 构建时报 shape 推导错误,日志里某个张量维度出现 -1,错误位置指向 Transformer 分支。原因:MobileViT 把空间展平成 token 序列时,用了写死的常量 shape,比如(1, 65536, C),batch 一旦变成动态值,这个常量 shape 就和输入对不上。解决:导出时把 reshape 的目标 shape 用运行时张量值计算,不要写死:
b, c, h, w = x.shape x = x.reshape(b, h * w, c) # h/w 固定为 256 时,h*w 是常量;batch 保持动态如果 H/W 也要动态,就必须改模型 forward 里 reshape 的实现,把目标 shape 用 concat 出来的张量显式传递。实际项目里我建议只让 batch 动态,H/W 在非分类任务里再按需放开,省掉一半以上的构建期问题。
5.3 多路并发 OOM:显存里塞了太多 context
现象:单路推理正常,压到 4 到 8 路时 CUDA OOM,显存曲线一路上涨。原因:每路独立创建了 engine,或者在推理循环里反复分配输入输出 buffer,显存碎片化严重。解决:全局只构建一次 engine,多路共享;每路只创建 context 和 CUDA stream;输入输出 buffer 在初始化时按最大 batch 预分配,不要在循环里频繁申请:
# 推理初始化阶段:每路一个 context + 一个 stream context = engine.create_execution_context() stream = cuda.Stream() # pycuda 或其他 CUDA binding # 推理循环里只做拷贝和执行,避免反复申请显存 cuda.memcpy_htod_async(d_input, input_batch, stream) context.execute_async_v2(bindings, stream.handle) cuda.memcpy_dtoh_async(output_batch, d_output, stream) stream.synchronize()显存预算按“engine 文件体积 + 每路输入输出 buffer 之和 + workspace 峰值”估算,卡上可用显存留出 20% 余量,别把最后一兆显存都用完。
5.4 INT8 精度崩塌:校准集选错了
现象:INT8 引擎比 FP16 掉 5 个点以上,且误差集中在特定类别。原因:校准集只有几十张图、分布和线上数据不一致,或者校准时的预处理和线上不一致,导致量化阈值完全偏掉。解决:校准数据至少几百张,优先从真实业务流里抽样,不要用训练集里随便挑的图;预处理必须严格对齐线上,同一尺寸、同一归一化参数、同一通道顺序;校准器用 Int8EntropyCalibrator2,并把校准缓存下来,避免反复校准。
class MobileViTCalibrator(trt.IInt8EntropyCalibrator2): def __init__(self, dataloader, cache_file): super().__init__() self.dataloader = dataloader self.cache_file = cache_file self.buffer = np.empty((8, 3, 256, 256), dtype=np.float32) def get_batch_size(self): return 8 def get_batch(self, names): batch = next(self.dataloader, None) if batch is None: return None self.buffer[:] = batch return [self.buffer.ctypes.data] def read_calibration_cache(self): if os.path.exists(self.cache_file): return open(self.cache_file, "rb").read() def write_calibration_cache(self, cache): with open(self.cache_file, "wb") as f: f.write(cache)如果校准集没问题但精度还是崩,就回到 5.1 的做法,把 LayerNorm、Softmax 这些敏感层在 INT8 引擎里保持 FP16。
5.5 trtexec 的 FPS 不能直接当线上指标
现象:trtexec 报告 300 FPS,线上服务实际只能跑到 100。原因:trtexec 只统计 GPU 端到端 kernel 时间,线上链路的图像解码、CPU 预处理、H2D/D2H 拷贝、后处理和线程同步它全都不算。很多新手拿了 trtexec 的数字去汇报并发路数,压测一上来就被打脸。解决:用 nsys profile 抓服务进程时间线,把耗时拆成解码、预处理、拷贝、推理、后处理五段,看瓶颈在哪;把 resize 和归一化搬进 CUDA kernel,或者用多 stream 让 CPU 预处理和 GPU 推理重叠。压测前要先做几十帧预热,否则首次 CUDA context 初始化和 kernel 加载的时间会被算进平均延迟里。
顺带说一句,类似“T4 上 1080p 25 帧每秒用 TensorRT YOLO 640 分辨率能支持多少路”这种问题,本质就是容量规划,估算方法在下一章。
6. 上线前验证:Polygraphy 做精度回归,按帧间隔算并发路数
6.1 用 Polygraphy 对比 ONNX Runtime 与 TensorRT 输出
构建完引擎别急着上线,先做逐元素的输出对比。Polygraphy 是最省事的工具,一条命令同时跑 ONNX Runtime 和 TensorRT 两个后端,自动统计误差:
pip install polygraphy polygraphy run mobilevit_trim.onnx \ --onnxrt --trt --fp16 \ --input-shapes 'input:[1,3,256,256]' \ --atol 1e-2 --rtol 1e-2它会用相同的随机输入分别跑两个后端,对输出做逐元素误差比较。如果误差超过阈值,Polygraphy 会把差异最大的层打印出来。实际项目里如果不想多引一个工具,最土但有效的办法是拿同一张图分别跑 PyTorch 和 TensorRT,比较 logits 的最大绝对差;分类任务里 abs diff 小于 1e-2 基本可接受,但最终还是要以你自己的 top-1/top-5 指标为准。
6.2 容量规划:1080p 25 帧每秒能接多少路
并发路数估算本质是延迟预算和显存预算双约束。拿 25 帧每秒的场景举例:每路每帧的可用时间预算就是 40ms。先加压测,拿到预热后的单路端到端平均耗时 t,理论并发上限就是 40ms / t。比如 t=8ms,上限 5 路,留 20% 余量,规划 4 路。如果显存先到瓶颈,就按显存算:engine 文件体积加每路 buffer 之和,不超过可用显存的 80%。如果单路耗时已经超过 40ms,就不要硬上 25fps,要么降输入分辨率、要么把多路输入聚合成大 batch 推理,再要么直接砍路数。
要注意的是单路耗时别用平均值,用 P95 或者 P99。分类服务对延迟抖动敏感,均值达标但长尾抖动超了,线上一样会出问题。批处理聚合能提升吞吐,但会引入等待批量凑满的排队延迟,25fps 实时场景下批大小要控制,别为了吞吐牺牲单帧时效。
我自己的习惯是每次换模型、换卡,都把这套流程固定下来:导 ONNX、修图、构建、Polygraphy 对比、压测算路数,五步走完才敢把 engine 交给服务端。以前偷懒跳过 Polygraphy,直接拿 trtexec 的 FPS 报路数,结果压测一上来就翻车,后来再也不敢省这步。希望帮到你。
本文还有配套的精品资源,点击获取