简介:这份资料围绕TensorRT与C++部署RetinaFace展开,面向具备基础深度学习和C++开发经验的算法工程师,解决人脸检测模型在NVIDIA GPU上推理慢、难以实时落地的问题。项目完整覆盖模型转换、TensorRT推理、后处理与工程优化链路,可作为优质算法部署项目参考。包内共65个文件,压缩包约6.67MB,以C++/CUDA源码、Python转换脚本、模型文件和配置文档为主,包含9个cpp、11个h、9个py以及caffemodel/prototxt等,另有INT8校准工具和MXNet2Caffe迁移脚本,目录结构清晰,便于按模块学习。已有71人学习。资料亮点在于给出了RetinaFace从MXNet/Caffe到TensorRT的完整转换与部署流程,附带工程构建配置、关键源码和校准工具,帮助读者理解推理加速的落地思路,也可复用到其他检测模型。
1. 把 RetinaFace 从 Python 搬到 TensorRT + Cpp:图的不只是省 20ms,而是让单卡扛住多路视频
做视频流人脸抓拍的人大概率都碰到过这个场景:Python 里 PyTorch 推理跑得挺顺,一上线上服务,GPU 利用率上不去、CPU 先打满,单路 1080p 25fps 都抖。问题不在 RetinaFace 本身,而在于推理框架和工程形态。用 TensorRT + Cpp 重新组织推理管线,FP16 精度下输入 640×640 的 RetinaFace 在 T4 上单帧延迟能做到 2~5ms,比 PyTorch 原生推理快一个数量级。这篇笔记我会按模型导出、工程搭建、解码后处理、常见问题、压测方法讲一遍,全程以常见开源 RetinaFace 的 C++ 部署为例。适合正在做视频流人脸检测、考勤抓拍、客流统计的算法工程师,也适合刚入 TensorRT 部署想找一条完整落地方案的后端开发。
2. 先从算法到计算图:RetinaFace 的输出张量与 ONNX 导出
2.1 不用背完整模型结构,但三个 head 的输出必须刻在脑子里
RetinaFace 本身是一个多任务检测模型,骨干网络后面接了 FPN 和 SSH 上下文模块,最终输出三路 head:人脸分类、边界框回归、五个人脸关键点回归。常见的开源版本以 MobileNet0.25 或 ResNet50 作为 backbone,工程部署里用得最多的是前者。
真正进 TensorRT 的不是整个网络结构,而是导出后的 ONNX 计算图。以 640×640 输入、anchor 解码已经放进模型内部的常见导出为例,ONNX 输出一般是三个张量:
| 输出张量 | 维度(NCHW) | 含义 |
|---|---|---|
| cls | 1 × 2 × 16800 | 人脸/背景两类得分,未过 softmax 的 logits |
| bbox | 1 × 4 × 16800 | 每个 anchor 对应的框坐标,已经是缩放后的绝对坐标 |
| landmark | 1 × 10 × 16800 | 5 个关键点的 x/y,每个点两个通道 |
这里 16800 是 640×640 输入下三个 stride(8/16/32)叠加出来的 anchor 总数。不同输入尺寸会改变这个数字,但通道顺序不会变。部署时我不建议去背整个网络的细节,但拿到一个 ONNX 后第一步一定是打印输出节点的名字和维度,确认哪个是 cls、哪个是 bbox,这一步错了后面全错。
TensorRT 为什么能把速度拉起来,核心是三件事:层融合、kernel 自动选择、显存复用。RetinaFace 这种 CNN 结构里大量 Conv+ReLU+BN 会被熔成一个算子,FP16 模式下访存压力又减半,所以同样一张卡,TensorRT 的收益非常直接。INT8 还能再快一截,但 RetinaFace 的关键点回归对量化敏感,我一般先落地 FP16,INT8 只做消融对比。
2.2 导出 ONNX 的落地设置:固定 shape 优先,动态 batch 留到多路优化
导出这一步决定了后面 TensorRT engine 的质量。常见做法是直接用 PyTorch 的 torch.onnx.export,但有几处必须调对。
import torch # net 是已经加载权重的 retinaface 模型,切换 eval 状态 net.eval() # 固定 batch=1、固定输入尺寸的导出,线上服务首选 dummy_input = torch.randn(1, 3, 640, 640) torch.onnx.export( net, dummy_input, "retinaface.onnx", export_params=True, opset_version=11, do_constant_folding=True, input_names=["input"], output_names=["cls", "bbox", "landmark"], dynamic_axes={ "input": {0: "batch"}, "cls": {0: "batch"}, "bbox": {0: "batch"}, "landmark": {0: "batch"}, }, )这段代码里,opset_version 我固定用 11,兼容性和算子支持都稳妥;dynamic_axes 只给 batch 维度开了动态,是为了后续多路并发时能尝试 batch 推理。输入尺寸我没有开动态,640×640 是部署目标尺寸,就让模型以这个形状固化。
需要注意两个细节。第一,输出顺序必须和后面 C++ 端 getBindingIndex 拿到的 index 一一对应,导出代码里 output_names 写的是 cls、bbox、landmark,后面 C++ 也按这个顺序取,不要自己调换。第二,anchor 解码是否在模型内,直接决定后处理代码写法。如果导出后的 bbox 输出维度是 1×4×16800,意味着解码已经在模型里完成,C++ 端只需要做 sigmoid 和阈值过滤;如果模型输出的是 1×16800×4 的原始偏移,那还得自己乘 stride 加 anchor。拿到 ONNX 后用 netron 或 onnxruntime 打印输出维度,一眼就能确认。
2.3 用 trtexec 构建 engine:主要参数与第一次精度对照
ONNX 只是中间产物,TensorRT 真正执行的是 engine 文件。构建 engine 有两种方式:命令行 trtexec 和 C++ Builder API。工程初期我推荐 trtexec,因为它把 profiling 和正确性检查都内置了,还能快速验证 FP16 是否掉点。
/usr/src/tensorrt/bin/trtexec \ --onnx=retinaface.onnx \ --saveEngine=retinaface_fp16.engine \ --fp16 \ --minShapes=input:1x3x640x640 \ --optShapes=input:1x3x640x640 \ --maxShapes=input:8x3x640x640 \ --memPoolSize=workspace:2048参数含义按我习惯拆开解释。--fp16 开启半精度;--minShapes 和 --maxShapes 分别是最小和最大 batch,optShapes 是优化基准 shape,如果暂时只跑单路,三个值都填 1 就行,性能会最大化;--memPoolSize=workspace:2048 是给构建过程分配 2GB 显存上限,避免吃满整卡。batch 上限给到 8,是为了给后面的多路并发留尝试空间。
构建完成后第一次验证不能只看跑通,必须做一次精度对照。用同一张含多个人脸的测试图,分别用 FP32 engine 和 FP16 engine 推理,对比 bbox 和 landmark 的绝对误差。我的标准是:人脸框 IoU 掉 1% 以内可以接受,关键点像素偏移在 640 输入下不超过 3 个像素。超过这个范围就要考虑使用 --strictTypes 强制某些层回退 FP32,或者用 ONNX GraphSurgeon 单独把那几个 head 的精度钉死。
提示:engine 文件强绑定 TensorRT 版本、CUDA 版本和 GPU 架构。同一份 retinaface_fp16.engine 换一张 GPU 就可能反序列化失败,不是你的代码写错了。
3. 搭一个能维护的 Cpp 推理项目:TensorRT 安装、CMake 与 engine 加载
3.1 Ubuntu 安装 TensorRT:版本配对比下载姿势更重要
很多 Cpp 项目在 Windows 上改半天跑不通,最后发现是 TensorRT 版本和 CUDA 对不上。Ubuntu 下安装 TensorRT 常见做法是下载 tar 包解压,路径自己管理,比 apt 方式更可控,也便于 CI 环境复现。以 TensorRT 8.6 系列配 CUDA 11.8 为例:
tar -xzvf TensorRT-8.6.x.x.Ubuntu-20.04.x86_64-gnu.cuda-11.8.tar.gz mv TensorRT-8.6.x.x ~/TensorRT # 环境变量追加到 ~/.bashrc export TRT_ROOT=$HOME/TensorRT export LD_LIBRARY_PATH=$TRT_ROOT/lib:$LD_LIBRARY_PATH export PATH=$TRT_ROOT/bin:$PATH # 验证库文件齐全 ls $TRT_ROOT/lib/libnvinfer.so*这里强调一个排查点:解压后必须确认 libnvinfer.so 存在,并且 ldd 能解析到它依赖的 CUDA/cuDNN 库。很多安装"看起来成功"但一运行就报 libnvinfer.so.8: cannot open shared object file,基本就是 LD_LIBRARY_PATH 没生效,或者 CUDA 版本不对。检查命令是ldd $TRT_ROOT/lib/libnvinfer.so,看到 not found 就去核对 CUDA 版本,不要急着重装整个 TensorRT。
版本配对是 TensorRT 部署里最常见的坑,没有之一。我的经验是:先在目标机器上用nvcc --version和nvidia-smi确认 CUDA 版本,再去选对应 TensorRT 发行版,顺序不能反。
3.2 CMake 工程骨架:库链接顺序和编译选项别乱改
Cpp 项目搭建阶段,CMakeLists 看起来简单,但库链接顺序和编译标准会直接影响调试体验。手写一个很薄的骨架就够了:
cmake_minimum_required(VERSION 3.18) project(retinaface_trt CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 调试阶段用 -g -O0,上线前再切 Release -O2 set(CMAKE_CXX_FLAGS_DEBUG "-g -O0") set(CMAKE_CXX_FLAGS_RELEASE "-O2 -DNDEBUG") # TensorRT 与 CUDA 头文件 include_directories(${TRT_ROOT}/include /usr/local/cuda/include) link_directories(${TRT_ROOT}/lib /usr/local/cuda/lib64) add_executable(retinaface_demo src/main.cpp src/retinaface.cpp ) target_link_libraries(retinaface_demo nvinfer cudart )链接顺序值得多说一句:nvinfer 必须放在可执行文件之后、依赖它的库之前,静态链接时顺序错了会报 undefined reference。日常调试我就是用g++ -g -o demo那一套去复现段错误,配合 gdb 看 backtrace;CMake 里只要保留 Debug 模式,没必要搞复杂的生成器表达式。
如果你是刚走 C++ 学习路线转来做部署,我的建议是先扎实理解 CMake 的目标概念和头文件搜索路径,再写业务代码。工程里最花时间的不是推理本身,而是环境变量、链接顺序、ABI 兼容这一类基础问题。
3.3 加载 engine 与创建 context:反序列化一段代码撑起整个服务
engine 文件是持久化的计算图,运行时需要通过 IRuntime 反序列化。这部分代码非常固定,但也非常容易写错生命周期:
#include <fstream> #include <vector> #include <NvInfer.h> std::vector<char> loadEngineFile(const std::string& path) { std::ifstream file(path, std::ios::binary | std::ios::ate); std::streamsize size = file.tellg(); file.seekg(0, std::ios::beg); std::vector<char> buffer(size); file.read(buffer.data(), size); return buffer; } nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(gLogger); auto engine = std::unique_ptr<nvinfer1::ICudaEngine>( runtime->deserializeCudaEngine(buffer.data(), buffer.size())); auto context = std::unique_ptr<nvinfer1::IExecutionContext>( engine->createExecutionContext());gLogger 我一般用nvinfer1::ILogger的匿名子类,不打印 INFO 只打印 ERROR,避免生产日志被刷爆。deserializeCudaEngine 执行一次就行,后续推理全部走 context。
这里有一个很关键的特性:ICudaEngine 是只读且线程安全的,可以多线程共享;但 IExecutionContext 不是,每个线程必须创建自己的 context。很多人C++部署翻车就翻在这里。engine 和 runtime 用 unique_ptr 管理,省去手动释放的繁琐;context 的生命周期必须长过所有 enqueue 调用,不能拿到局部函数里用。
3.4 预处理也写进工程:letterbox、颜色顺序与归一化
预处理看起来不起眼,却是坐标错位的重灾区。RetinaFace 的输入要求是 640×640 的 RGB 图,值域归一化到 0~1。视频流里的原始帧基本是 1920×1080 的 BGR,必须经过缩放、填充、转通道、归一化四个步骤:
cv::Mat image = cv::imread("frame.jpg"); // BGR, HWC int target = 640; float scale = std::min(target / (float)image.cols, target / (float)image.rows); int newW = std::round(image.cols * scale); int newH = std::round(image.rows * scale); cv::Mat resized; cv::resize(image, resized, cv::Size(newW, newH), 0, 0, cv::INTER_LINEAR); // 中心填充,记录 pad 值供后处理逆变换 int padX = (target - newW) / 2; int padY = (target - newH) / 2; cv::Mat canvas = cv::Mat::zeros(target, target, CV_8UC3); resized.copyTo(canvas(cv::Rect(padX, padY, newW, newH))); // BGR 转 RGB,并转换为 NCHW 排布的 float 数据 cv::Mat rgb; cv::cvtColor(canvas, rgb, cv::COLOR_BGR2RGB); // 按通道拆分并归一化,填入 inputTensorscale、padX、padY 这三个值必须作为后处理参数传出去,因为最终检测框是在 640×640 坐标系里输出的,还原到原图要做逆变换:x_orig = (x_pred - padX) / scale。很多开源代码用右/下填充而不是中心填充,如果导出模型训练时用的是中心填充,两者混用会导致框整体偏移。我一般统一用中心填充,并在代码注释里标明。
调这种 C++ 工程时,我会在 VSCode 里配 clangd 加 compile_commands.json,跳转函数定义比全局搜索快很多,尤其适合 TensorRT 这种头文件套头文件的库。
4. 解码与 NMS:RetinaFace 的三路输出怎么变成可用的人脸框
4.1 先读对 binding index:把内存布局当输入而不是黑匣子
TensorRT 引擎里每个输入输出张量都有一个 binding index,通过名称获取。不要硬编码 0、1、2,因为 ONNX 输出顺序在不同版本里可能不同,即使代码没变,重新导出后顺序也可能变。正确做法是运行时查询:
int inputIndex = engine->getBindingIndex("input"); int clsIndex = engine->getBindingIndex("cls"); int bboxIndex = engine->getBindingIndex("bbox"); int landmarkIndex = engine->getBindingIndex("landmark");拿到 index 之后要确认张量维度。RetinaFace 输出一般是 NCHW 排布,例如 cls 的维度是 {1, 2, 16800},这意味着内存里第一个通道是人脸负类、第二个通道是正类。如果把它当成 {1, 16800, 2} 去读,取到的就是完全错乱的数据。
TensorRT 的 binding 数据是连续显存块,enqueue 之后用 cudaMemcpyAsync 拷回 host。对于 640 输入,cls 约 33KB、bbox 约 67KB、landmark 约 168KB,单次拷贝量很小,可以不用 pinned memory,但用 pinned memory 会降低拷贝延迟。我一般直接分配一个足够大的连续 host buffer,三个输出共用一份,减少内存碎片。
还有一点:TensorRT 默认输入是 NCHW,如果你的预处理写成了 NHWC,推理结果会完全错乱。初学 C++ 部署最容易忽略这种内存布局差异,调试时第一件事是打印 dims 数组而不是猜。
4.2 解码循环:sigmoid、阈值过滤与坐标回退
这一步是 RetinaFace 部署的核心。假设模型输出已经是解码后的绝对坐标,后处理只需要做 sigmoid、阈值过滤、还原到原图、NMS。
float score_thresh = 0.5f; int num = 16800; std::vector<float> boxes; std::vector<float> landmarks; std::vector<float> scores; for (int i = 0; i < num; ++i) { float prob = 1.0f / (1.0f + std::exp(-clsData[i * 2 + 1])); if (prob < score_thresh) continue; float x1 = bboxData[i * 4 + 0]; float y1 = bboxData[i * 4 + 1]; float x2 = bboxData[i * 4 + 2]; float y2 = bboxData[i * 4 + 3]; // 还原到原图坐标,pad 值来自预处理阶段的记录 float orig_x1 = (x1 - padX) / scale; float orig_y1 = (y1 - padY) / scale; float orig_x2 = (x2 - padX) / scale; float orig_y2 = (y2 - padY) / scale; boxes.insert(boxes.end(), {orig_x1, orig_y1, orig_x2, orig_y2}); scores.push_back(prob); for (int k = 0; k < 5; ++k) { float lx = (landmarkData[i * 10 + k * 2] - padX) / scale; float ly = (landmarkData[i * 10 + k * 2 + 1] - padY) / scale; landmarks.insert(landmarks.end(), {lx, ly}); } }代码里的clsData[i * 2 + 1]取的是第二个通道,也就是人脸正类的 logit。如果导出的模型把 softmax 也包进来了,这一步就直接读概率,不需要再做 sigmoid,这一点务必通过打印输出范围确认。padX和scale来自预处理阶段,中心填充和右下填充的逆变换公式不同,写鬼了就会框全部偏左上。
这里也顺带说明cpp里最常用的->操作符:在上面的代码中虽然没有显式用到,但在更完整的工程里,context->enqueueV2、engine->getBindingIndex都是通过->访问对象方法。初学 C++ 的部署同学容易把它和.混用,记住规则:指针用->,引用和对象用.,别在 unique_ptr 上写.enqueueV2然后问编译器为什么报错。
4.3 NMS 与并行化:当 vector 循环成为 CPU 瓶颈
过滤之后同一张人脸可能还有多个框,需要用 NMS 去除重复。常见做法是手写一个按 score 排序后逐次抑制的过程,也可以直接用 OpenCV 的cv::dnn::NMSBoxes。手写版更容易控制坐标类型,推荐在工程里显式实现:
std::vector<int> keep; std::vector<int> order(scores.size()); std::iota(order.begin(), order.end(), 0); std::sort(order.begin(), order.end(), [&](int a, int b) { return scores[a] > scores[b]; }); for (int idx : order) { bool suppressed = false; for (int k : keep) { float iou = computeIoU(boxes[idx], boxes[k]); if (iou > 0.4f) { suppressed = true; break; } } if (!suppressed) keep.push_back(idx); }score_thresh 和 NMS 阈值对结果影响很大。我的常用参数如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| score_thresh | 0.5 | 越低召回越高,但误检也会上来 |
| iou_thresh | 0.4 | RetinaFace 人脸框重叠少,0.4 足够 |
| 输入尺寸 | 640 | 低于 320 小脸基本丢光 |
| FP16 开关 | 开启 | landmark 可能轻微偏移,需要实测 |
当路数多起来,这个循环会变成 CPU 瓶颈。C++17 可以引入<execution>用std::execution::par并行跑阈值过滤,但 NMS 本身是串行依赖的,并行要自己拆分区,收益没有想象中大。我一般先保证循环里少做重复计算,例如把expf替换成__expf,避免在循环里调用std::vector::insert这类可能触发重新分配的操作,预先 reserve 空间。
vector 循环的性能问题在 C++ 部署里非常典型,很多 python 转 C++ 的人会把 Python 的 list append 习惯带过来,结果后处理比推理还慢。记住一个原则:后处理代码追求的是可预测的常数开销,不是极致抽象。
4.4 Windows 侧工程的一个注意点:预编译头
如果你是在 Visual Studio 里做 Windows 端的验证工程,TensorRT 头文件体量很大,每次编译都去解析NvInfer.h会非常慢。常见的做法是把 TensorRT 和 CUDA 的头文件放进预编译头文件(pch),后续编译只做一次解析,增量编译速度提升明显。但要注意预编译头里不要放依赖版本宏的代码,换 TensorRT 版本后要全量重建一次,否则容易出现头文件版本不匹配的编译错。Linux 端 CMake 没有预编译头概念,换版本后直接cmake --build . --clean-first就行。
5. 常见问题排查:engine 跨卡、坐标偏移、并发崩溃与精度漂移的现场排查顺序
5.1 engine 换 GPU 后加载失败
现象:在开发机器上构建的 retinaface_fp16.engine,拷贝到另一台 GPU 机器上运行时,deserializeCudaEngine返回空指针,或者直接抛异常。
原因:TensorRT engine 与 GPU 架构、TensorRT 版本、CUDA 版本强绑定。开发机是 Ampere 架构,线上是 Turing 架构,序列化数据里的 kernel 缓存直接失效。
解决:不要在开发机构建 engine 分发给生产机。生产机上用 trtexec 现场构建,或构建成功后自己实现一个"缓存 engine 到本地磁盘"的模块,但首次加载前必须校验cudaDeviceProp的 major/minor 版本。也可以在代码里 catch 反序列化异常后自动降级重新构建,这是最稳的做法。
5.2 人脸框整体向左上偏移
现象:检测置信度正常,NMS 结果也对,但画出来的框整体偏左上,或者人脸越大框越偏。
原因:预处理用了中心填充,后处理逆变换却按左上填充的公式计算;或者反过来。还有一种情况是 letterbox 时padX/padY用的是(target - newW) / 2,但还原时忘了减 pad,直接把 640 坐标除以 scale,误差随着 pad 增大而放大。
解决:先在预处理函数里把 scale、padX、padY 三个值打日志,再用一张单脸图跑推理,手动验证(x_pred - padX) / scale是否落在原图的真实人脸框上。这个检查只能靠人眼对比,没有捷径。
5.3 多线程并发随机段错误
现象:单线程推理稳定,开 4 个线程各处理一路视频后,程序随机崩溃,backtrace 指向 enqueueV2 或 cudaMemcpy。
原因:多个线程共享了同一个IExecutionContext。TensorRT 文档明确说明 context 不是线程安全的,多个线程必须以自己的 context 执行 enqueue,底层的 CUDA stream 也必须是各自独立的。engine 可以共享,context 不能。
解决:每个线程创建独立的 context 和 stream,engine 全局唯一。线程启动后先调用一次 context 的 createExecutionContext,而不是从主线程 context 拷贝。我的习惯是写一个ThreadLocalContext封装,把 context、stream、host buffer 都放在线程对象里,避免跨线程访问。
5.4 FP16 后 landmark 偏移明显
现象:FP32 engine 输出关键点位置正常,FP16 engine 检测框还行,但五个关键点整体偏移 5 个像素以上,影响后续人脸对齐。
原因:RetinaFace 的 landmark 回归 head 输出数值范围较小,FP16 尾数精度不足导致回归偏差。有些层对低精度特别敏感,尤其是最后的 1x1 卷积。
解决:先用 trtexec 打开--fp16看是否掉点;如果掉点,用--layerPrecisions指定关键 head 回退 FP32,只让 backbone 部分使用 FP16。更精细的做法是用 ONNX GraphSurgeon 把 landmark head 的 Conv 层精度设为 kFLOAT,然后重新构建 engine。这样能保住大部分加速收益,同时把关键点误差拉回可接受范围。
5.5 结果时好时坏:没等流同步就拷贝
现象:同一张图跑多次,有时结果完全正确,有时输出全零,有时框的位置随机跳变。
原因:enqueueV2 是异步的,推理在 CUDA stream 上排队执行。如果cudaMemcpyAsync在推理尚未完成时就发起拷贝,拷回来的 host 数据可能是旧数据或半写入数据。
解决:enqueueV2 之后必须调用cudaStreamSynchronize(stream),再执行 host 端解码。这个同步是必须的,不能为了省时间省略。如果用的是cudaMemcpyAsync到 pinned memory,同样要等待 stream 完成。我一般会用cudaEvent做细粒度计时,同时在拷贝前显式同步,避免把 GPU 流水线里的脏数据带出来。
6. 压测与多路承载:用同一段代码算出你的单卡能带多少路视频
6.1 最小但足够的压测循环
跑通不是终点,能支撑多少路视频流才是关键。用下面的计时循环测端到端延迟,比直接用 nvidia-smi 看利用率更准确:
auto start = std::chrono::high_resolution_clock::now(); context->enqueueV2(buffers.data(), stream, nullptr); cudaStreamSynchronize(stream); auto end = std::chrono::high_resolution_clock::now(); float ms = std::chrono::duration<float, std::milli>(end - start).count();压测时要先做 50 次预热,排除显存初始化和 kernel 编译的影响。统计平均值之外还要看 p99,单帧均值 3ms 但 p99 跳到 20ms 的情况在视频流里很常见,可能原因是 CPU 后处理线程抖动或显存碎片。计算多路承载时,保守用 p99 而不是平均值。
通常题目里那个"T4 上 1080p 25 帧每秒用 TensorRT 640 分辨率能支持多少路"的问题,答案依赖具体机器和 kernel 选择,但可以给出经验公式:单路 25fps 意味着每帧最多约 40ms 的处理预算,如果 RetinaFace 在 T4 上 FP16 实测单帧 3ms,单 batch 下纯 GPU 推理就能覆盖约 13 路;加上视频解码、缩放、后处理,实际能稳定跑到 8~10 路就算不错。如果后端 CPU 是瓶颈,再大的 batch 也救不回来。
6.2 从延迟估算路数:先看单帧,再看 batch
多路优化我一般分两步走。第一步先把单帧延迟压到能接受的范围,调整输入尺寸、FP16/INT8、后处理并行度;第二步再尝试 batch 推理,把多路的输入拼成一个 batch,用一次 enqueue 处理多帧,GPU 吞吐会明显提升。但 batch 推理会引入额外的排队延迟,不适合对单帧延迟极敏感的业务。
我自己的习惯是:先用trtexec --duration=10做一次纯 GPU 测速,得到该 engine 在当前卡上的真实吞吐上限,然后再把预处理和后处理加进来做端到端压测。如果 GPU 利用率不到 70%,大概率是 CPU 后处理拖了后腿;如果显存占用高但利用率低,就该检查是不是每帧都在重复 cudaMalloc。把这些细节都排掉后,T4 上跑 640 输入 FP16 RetinaFace,做到 10 路 1080p 25fps 的视频管线并不是难事。希望帮到你。
本文还有配套的精品资源,点击获取