简介:面向深度学习算法工程师与视频理解方向开发者,这份实战项目以使用TensorRT部署SlowFast视频理解算法作为完整主题,从模型导出、格式转换到推理链路搭建均有清晰演示。项目聚焦视频理解模型计算量大、推理耗时长的实际问题,详细呈现TensorRT的图优化、层融合、内核自动调优、精度校准与内存管理优化等关键技术,让读者理解在NVIDIA GPU上实现低延迟视频推理的工程化方法。资源压缩包共24个文件,含21个Python脚本、1个YAML配置、1个Markdown说明及gitignore文件;脚本覆盖ONNX导出、TensorRT转换与推理验证,YAML提供SlowFast 4x16 R50模型推理参数,整体仅46KB,轻量易读。目前已有158人学习,项目步骤连贯、模块划分清楚,适合具备一定模型基础但希望补齐工程化部署能力的开发者。通过实际操作可掌握模型转换、性能优化到接口对接的部署链路,理解内核选择与工作空间设置对推理的影响,为实时视频分析应用落地提供参考。

1. 用 TensorRT 部署 SlowFast,先过视频输入这道关

做过图像模型 TensorRT 部署的工程师,第一次碰 SlowFast 时几乎都会卡在同一个地方:engine 构建成功,输入也填进去了,输出的概率分布却完全不可信。问题不在 TensorRT,而在视频输入的组织方式。

SlowFast 是 FAIR 提出的双路径视频理解算法,慢路径低帧率抓语义,快路径高帧率抓运动,通过 lateral 连接融合,是动作识别任务的实用基线。与单帧分类不同,它的输入是一段 clip 的连续帧,两条路径以不同帧率并行进入网络,预处理阶段稍有偏差,后面的精度就全部作废。

这里把 PyTorch 权重到 TensorRT engine、再到 Orin 板上推理的链路讲清楚:ONNX 输入组织、trtexec 构建参数、帧采样与缓冲区绑定、精度验证,以及和设备树配置、系统裁剪相关的坑。适合部署过图像模型、第一次碰视频理解算法的工程师。

2. SlowFast 双路径结构与 TensorRT 优化适配的三个关键决策

2.1 慢路径与快路径的计算特征:Conv3D、BN3D 与 lateral 连接怎么影响部署

官方 SlowFast 的 backbone 是双路径 ResNet。慢路径输入稀疏采样的帧,以 8x8 配置为例是 8 帧,通道数保留完整;快路径输入连续帧,共 64 帧,通道数只有慢路径的 1/8, BETA_INV 这个配置项控制的就是这个比值。两条路径每个 stage 之间都有 lateral 连接:fast 到 slow 方向把快路径特征做时间维池化后用 1x1 卷积对齐通道,slow 到 fast 方向做 1x1 卷积再拼回快路径。落到 ONNX 图上,这些全部是 Conv、MaxPool、Concat 这类原生算子。

从 TensorRT 适配角度看,分类头版本的 SlowFast 不需要写任何自定义 plugin。主要组件对应的算子如下表:

组件 主要 ONNX 算子 TensorRT 处理方式
基础卷积块 Conv(3D)、BatchNormalization、ReLU 原生支持,BN 在构建时融合进卷积
fast→slow lateral MaxPool(3D)、Conv(3D)、Concat 原生支持
slow→fast lateral Conv(3D)、Concat 原生支持
分类头 GlobalAveragePool(3D)、MatMul 原生支持
检测头(R-CNN 变体) RoIAlign 需要第三方 plugin,一般先不碰

真正影响性能的是 3D 卷积的 kernel 选择。TensorRT 在 NCDHW 排布下对 5D 卷积的 tensor core 映射,会随 T、C 尺寸改变最优实现;同样的权重,T=8 和 T=64 的算子可能落到完全不同的 kernel 上。这也是为什么 trtexec 的 shape 优化区间不能随意放宽,区间越大,autotune 搜索越久,且不保证选到局部最优。

2.2 双输入还是单输入:SlowFast 的 ONNX 图组织方式对比

PyTorch 官方实现的前向签名是 forward(slowpath_input, fastpath_input) ,两个输入天然分开。导出 ONNX 时有三种组织方式,按工程经验优先选第一种:

方案 ONNX 输入 优点 代价
双输入(推荐) slow_frames、fast_frames 与训练模型一一对应,导出零改动 推理代码要维护两个 binding
单输入拼接 [N, 3, T_s+T_f, H, W] 输入管理简单 图里多 Split 节点,多一次显存读写
固定 shape 写死 单个固定张量 构建最快 换分辨率、帧数要重新导出

单输入拼接的做法是把 slow 和 fast 的帧在时间维拼成一个输入,模型入口处用 Slice 拆回两路。这样少管理一个输入,但引入的切分节点在 TensorRT 里对应一次显存内的数据搬移;慢路径和快路径的时间窗一旦改变,切片索引也要跟着改。视频理解模型的输入本身就大,多一次拷贝的代价比多维护一个 binding 更明显,所以我一般直接走双输入。

2.3 导出前先对齐的四个数字:帧数、alpha、时域步长与输入 shape

部署前要把四个数字写成常量,在模型导出、数据采样、推理代码三处保持一致:慢路径帧数 T_slow、快路径帧数 T_fast、帧率倍率 alpha、时域步长 slow_stride。以 8x8 配置为例,从视频里均匀抽 64 帧作为时间窗口,慢路径按 stride=8 取 8 帧,快路径按 stride=1 取 64 帧,T_fast 正好是 T_slow 的 8 倍,这就是 alpha=8 的含义。

这四个数字直接决定 ONNX 的输入 shape。导出后先检查一遍图的输入签名,用一段小脚本打印:

import onnx

m = onnx.load("slowfast_8x8.onnx")
for inp in m.graph.input:
    dims = [d.dim_value for d in inp.type.tensor_type.shape.dim]
    print(inp.name, dims)

期望输出是 slow_frames [1, 3, 8, 224, 224] 和 fast_frames [1, 3, 64, 224, 224] 。batch 维允许动态,T、H、W 必须固定。常见的翻车点有两个:一是训练时输入是 256,部署时改成 224,BN 和卷积的统计特性已经按原分辨率调整过,精度差直接暴露在 top-1 上;二是只改了模型配置里的 alpha,没改采样代码,慢路径和快路径看到的时间范围不一致,engine 构建没任何报错,输出的动作类别却是乱的。

3. PyTorch 导出 ONNX 并用 trtexec 构建 SlowFast 的 TensorRT engine

3.1 版本匹配:x86 工作站与 Jetson Orin 两套环境组合

先把环境钉死再谈导出。x86 工作站建议直接用 NVIDIA 官方 TensorRT 容器,容器内的 CUDA、cuDNN、TensorRT 是官方验证过的组合;Orin 跟着 JetPack 走,预装版本以板载为准。另外 engine 文件不可跨平台复制,x86 上构建的 engine 在 aarch64 上必须重新 build,ONNX 才可移植。下表是两套可以复现的组合:

平台 CUDA TensorRT 导出侧 PyTorch
x86 + Ampere 以上显卡 12.x 8.6.x 或 10.x torch 2.0+
Jetson Orin + JetPack 5.1.2 11.4 8.5.2.2 torch 1.13+(容器)
Jetson Orin + JetPack 6.0 12.2 8.6.3 torch 2.2+(容器)

Orin 上一个绕不开的话题是降 TensorRT 版本。JetPack 预装的 TRT 与某个 PyTorch 导出的 opset 不兼容时,项目里常见做法是在容器里装回旧版 trt wheel,但只改 pip 包不够——/usr/lib/aarch64-linux-gnu 下的 libnvinfer.so 和 cuDNN 版本也得对齐,否则 import tensorrt 和 trtexec 的行为不一致。最稳的路径是拉取与 JetPack 对应的 TensorRT 容器,把 libnvinfer*.so 指过去,启动脚本里用 LD_LIBRARY_PATH 固定住,避免系统库干扰。

3.2 PyTorch 导出 SlowFast 到 ONNX 的脚本与导出后检查

以下脚本在官方 slowfast 仓库的代码结构上工作,导出 8x8 配置的 R50:

import torch
from slowfast.models import build_model
from slowfast.config.defaults import get_cfg
from slowfast.utils import checkpoint

cfg = get_cfg()
cfg.merge_from_file("configs/SLOWFAST_8x8_R50.yaml")
cfg.TEST.CHECKPOINT_FILE_PATH = "SLOWFAST_8x8_R50.pkl"

model = build_model(cfg)
checkpoint.load_checkpoint(cfg, model)
model.eval()  # 关键:BN 才会按全局统计量折叠

slow_shape = (1, 3, 8, 224, 224)
fast_shape = (1, 3, 64, 224, 224)

torch.onnx.export(
    model,
    (torch.randn(slow_shape), torch.randn(fast_shape)),
    "slowfast_8x8.onnx",
    input_names=["slow_frames", "fast_frames"],
    output_names=["logits"],
    dynamic_axes={
        "slow_frames": {0: "batch"},
        "fast_frames": {0: "batch"},
        "logits": {0: "batch"},
    },
    opset_version=17,
)

model.eval() 不能省:训练模式下 BatchNorm 用的是 batch 统计量,导出的图在推理时语义不正确。 dynamic_axes 只放开 batch 维,T/H/W 保持静态,这样 trtexec 构建时只在一个方向搜索 shape 区间,kernel 选择更稳定。opset 定在 17 对 Conv3D、MaxPool3D 这些算子足够,不需要追新。

导出后做两层检查:先用 onnx.checker 验图结构,再用 onnxruntime 和 PyTorch 各跑一遍同样的输入,比较输出差异:

import onnx
import onnxruntime as ort
import numpy as np

onnx.checker.check_model(onnx.load("slowfast_8x8.onnx"))

a = np.random.randn(1, 3, 8, 224, 224).astype(np.float32)
b = np.random.randn(1, 3, 64, 224, 224).astype(np.float32)

sess = ort.InferenceSession("slowfast_8x8.onnx",
                            providers=["CPUExecutionProvider"])
onnx_out = sess.run(["logits"],
                    {"slow_frames": a, "fast_frames": b})[0]

with torch.no_grad():
    torch_out = model(torch.from_numpy(a),
                      torch.from_numpy(b)).numpy()

print("max diff:", np.abs(onnx_out - torch_out).max())

max diff 在 1e-3 以下说明导出干净,可以进入构建阶段。如果差异到 1e-1 量级,优先检查是否漏了 eval() ,其次看 ONNX 图里有没有被解释成高频子图的自定义 op,有的话先用 onnx-graphsurgeon 重写图再导出。

3.3 trtexec 构建 FP16 engine 的命令与 6 个关键参数

ONNX 准备好之后,用 trtexec 构建和验证一步到位:

trtexec \
  --onnx=slowfast_8x8.onnx \
  --saveEngine=slowfast_8x8_fp16.engine \
  --fp16 \
  --memPoolSize=workspace:4096 \
  --minShapes=slow_frames:1x3x8x224x224,fast_frames:1x3x64x224x224 \
  --optShapes=slow_frames:2x3x8x224x224,fast_frames:2x3x64x224x224 \
  --maxShapes=slow_frames:4x3x8x224x224,fast_frames:4x3x64x224x224 \
  --timingCacheFile=slowfast.timing

参数含义和部署建议:

参数 作用 建议
--fp16 打开半精度,走 tensor core 视频模型首选,通常不掉点
--memPoolSize 构建时 workspace 上限(TRT 8.5+) 8.4 之前用 --workspace;4~8 GB 均可
--minShapes/optShapes/maxShapes 动态 shape 优化区间 batch 1/2/4,T/H/W 固定
--timingCacheFile 缓存 kernel 选择结果 反复调参时省掉重复 autotune
--buildDLA 在 Jetson 上启用 DLA SlowFast 以 3D 卷积为主,DLA 支持有限,保持关闭
--int8 INT8 量化,需额外校准数据 动作识别慎用,见 5.1

注意:TRT 8.5 起 --workspace 已废弃,统一用 --memPoolSize=workspace:N ;在 TRT 8.4 及更早版本上仍用 --workspace=N 。

首次 build 因为 kernel autotune 会比较久,8x8 的 R50 在 x86 上通常几分钟,Orin 上会更久, --timingCacheFile 能在下次迭代里省掉这段。构建完成后,用同一份 trtexec 加载 engine 并跑 10 秒基准,确认耗时符合预期:

trtexec --loadEngine=slowfast_8x8_fp16.engine \
  --shapes=slow_frames:1x3x8x224x224,fast_frames:1x3x64x224x224 \
  --duration=10

--shapes 在 loadEngine 模式下作用于动态输入,batch 必须落在构建时的 [min, max] 区间内,否则 engine 拒绝执行。trtexec 输出的 mean GPU time 是纯推理耗时,不含解码和预处理,拿这个数去对后面的端到端性能预算。

4. SlowFast 的 TensorRT 推理工程:帧采样、双输入绑定与性能预算

4.1 视频帧组装:慢路径 8 帧、快路径 64 帧的采样与预处理代码

engine 拿到手之后,真正决定线上精度的是推理代码里怎么把一段视频变成两个输入。以下代码按 8x8 配置组装:

import cv2
import numpy as np

MEAN = np.array([0.45, 0.45, 0.45], dtype=np.float32)
STD = np.array([0.225, 0.225, 0.225], dtype=np.float32)
SLOW_STRIDE = 8   # 慢路径取帧间隔,与训练配置一致

def resize_crop(frame, short=256, crop=224):
    h, w = frame.shape[:2]
    scale = short / float(min(h, w))
    frame = cv2.resize(frame, (int(w * scale), int(h * scale)))
    nh, nw = frame.shape[:2]
    y0, x0 = (nh - crop) // 2, (nw - crop) // 2
    return frame[y0:y0 + crop, x0:x0 + crop]

def read_clip(path, clip_len=64):
    cap = cv2.VideoCapture(path)
    total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    idx = np.linspace(0, total - 1, clip_len).astype(int)  # 均匀抽帧
    frames = []
    for i in idx:
        cap.set(cv2.CAP_PROP_POS_FRAMES, int(i))
        ok, frame = cap.read()
        frames.append(resize_crop(frame) if ok
                      else np.zeros((224, 224, 3), np.uint8))
    cap.release()
    return frames

def pack_pathway(frames):
    slow = frames[::SLOW_STRIDE]      # 64 帧每 8 帧取 1 -> 8 帧
    fast = frames[::1]                 # 64 帧全量给 fast

    def to_tensor(fs):
        x = np.stack(fs)[:, :, :, ::-1]  # BGR -> RGB
        x = x.astype(np.float32) / 255.0
        x = (x - MEAN) / STD
        return np.ascontiguousarray(x.transpose(3, 0, 1, 2))[None]

    return to_tensor(slow), to_tensor(fast)

采样逻辑里两件事不能改:一是时域步长 SLOW_STRIDE 必须等于训练配置,二是归一化的 mean/std 要与训练 DataLoader 一致,上面用的是 SlowFast 仓库默认值。 cv2 读出来是 BGR,转 RGB 这步漏掉的话输出类别会整体偏移。 resize_crop 模拟训练时的短边缩放加中心裁剪,直接拉伸到 224 会引入畸变,对动作识别的影响比图像分类更明显。

4.2 推理骨架:engine 反序列化、CUDA 缓冲区绑定与异步执行

双输入模型的推理骨架和单输入模型区别不大,关键是 binding 的绑定方式。下面用 pycuda 实现一份可跑的版本:

import numpy as np
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit

class SlowFastEngine:
    def __init__(self, engine_path, batch=1):
        logger = trt.Logger(trt.Logger.WARNING)
        runtime = trt.Runtime(logger)
        with open(engine_path, "rb") as f:
            self.engine = runtime.deserialize_cuda_engine(f.read())
        self.context = self.engine.create_execution_context()
        self.stream = cuda.Stream()

        # 动态 batch 必须先声明,再查 shape
        self.context.set_input_shape("slow_frames", (batch, 3, 8, 224, 224))
        self.context.set_input_shape("fast_frames", (batch, 3, 64, 224, 224))
        self._alloc_buffers()

    def _alloc_buffers(self):
        self.dev, self.host = {}, {}
        for name in self.engine:
            shape = self.context.get_tensor_shape(name)
            dtype = trt.nptype(self.engine.get_tensor_dtype(name))
            size = trt.volume(shape)
            self.host[name] = cuda.pagelocked_empty(size, dtype)
            self.dev[name] = cuda.mem_alloc(size * np.dtype(dtype).itemsize)

    def infer(self, slow_np, fast_np):
        cuda.memcpy_htod_async(self.dev["slow_frames"],
                               np.ascontiguousarray(slow_np).ravel(),
                               self.stream)
        cuda.memcpy_htod_async(self.dev["fast_frames"],
                               np.ascontiguousarray(fast_np).ravel(),
                               self.stream)
        self.context.execute_async_v2(
            list(self.dev.values()), self.stream.handle)
        cuda.memcpy_dtoh_async(self.host["logits"],
                               self.dev["logits"], self.stream)
        self.stream.synchronize()
        return self.host["logits"].copy()

这里三个关键点: set_input_shape 必须在分配缓冲区之前调用,否则 get_tensor_shape 返回 -1, trt.volume 会算出错误大小;host 缓冲用 pagelocked 内存,配合 memcpy_htod_async 避免异步拷贝时的页错误; execute_async_v2 的 binding 列表顺序就是 engine 的 binding 索引顺序,用字典 values 保持遍历顺序最稳妥。batch 换成 2 或 4 时,要重新 set_input_shape 并重分配缓冲区,视频推理场景一般固定 batch=1。

4.3 端到端性能预算:解码、拷贝与推理的耗时分配

视频理解的端到端延迟,大头往往在解码而不是 GPU。8x8 配置一次推理要喂 72 帧,Orin 上 FP16 engine 大概 20~40 ms,但 cv2.VideoCapture 用 set + read 随机抽 64 帧,动辄 100 ms 以上。先拆时间再调优,下面是常见量级(具体以板子为准):

环节 常见耗时 优化手段
视频解码/抽帧(64 帧 1080p) 60~150 ms PyAV 批量解码,或走 GPU 硬解
resize + normalize(CPU) 10~30 ms 搬到 GPU 上做,直接产出 NCHW float
H2D 拷贝(72 帧) 5~15 ms 双缓冲 + 异步拷贝
TRT FP16 推理(224px) 15~40 ms 调 optShapes、timingCache

算法嵌入式部署的性能调优,第一刀永远是砍数据移动。我一般会把解码线程和推理线程解耦:解码线程只负责把帧放进环形缓冲,推理线程只管 H2D 和 execute,两个线程之间用双缓冲避免互相等待。用 nsys profile 抓一次,如果 memcpyHtoD 占比超过 30%,就把预处理下沉到 CUDA kernel,host 端只留一个 memcpy。

5. 精度对齐验证与 Orin 嵌入式部署的四个高频坑

5.1 用 cosine 相似度对齐 PyTorch 与 TensorRT 输出

验证精度别靠肉眼比较类别。取同一段帧,分别用 PyTorch 和 TensorRT engine 推理,比较 logits:

import numpy as np

def compare_logits(torch_out, trt_out):
    a = torch_out.astype(np.float32).ravel()
    b = trt_out.astype(np.float32).ravel()
    cos = float(np.dot(a, b) /
                (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12))
    max_abs = float(np.abs(a - b).max())
    print(f"cosine={cos:.6f} max_abs_diff={max_abs:.4f}")
    return cos, max_abs

FP32 engine 和 PyTorch 的差异一般在 1e-5 量级,FP16 下 cosine 不低于 0.999、top-1 一致就可以认为部署无损。量化到 INT8 时 cosine 掉到 0.99 以内,就要重新审视校准集——动作识别对运动敏感,校准集必须包含运动丰富的片段,只用静态帧标定会把 fast 路径的响应压没。

5.2 Orin 部署的四个高频坑:版本降级、显存、时序错位与系统裁剪

第一个坑是 TensorRT 版本与 JetPack 耦合。Orin 上「降 TensorRT 版本」时,pip 里的 trt wheel 和系统 libnvinfer.so 必须同版本,建议直接在对应 JetPack 的容器里操作,容器与板载 CUDA 版本天然对齐。

第二个坑是 fast 路径的显存。batch=4 时 fast 输入就要 4×3×64×224×224×4 字节,约 150 MB,连上中间激活很容易把 Orin 的显存吃满。部署时把 optShapes 的 batch 控制在 1~2,或者只放开 batch 动态、固定其它维度。

第三个坑是帧采样与训练时序错位。fast 路径的时间窗必须和 slow 路径严格对齐, SLOW_STRIDE 和 alpha 一旦和导出的 shape 不一致,输出就是随机概率。每次改模型配置,先跑 5.1 的对齐脚本再谈性能。

第四个坑在模型之外。视频分析服务长时间在 Orin 上跑,延迟抖动往往来自系统侧:显示控制器还在占用 GPU 上下文,图形栈吃内存带宽,CPU 频点浮动导致解码线程掉速。嵌入式侧常见做法是用设备树配置关掉用不到的显示节点,移除图形栈,把 CPU/GPU 频率固定。linux 嵌入式驱动开发和系统裁剪优化在这一步比调 TRT 参数更值得投入——先解决系统抖动,再看 kernel 级调优。先把这四个坑按顺序过一遍,再回到 trtexec 调 optShapes 和 timingCache,比对着 nsys 报告空转更有价值。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐