TensorRT+C++部署SuperPoint+SuperGlue:低延迟特征匹配工程实践
简介面向需要把SuperPoint与SuperGlue算法部署到实际视觉系统中的C开发者这一压缩包提供基于TensorRT的完整落地思路与可运行工程。资源共50个文件包含10个头文件与5个C源文件用于SuperPoint关键点检测和SuperGlue描述子匹配的推理实现4个Python脚本负责将PyTorch模型转换成ONNX再生成TensorRT引擎3个engine和3个onnx覆盖室内外模型权重可直接调用另含20张图片、配置yaml、README说明、CMakeLists工程文件和演示gif包体约209MB。目前已有418人学习。项目安排上先完成两个算法的C迁移再结合TensorRT做图优化、层融合、精度校准与引擎构建针对实际部署中的性能瓶颈和精度损失均给出排查思路。读者可获得freiburg序列图像推理案例、室内外模型文件、转换脚本、头文件与C源码并掌握TensorRT的CAPI使用、ONNX转换流程和模型加速技巧适合具备C与深度学习基础、希望提升推理速度的工程师。1. 用 TensorRT 和 C 部署 SuperPointSuperGlue先解决一个现实问题做过视觉 SLAM 或者图像配准的同行应该都有体会Python 里跑 SuperPointSuperGlue 的 Demo 很顺畅torch 一加载、直接 forward特征点哗哗往外冒。可一旦你想把它塞进巡检机器人、无人机或者工业检测的实时链路里事情就开始变味了。模型推理占掉几百毫秒显存被 torch 的运行时吃掉一大块GPU 利用率还不高。用 TensorRT 加 C 把这个组合部署成生产级服务是我这两年做视觉前端落地时被验证过的一条可靠路径推理延迟砍掉一个数量级显存占用压到几十 MB整个链路可以被一个几十 KB 的可执行文件包住。这篇文章面向的读者很具体手里有 SuperPointSuperGlue 的权重想在 Jetson、工控机或数据中心 GPU 上把它做成稳定低延迟的 C 服务并且对 TensorRT 的 engine 构建、动态 shape 和 C 端内存管理有真实诉求的人。2. 先把网络和引擎对齐SuperPoint、SuperGlue 在 TensorRT 里到底在算什么2.1 SuperPoint 的编码器与两个输出头C 侧看到的张量是什么样的SuperPoint 的结构并不复杂一个 VGG-like 的编码器卷积加降采样后面挂着两个头关键点头和描述子头。关键点头输出的是半分辨率下的 heatmap形状是1, 1, H/8, W/8值越大代表这个位置越可能是角点描述子头输出的是半分辨率下的 256 维描述子形状是1, 256, H/8, W/8之后会做 L2 归一化。C 侧拿到这两个输出后要做的事情和 Python 侧完全一样在 heatmap 上做非极大值抑制NMS取出 top-K 个关键点坐标再按坐标把描述子从特征图里抽出来按通道维排成K, 256的矩阵。这一步值得在 TensorRT 的 engine 之外单独做不要塞进网络图里。原因是 NMS 和坐标索引在 TensorRT 里要么没有原生算子要么实现起来要拼一堆小算子性能不一定比在 CUDA 核函数里手写更好。常见做法是让 TensorRT 只输出 heatmap 和描述子图剩下的取出 top-K、坐标还原到原图分辨率、描述子 L2 归一化都在 C 侧用一个简单的 CUDA kernel 或干脆 CPU 端循环完成。SuperPoint 输入图像一般会被缩放到640x480或480x360即便在 CPU 端做 NMS 和抽取开销也就在几毫秒量级不会成为瓶颈。描述子的 L2 归一化有个细节要留意。PyTorch 里的实现是在dim1上做归一化也就是说按通道维归一化的是每个位置上的 256 维向量而如果你直接在 C 里按行归一化数据布局不同会产生完全错误的结果。TensorRT 输出的布局默认是 NCHW描述子张量的内存顺序是C, H, W连续排列的所以你按空间位置取描述子时必须按desc[ch * H * W y * W x]这样的内存偏移去索引而不是按desc[y * W x]去取。这个错误不报错也不会产生显存越界但取出来的描述子匹配率会低到让你怀疑模型没转换对。2.2 SuperGlue 的 GNN 与最优传输Sinkhorn 迭代放在哪一层SuperGlue 的核心是两个关键点集之间的匹配。它先把两幅图的描述子经过一个编码器映射成节点特征然后在图神经网络里做 self-attention 和 cross-attention 的消息传递最后用一个可学习的最优传输层输出一个得分矩阵矩阵的维度是M1, N1多出来的那一行一列是 dustbin代表「没有匹配」的置信度。这套东西转换到 TensorRT 时有几个关键决策点。attention 层里的 multi-head 结构、线性投影和 LayerNormONNX 导出时基本都能映射到 TensorRT 的算子真正麻烦的是 Sinkhorn 迭代。Sinkhorn 本质上是一连串的逐行逐列归一化在 PyTorch 里用一个for循环迭代 20 次左右。如果你把这个循环原样导出到 ONNXTensorRT 会把每一次迭代展开成一串显式算子图会变得非常大而且可能触发某些算子融合失败构建速度明显变慢推理延迟也不一定理想。我一般会把 Sinkhorn 迭代次数从部署时的 20 次下调到 5 到 7 次。SuperGlue 的作者在训练时用的就是逐步减少迭代次数的策略推理阶段 5 次迭代和 20 次迭代的匹配质量差距在肉眼和常见指标上几乎看不出来但延迟能省下一大截。至于得分矩阵的后续处理——用阈值过滤低分匹配、按 dustbin 行和列判断 unmatched这些放在 C 侧做和 SuperPoint 的 NMS 一样不要塞进 TensorRT。C 端处理一个M1, N1的矩阵遍历一遍的代价在几万关键点规模下是微秒级的。2.3 为什么不用 Python 直接部署延迟之外还有显存和线程安全很多人问的一个问题是Python 有 torch 或者 onnxruntime为什么要绕一大圈去做 TensorRT C 部署直接原因有三个。第一是延迟Python 侧每次推理有解释器开销和 torch 的运行时调度开销单次推理多出几十毫秒在视觉 SLAM 这种循环里是致命的第二是显存torch 的 CUDA context 启动就占掉几百 MBTensorRT 的 engine 本身显存占用可以压到几十 MB 级别第三是线程安全Python 的 GIL 和 torch 的多线程推理在真实服务里会让你吃尽苦头而 C 侧一个 context 一个线程行为可预期得多。在 Jetson 这类嵌入式设备上还有一个额外理由TensorRT 是 NVIDIA 官方在 Jetson 平台上的第一公民DeepStream 和 VPI 等周边组件都围绕它工作。如果后续想把特征匹配的结果接进目标跟踪或者重定位链路TensorRT 的 engine 可以直接被其它模块复用而不用维护一个常驻的 Python 进程。对比项Python PyTorchPython ONNX RuntimeC TensorRT单次推理延迟典型 640x48030~80 ms15~40 ms3~8 ms显存占用500 MB 以上200 MB 左右50~150 MB多线程并发GIL 限制需小心配置每 context 独立嵌入式平台支持受 PyTorch 版本限制一般原生支持3. 从 pt 到 engine把 PyTorch 权重转成 TensorRT 能吃的格式3.1 ONNX 导出opset 版本和动态轴设在哪TensorRT 不直接吃.pt权重这是第一个要迈过去的坎。常见路径是 PyTorch → ONNX → TensorRT engine这个流程里 ONNX 导出的质量直接决定后面能不能顺利构建 engine。以 SuperPoint 为例我一般会用下面的脚本导出import torch import torch.onnx from superpoint import SuperPoint model SuperPoint() model.load_state_dict(torch.load(superpoint_v1.pth, map_locationcpu)[model]) model.eval() dummy_input torch.randn(1, 3, 480, 640) torch.onnx.export( model, dummy_input, superpoint.onnx, opset_version17, input_names[input], output_names[scores, descriptors], dynamic_axes{ input: {2: height, 3: width}, scores: {2: height, 3: width}, descriptors: {2: height, 3: width}, }, do_constant_foldingTrue, verboseFalse, )这里有两个参数要特别注意。dynamic_axes里把高和宽设成了动态轴这直接对应 TensorRT profile 里的 min/opt/max 配置。SuperPoint 在推理时图像分辨率可能会有变化如果你把分辨率写死成480x640后续换一个输入尺寸就必须重新构建 engine很不划算。opset_version我会选 17 或更高较低版本对grid_sample、scatter这类算子的支持不完整导出时不会报错但转 TensorRT 时会出现不支持的节点。导出完成后先用 onnxruntime 或onnx.checker验证一遍。有一个很隐蔽的问题ONNX 导出时把torch.max或argmax这类带索引的算子展开成了多个基础算子某些展开方式会让 TensorRT 的图优化器无法融合导致构建变慢。如果遇到这种情况可以在导出时用torch.onnx.export的operator_export_type参数切换导出模式或者干脆在模型代码里把max替换成amax这类 ONNX 原生支持的算子。3.2 用 trtexec 构建 engine三个 profile 参数怎么填拿到 ONNX 之后构建 engine 有两种方式直接用 NVIDIA 自带的trtexec命令行工具或者在 C 代码里调用OnnxParser。我建议第一次构建用 trtexec因为构建信息一目了然确认参数没问题后再把构建逻辑写进代码里。以 SuperPoint 为例一条典型命令是这样trtexec \ --onnxsuperpoint.onnx \ --saveEnginesuperpoint.engine \ --minShapesinput:1x3x360x480 \ --optShapesinput:1x3x480x640 \ --maxShapesinput:1x3x720x1280 \ --fp16 \ --workspace2048minShapes、optShapes、maxShapes三个参数定义了这个 engine 能接受的输入尺寸范围TensorRT 会分别为三个档位做图优化。optShapes设置为实际部署中最常见的分辨率minShapes和maxShapes是防边界情况用的范围设得越大TensorRT 生成的 kernel 越多engine 文件也越大。一般部署场景里min 实际最小值、opt 实际主要值、max 实际最大值就够了不要把范围设置到模型完全不支持的尺寸。--fp16有个坑它只开启 FP16 计算不改变输入输出张量的数据类型。输入仍然是 FP32TensorRT 在内部把能安全转换的层切换成 FP16精度敏感的层保持 FP32。SuperGlue 里的 LayerNorm 和 Sinkhorn 归一化对精度比较敏感如果强制全精度转换匹配质量会下降。TensorRT 从 8.x 开始有自动精度选择机制你只需要启用--fp16并允许 TensorRT 自主决定哪些层用 FP16而不是用--precisionFP16去强制所有层。构建完成后一定要检查日志里的Total Host Memory、Total Device Memory和每个 layer 的推理时间。如果某个 layer 的时间异常长比如 Sinkhorn 展开后的某个归一化层花了 1 毫秒以上说明这个 layer 没有被有效融合回到 ONNX 导出那一步去改算子可能是更高效的路径。3.3 量化精度档位FP16 和 INT8 的取舍TensorRT 支持 INT8 量化理论上能把延迟再砍一半但 SuperPoint 和 SuperGlue 是典型的「尾部精度敏感」模型INT8 量化后特征点的重复性和描述子的区分度都会有可感知的下降。我的经验是在 Jetson 系列Orin 及以后上用 FP16 完全够用在更老的嵌入式 GPU 上如果确实需要 INT8务必准备一个真实分布的数据集做校准而不是随机生成几万张图草草校准。校准集的选择直接影响 INT8 效果。校准数据要覆盖模型在部署时可能见到的所有场景——室内、室外、弱纹理、过曝——每类至少几百张。calibration cache 文件要保留好换 TensorRT 版本后如果 engine 需要重建cache 还在就能省去重新校准的耗时。还要注意校准数据不参与梯度计算但要走一遍完整的前向推理所以校准耗时等于数据量乘以单次推理时间几百张图在 GPU 上几分钟内跑完不算门槛。4. C 部署把 engine 接进 SuperPointSuperGlue 的处理链4.1 engine 加载与输入输出绑定搞清楚指针和生命周期engine 构建完成的产物是一个二进制文件C 侧的工作从这个文件开始。加载和推理的核心代码大致如下#include NvInfer.h #include fstream #include vector std::vectorchar loadEngineFile(const std::string path) { std::ifstream file(path, std::ios::binary | std::ios::ate); size_t size file.tellg(); file.seekg(0, std::ios::beg); std::vectorchar buffer(size); file.read(buffer.data(), size); return buffer; } auto readFile loadEngineFile(superpoint.engine); auto runtime std::unique_ptrnvinfer1::IRuntime(nvinfer1::createInferRuntime(logger)); auto engine std::unique_ptrnvinfer1::ICudaEngine( runtime-deserializeCudaEngine(readFile.data(), readFile.size(), nullptr)); auto context std::unique_ptrnvinfer1::IExecutionContext( engine-createExecutionContext()); int inputIndex engine-getBindingIndex(input); int scoreIndex engine-getBindingIndex(scores); int descIndex engine-getBindingIndex(descriptors); // 动态 shape 必须显式设置 context-setInputShape(inputIndex, nvinfer1::Dims4(1, 3, 480, 640)); context-setTensorAddress(inputIndex, inputDevicePtr); context-setTensorAddress(scoreIndex, scoreDevicePtr); context-setTensorAddress(descIndex, descDevicePtr);setInputShape是动态 shape 场景下最容易漏的一步。如果你跳过它推理会直接失败或产生错误结果报错信息还不太直观。setTensorAddress绑定的是 device 指针C 侧需要你自己用cudaMalloc分配好输入输出的显存并把指针递进去。生命周期管理是这里最常见的坑。engine和context都是智能指针管理但runtime的生命周期必须长过 engine 和 context否则在程序退出时会出现 cuda 相关的崩溃。另外如果你在服务里想多线程并发推理一个好的模式是一个engine创建多个context每个线程持有一个独立的context而不是每线程持有一个独立的 engine。context比engine轻量得多且共享引擎的权重参数显存占用更低。4.2 预处理归一化、BGR/RGB 和内存布局SuperPoint 在 PyTorch 里的预处理通常是读图 → resize → 转 RGB → 归一化到[0, 1]→ 转 CHW → 加 batch 维。C 侧做同样的事情但要注意三个常被忽略的细节。第一是颜色通道顺序。OpenCV 读进来是 BGRPyTorch 训练时用的是 RGB如果直接把 OpenCV 的图喂给 TensorRT特征点质量会显著下降。常见的做法是在 CUDA kernel 里顺手做 BGR→RGB 的转换不要先在 CPU 上做一次cvtColor再拷贝到显存那样多一次 PCIe 拷贝。第二是归一化参数PyTorch 里是标准归一化(x / 255.0)没有 mean 和 std但有些人的自定义训练脚本里用了mean[0.485, 0.456, 0.406]这两者的预处理必须和训练时完全一致否则 model 拿到的是分布漂移的输入输出没有任何意义。第三是 HWC 到 CHW 的转换TensorRT 默认要求 NCHW 布局OpenCV 的Mat是 HWC 布局这个转换在 CUDA kernel 里一起做掉__global__ void preprocessKernel( const uint8_t* src, float* dst, int width, int height) { int idx blockIdx.x * blockDim.x threadIdx.x; int total width * height; if (idx total) return; int x idx % width; int y idx / width; int srcIdx y * width x; int dstIdx x * height y; // CHW 布局下的偏移 // BGR - RGB并归一化到 [0, 1] dst[0 * width * height y * width x] src[srcIdx * 3 2] / 255.0f; dst[1 * width * height y * width x] src[srcIdx * 3 1] / 255.0f; dst[2 * width * height y * width x] src[srcIdx * 3 0] / 255.0f; }这个 kernel 是完整可用、能直接编译的版本。dst的空间是按(C, H, W)分配的dst[0 * H * W y * W x]对应通道 0 的第(y, x)个像素索引写错会得到一张完全混乱的图而且不一定报错。把预处理直接塞进显存操作能免掉 CPU↔GPU 之间的多次拷贝在图像尺寸大的时候省下的延迟不容忽视。4.3 后处理与匹配 pipeline把两个模型串成一条流水线SuperPoint 推理输出原始张量后C 侧要把它们接进 SuperGlue再把匹配结果输出到上层模块。完整流程拆成下面几步// 1. 取出 top-K 关键点 std::vectorcv::KeyPoint keypoints; extractTopK(scoresHost, width, height, k, confidence_threshold, keypoints); // 2. 按坐标抽取描述子L2 归一化 std::vectorfloat descs(k * 256); collectDescriptors(descHost, keypoints, descs.data(), width, height); normalizeDescriptors(descs.data(), k); // 3. 构造 SuperGlue 的输入 float* glueInputHost new float[2 * k * 256]; memcpy(glueInputHost, descs.data(), k * 256 * sizeof(float)); // 把第二幅图的描述子接到后面 // 4. 推理 SuperGlue得到 M1 行 N1 列的得分矩阵 std::vectorfloat scoresMatrix((M 1) * (N 1)); // 5. 阈值过滤 dustbin 处理 std::vectorcv::DMatch matches; filterMatches(scoresMatrix.data(), M, N, threshold, matches);关键点的置信度阈值是一个需要调的参数。SuperPoint 默认置信度阈值在 0.015 左右但在不同的光照和纹理条件下这个值可能需要调整。如果匹配点太少先别调 SuperGlue 的参数试着把 SuperPoint 的 top-K 从 1024 提高到 2048 往往立竿见影。top-K 越大SuperGlue 的 attention 计算量平方级增长所以这个值要在匹配数量和延迟之间找平衡。SuperGlue 的得分矩阵处理有个细节矩阵的行代表第一幅图的关键点列代表第二幅图的关键点(M, N)位置的 score 同时满足两个条件才算是有效匹配——该位置的 score 大于阈值且它同时是这一行的最大值、也是这一列的最大值。这个「双向最大值」检查不能省否则会得到大量一对多的错误匹配。5. 部署避坑5 个让 engine 白构建的常见翻车点5.1 TensorRT 10.x 在老 GPU 上报「sm_xx not supported」现象在 GTX 1070 或者更老的 Maxwell 架构 GPU 上用 TensorRT 10.x 加载 engine直接报错说支持的 SM 版本不匹配或者构建时找不到对应的 CUDA capability。原因TensorRT 10.x 的官方预编译包默认不再包含 Pascal 及更老架构的支持。GTX 1070 是 6.1 计算能力而新版本 TensorRT 的某些 kernel 是为 7.5 及以上的 Turing 架构编译的。这属于版本兼容问题不是代码 bug。解决先确认自己 GPU 的计算能力查到之后去 NVIDIA 官网查 TensorRT 的 release note看该版本最低支持到哪个 SM。如果是 Pascal 架构的老卡锁到 TensorRT 8.5 LTS 或 8.6 版本同时在构建命令里显式指定--deviceTypeGPU和对应的 SM 参数不要盲目追新。在 Jetson 设备上同理JetPack 自带的 TensorRT 版本和 GPU 算力是匹配过的自己重装新版 TensorRT 经常踩进这个坑里。5.2 ONNX 导出成功但 trtexec 报「Unsupported Operator」现象torch.onnx.export一路顺利onnxruntime 也能跑通一进 trtexec 就报某层不支持的算子常见于torch.nn.functional.grid_sample或某些scatter、index_put操作。原因PyTorch 的 ONNX 导出器对部分算子采用的是「分解」策略——把一个高层算子拆成多个 ONNX 基础算子。这些基础算子互相组合时可能超出 TensorRT 的算子支持范围。尤其在动态 shape 场景下某些降级用的算子组合无法被解析。解决查看报错日志里具体是哪一层不兼容回到 PyTorch 代码里把那一段改写。SuperPoint 的 NMS 如果在模型内部实现大概率就是罪魁祸首——把它从模型里摘掉放在 C 侧做。另一个备选方案是升级 ONNX 的 opset 版本新版本里 TensorRT 的 parser 支持度更高再不行就查 TensorRT 版本的 operator support 文档在官方安装包 doc 目录里有。5.3 动态 shape 下 engine 构建成功但推理时显存暴涨现象固定 shape 构建的 engine 完全正常改成动态 shape 构建后推理过程中nvidia-smi里显存占用持续攀升或者偶尔触发 cuda OOM。原因minShapes和maxShapes范围跨度太大TensorRT 在构建时按最大尺寸分配了中间缓冲。比如 max 设成720x1280而实际推理一直在480x640中间 activation 的缓冲区远大于实际需要。如果启用了多个 context累积下来的显存消耗会非常可观。解决收紧 maxShapes 到实际业务场景的最大值。如果业务里有 4K 图的需求但高频场景是 1080p我一般会准备两个 engine一个 maxShapes 覆盖到 4K 用于低频高精度场景一个 maxShapes 限制在 1080p 用于实时链路。如果你同时用多个 context给 context 里setInputShape传入实际输入尺寸TensorRT 会按实际尺寸分配部分缓冲但 actovation 缓冲的分配策略还是要看构建时的 profile 设置。5.4 FP16 开启后匹配质量肉眼可见地变差现象不开--fp16时两幅图的匹配结果很干净开 FP16 后匹配数量明显减少或者出现一批明显错误的跨区域匹配。原因SuperGlue 里有 LayerNorm 和 Softmax这两类操作在 FP16 下的动态范围不够导致中间结果截断误差被后续的 attention 放大。TensorRT 的--fp16默认是「尽可能用 FP16」但它在精度收益不明确时不会自动回退到 FP32。解决用--precision和--layerPrecisions参数做层级别的精度控制。优先把 LayerNorm、Softmax、Sinkhorn 相关的层锁定为 FP32其余卷积层保持 FP16。另一个更实用的办法是直接不用成对比较肉眼效果用指标说话把匹配结果的 AUC 或正确率数值跑出来低于阈值就继续调高于阈值就收工。精度调优是层层逼近的过程不要指望一把到位。5.5 第二次推理开始结果错乱或程序崩溃现象第一次推理完全正常第二次开始输出数据不对或者在退出时抛 cuda error。原因最典型的原因为显存指针复用错误。输入输出缓冲区在第一次推理后被覆盖或者 buffer 被释放后 engine 内部还有指针引用着它。另一个原因是 event 同步缺失——异步推理时 CUDA stream 没有同步下一次推理的写入把上一次没算完的数据覆盖了。解决确认输入输出 buffer 的生命周期长于所有推理操作并在每次推理结束后调用cudaStreamSynchronize。多线程场景下每个线程要有独立的cudaStream和独立的IExecutionContext绝对不要在不同线程间共享 context。程序退出时的崩溃检查runtime、engine、context的析构顺序runtime最后析构确保 device 资源全部释放后再销毁 runtime。6. 让部署再进一步engine 复用、异步流水线和一个验证指标上面讲的是把流程跑通这一节说三个让它变得实用的经验。第一个是 engine 的复用策略。engine 文件从二进制反序列化后如果你在进程里创建多个 context它们会共享权重。这在做多路视频流时非常关键一路视频流一个 context每路共享同一份模型权重显存只涨 activation 部分不涨权重部分。常见做法是启动时加载一次 engine之后每次新连接只创建 context这样可以显著降低单路显存占用。第二个是 CUDA stream 的使用。把输入图像的预处理、SuperPoint 推理、关键点后处理、SuperGlue 推理放到两条 stream 里流水线化。第一条 stream 做预处理和 SuperPoint 推理第二条 stream 等 SuperPoint 的 event 信号后再启动 SuperGlue 推理。关键点是不要让 CPU 端在两次推理之间停下来等待拷贝完成——把同步点安排得越少GPU 利用率就越高。如果图像分辨率不大CPU 端的后处理也可以并行做不必等 GPU 全部结束。第三个是验证方法。不要用肉眼判断部署结果。我一般会拿 HPatches 序列里的几百对图像跑一遍计算一个简单指标匹配点中几何一致的比率。做法是用基础矩阵做 RANSAC 校验内点比例高于 60% 就认为这个部署精度是可接受的。这个指标不依赖 ground truth任何场景都能立刻算出来比肉眼数匹配点靠谱得多。每个新模型的部署我都会保留三个东西导出 ONNX 时的导出日志、trtexec 的构建日志、第一次推理时的性能日志。这三份日志是排障的后悔药。TensorRT 的版本升级、算子行为变化都是黑匣子没有日志对照每翻一次车都要从头排查。希望这些踩坑经验能帮你把 SuperPoint 和 SuperGlue 的 TensorRT 部署这条路走得更顺。整个方向是值得投入的把视觉前端的延迟从几十毫秒压到几毫秒之后上层能做的事情会比现在多得多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →