ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LibTorch部署U-Net:从Python到C++的完整实践链路

LibTorch部署U-Net:从Python到C++的完整实践链路

前阵子帮一个做医学影像分割的朋友把一个训练好的U-Net模型从Python环境搬到C++工程里做在线推理,折腾了差不多一整个周末。说实话,在Python里用PyTorch加载模型、跑个预测,基本就是几行代码的事;可一旦脱离Python运行时,要在一个没有anaconda、没有torch的干净部署环境里把同一个模型跑起来,事情就没那么顺了。

当时我第一反应是转ONNX,再走ONNX Runtime,后来发现项目里已有的图像预处理、后处理模块全是C++写的,为了减少中间环节,干脆直接用LibTorch(PyTorch的C++前端)把原来的U-Net权重文件原封不动加载进来。整个过程踩了不少坑,尤其是模型导出阶段和输入张量的数据布局问题,稍不注意预测结果就全乱了。

这篇就把我验证过的一条完整链路整理出来:怎么把训练好的U-Net导出成LibTorch能加载的格式,怎么在C++工程里完成环境配置和模型加载,以及最关键的两部分——预处理保持跟训练时一致、输出结果如何解析回分割图。

如果你也是“模型训练在Python,部署却必须放在C++服务里”的处境,这篇文章应该能帮你少走很多弯路。我不打算只贴能跑的代码,还会把每一步的原理和翻车点一起说清楚。

1. 为什么选择LibTorch:U-Net部署方案的横向对比

先说说U-Net这个模型本身。它是典型的编码器-解码器结构,主干里有卷积、池化、上采样、跳跃连接,还有常见的BatchNorm和ReLU。这类网络结构规整、算子固定,理论上可以走各种部署框架,但在真实工程里选型还是要看你的运行环境和集成方式。

我们当时的约束条件有三个:一是目标机器上没有Python环境,客户不允许为了一个推理模块额外装anaconda或python解释器;二是图像前后处理已经有大量现成的C++代码,最好复用;三是对延迟有一定要求,但还没苛刻到必须上TensorRT那一步。

在这三个约束下,我对比了三种常见路线:

方案集成复杂度运行时体积算子兼容性与训练结果一致性
ONNX Runtime C++中较小一般,部分算子需适配高,但转换可能遇到算子不支持
TensorRT高较小较高,但需显式构建engine高,但转换和调优成本大
LibTorch低较大高,与PyTorch一致最高,几乎零迁移成本

我是这么判断的:TensorRT虽然性能上限最高,但它的engine构建、动态shape配置、插件编写这一套下来,不是一两天能搞定的。ONNX Runtime看起来轻巧,但U-Net里的某些上采样模式、特定的padding方式在导出时偶尔会出兼容性问题。LibTorch虽然打出来的部署包体积会大一些,但它和PyTorch共享同一套算子实现,只要在训练端能跑通,C++端就大概率能跑通,调试成本最低。

还有一个非常实际的理由:LibTorch直接复用PyTorch的权重格式,不需要做格式转换和算子对齐验证。团队里已经有人熟悉PyTorch,遇到模型层面的异常能快速定位。从“先跑起来,再优化”的角度看,LibTorch是风险最可控的一条路。

2. 模型导出:torch.jit.trace的完整操作与常见陷阱

LibTorch不能直接加载普通的.pth权重文件,它要求的是TorchScript格式的模型。TorchScript本质上是PyTorch模型的一种可序列化、可独立执行的中间表示。这一步是整个流程里第一个坑密集区,我至少有两次在这里折腾到怀疑人生。

2.1 为什么U-Net这类模型优先选trace而不是script

PyTorch提供了两种把模型转成TorchScript的方式:torch.jit.trace和torch.jit.script。

trace的思路是给模型一个真实输入,然后追踪一遍前向传播实际走过的算子路径,把这条路径记录成计算图。它不关心代码里有没有if/else、for循环这些控制流,只关心输入真的走过哪些分支。

script则是直接解析Python源码,把模型类完整地编译成TorchScript。它对代码写法有要求:动态控制流要用torch.jit支持的方式写,某些Python特性(比如字典推导、某些lambda)会直接报错。

U-Net只要输入尺寸固定,前向路径通常都是完全确定的线性结构,非常适合trace。而且很多现成的U-Net实现里,forward函数里可能写了assert、print之类的语句,这些不影响trace但会影响script编译。所以我的建议是:U-Net优先用trace,简单、不容易报错,而且生成的图更贴近实际推理路径。

2.2 带BatchNorm的模型必须用eval模式导出的原因

这一点很多人忽略,但它直接影响推理结果。训练模式下BatchNorm层会用当前batch的均值和方差来归一化,而eval模式下用的是训练阶段累计的全局统计量。如果你以model.train()状态做trace,导出的模型里BatchNorm行为是错的,C++端推理出来的分割结果会明显质量下降。

正确的导出姿势是先把模型切到eval模式,再执行trace:

import torch import torchvision model = YourUNet() # 换成你自己的U-Net类 checkpoint = torch.load("unet_best.pth", map_location="cpu") # 如果checkpoint里是完整的model_dict结构,则取state_dict model.load_state_dict(checkpoint["state_dict"] if "state_dict" in checkpoint else checkpoint) model.eval() # 关键:必须先切到eval模式 dummy_input = torch.randn(1, 3, 512, 512) # 必须和训练时的输入shape一致 with torch.no_grad(): traced_model = torch.jit.trace(model, dummy_input) # 校验输出是必要的,保存前先对比一次 torch.jit.save(traced_model, "unet_traced.pt")

这里有个容易被忽略的细节:dummy_input的batch size。如果你用的是torch.randn(1, 3, 512, 512),trace出来的模型第一个维度就被固化成1。后面C++端想一次跑更大的batch,还得分batch依次调用。我不想在代码里额外写batch切分逻辑,所以直接把dummy设成(1, 3, 512, 512),C++端就一次处理一张图,逻辑最简单。

2.3 导出后的一致性校验:做完这步再进C++

我强烈建议导出后先用Python这边加载trace模型,喂一张真实图片,把输出和原模型输出对比一下。数值上应该完全一致(允许浮点级别的极小误差)。这一步能帮你把“导出问题”和“C++加载问题”隔离开来,否则一旦C++端结果不对,你根本不知道该往哪边排查。

我当时对比了三个尺度的输出:U-Net常用的输出是形状为(N, C, H, W)的logits图,我检查了最大值、最小值、均值以及argmax之后各类别像素比例。如果这四项都基本一致,再进C++端,后续出了问题就可以放心地把矛头指向C++代码。

sample_image = load_preprocessed_image("test_slice.png") # 形状 (1,3,512,512) 的Tensor with torch.no_grad(): out_origin = model(sample_image) out_traced = torch.jit.load("unet_traced.pt")(sample_image) print("max diff:", (out_origin - out_traced).abs().max().item()) print("mean diff:", (out_origin - out_traced).abs().mean().item())

通常两个输出的最大绝对差异在1e-6量级,如果差到1e-3以上,说明导出过程有问题,不要急着往下走。

3. C++端环境搭建与加载流程:从下载LibTorch到第一个推理Demo

模型准备好之后,接下来就是C++端的硬功夫了。LibTorch不像OpenCV那样一个包解压就能用,它涉及头文件、动态库、链接选项三个层面的配置。我第一次配CMake时还经历过“编译过了但一运行就崩”的阶段,后来查明白是运行时库路径没配好。

3.1 版本对齐是最容易忽略的致命点

LibTorch的版本必须和训练时用的PyTorch版本保持严格一致。这里说的“一致”不只是大版本,连小版本号都要对上。比如训练端是1.13.1,C++端就尽量去找1.13.1的libtorch包。版本不一致导致的典型问题是:加载模型时提示Unknown custom class、算子找不到、甚至直接std::bad_alloc崩溃。

我个人的习惯是先在训练环境里跑一句python -c "import torch; print(torch.__version__)"确认精确版本,然后去对应渠道下载libtorch。需要注意CPU版和CUDA版也要区分开,如果训练用的模型里有CUDA权重(某些层里可能有),CPU版LibTorch可能加载时就会报错。

下载解压后,目录结构大概长这样:

libtorch/ include/ lib/ share/

我们实际用到的核心头文件在include/torch和include/torch/csrc/api/include,库文件在lib/下面。后续所有链接配置都围绕这两个目录展开。

3.2 CMakeLists.txt的配置细节

一个能跑LibTorch推理的CMake配置,我习惯这么写:

cmake_minimum_required(VERSION 3.18) project(unet_infer) set(CMAKE_CXX_STANDARD 14) set(CMAKE_BUILD_TYPE Release) # 设置LibTorch路径 set(Torch_DIR /path/to/libtorch/share/cmake/Torch) find_package(Torch REQUIRED) add_executable(unet_infer main.cpp) # 把Torch库链接进来 target_link_libraries(unet_infer "${TORCH_LIBRARIES}") # 这两行很关键,少了必崩 target_compile_definitions(unet_infer PRIVATE "_GLIBCXX_USE_CXX11_ABI=0") set_property(TARGET unet_infer PROPERTY CXX_STANDARD 14)

关于_GLIBCXX_USE_CXX11_ABI这里我要单独说。LibTorch官方预编译包默认用的是旧版C++ ABI(等于0),如果你这套代码里还引用了其他用新ABI编译的库(比如某些版本的OpenCV),就得小心处理。最省事的做法是:整个工程统一用-D_GLIBCXX_USE_CXX11_ABI=0编译,如果其他库不兼容,再做隔离。这个问题一旦碰上,症状就是链接报错或运行时崩溃,极其隐蔽。

3.3 加载模型与跑一次空forward

环境配好后,第一版代码越简单越好。我当时先写了一个只加载模型的main,加载成功后再随机生成一个tensor跑一次forward,验证整个链路通不通。

#include <torch/script.h> #include <iostream> int main() { // 加载TorchScript模型 torch::jit::script::Module module; try { module = torch::jit::load("unet_traced.pt"); } catch (const c10::Error& e) { std::cerr << "模型加载失败: " << e.what() << std::endl; return -1; } // 构造一个随机的模拟输入,shape与导出时一致 std::vector<torch::jit::IValue> inputs; inputs.emplace_back(torch::randn({1, 3, 512, 512})); // 前向推理 torch::NoGradGuard no_grad; auto output = module.forward(inputs).toTensor(); std::cout << "输出shape: " << output.sizes() << std::endl; return 0; }

这段代码跑通意味着:模型文件没有损坏、LibTorch版本正确、所有依赖库都已链接到位。我那次在真实环境里跑这块,还遇到过一个特殊情况——目标机器上有多张显卡,但PyTorch自动选了GPU0,推理时显存不足。解决办法是在代码最前面加一句:

torch::set_num_threads(4); // 控制CPU线程数,避免默认占满全部核心

如果希望强制用CPU推理(比如线上服务没有GPU),可以通过设置torch::jit::setTensorExprFuserEnabled和指定设备来约束,不过最简单的方式是干脆把权重文件在导出时就放在CPU上。

3.4 设备与精度:GPU推理时的常见坑

如果你目标环境有GPU,C++端推理时想用CUDA,需要做两步:一是把模型.to(torch::kCUDA),二是把输入tensor也.to(torch::kCUDA)。但这里有个容易忽略的问题:模型导出时如果带的是CPU版权重,首次.to(kCUDA)会有一次权重拷贝,这部分时间不算长,但如果你在推理循环里每次都调用module.to(kCUDA),性能会严重下降。正确的做法是启动时只做一次迁移。

另外,半精度推理确实能带来不小的加速。U-Net这类分割模型对精度波动并不算太敏感,用torch::kHalf跑推理,一般情况下分割结果的IoU变化很小。但要注意,半精度并不总是安全的,BatchNorm在半精度下偶尔会出现一些奇奇怪怪的数值问题。我的做法是:先在Python端用model.half()跑一遍测试集,确认精度符合预期再在C++端启半精度。

4. 预处理与数据布局:预测结果不对的头号原因

导航到这一步时,模型已经能加载、能跑了,但真正喂真实图像时,意外画面才刚开始。我在C++端得到的第一张分割图是全黑的,后来又出现过条纹状噪声,最后才定位到是预处理环节和训练时的预处理不一致。这部分内容我建议所有做部署的人认真看。

4.1 从OpenCV的Mat到Tensor:通道顺序、归一化、布局全要对

医学影像任务里,输入通常不是普通的三通道RGB自然图,我这次的项目是把灰度切片复制成三通道,再做了z-score归一化。训练时的预处理逻辑是这样的:

  1. 读取灰度图,形状(H, W);
  2. cv::cvtColor转成三通道,得到(H, W, 3);
  3. 每个通道整体减去均值mean,除以标准差std;
  4. 转成(C, H, W)的张量并加batch维度。

在Python里,第4步是np.transpose(img, (2, 0, 1))再torch.from_numpy。C++端要完全复刻这条链路,关键是最终张量的内存排列必须是NCHW。

// cv::Mat mat: 形状为 (H, W, 3) 的RGB图像 cv::Mat rgb; cv::cvtColor(mat_bgr, rgb, cv::COLOR_BGR2RGB); // 确保通道顺序 double mean = 0.485, std = 0.229; // 与训练时一致 rgb.convertTo(rgb, CV_32FC3, 1.0 / 255.0); // 先归一化到0-1区间 // 按通道做标准化 for (int c = 0; c < 3; ++c) { cv::Mat channel(cv::Size(rgb.cols, rgb.rows), CV_32FC1); cv::extractChannel(rgb, channel, c); channel = (channel - mean) / std; // 放回通道? 简化起见直接构建tensor }

更简洁的做法是直接构造一个torch::Tensor,然后把像素数据按NCHW顺序填入内存:

torch::Tensor input_tensor = torch::from_blob(rgb.data, {1, 3, H, W}, torch::kFloat32).clone();

这里有个非常重要的细节:torch::from_blob返回的张量是共享底层内存的,它不会复制数据。如果你在函数里创建了这个tensor,并且函数结束后cv::Mat的内存被释放,那么这个tensor就变成了野指针。所以务必加一个.clone(),把数据真正复制到torch管理的内存里。

4.2 归一化不一致会带来什么后果

训练的时候用的可能是0-1归一化后做z-score,也可能是直接对原始0-255像素做z-score。这两个方式在C++端如果搞混,模型依然能推理,但输出的概率图会非常离谱——“看似有结果,实则全错”,这是最危险的情况,因为没有报错可以看。

我对比过:把归一化方式做错时,U-Net输出的logits均值会很差,经过argmax之后分割区域面积明显偏大或偏小。最容易被忽视的是“像素值范围到底是不是0-1”。

我的建议是:在Python导出阶段,把一张真实预处理过的输入tensor也一并导出(.pt文件直接保存tensor),然后在C++端直接对比预处理结果。如果两条链路的tensor最大绝对误差小于1e-3,说明预处理对齐了,再跑后续推理才有意义。

4.3 输出张量的解析:从logits到分割图

U-Net输出通常是(1, num_classes, H, W)的logits多通道图。在Python里要做一轮softmax再argmax,或者在遍历像素时直接对每个位置做最大值索引。C++端的处理方式类似,但要特别注意argmax到底在哪一个维度上执行。

// output的形状是 (1, C, H, W) auto output = module.forward(inputs).toTensor().squeeze(0); // 变成 (C, H, W) // 方法1:用torch自带的argmax auto pred = torch::argmax(output, /*dim=*/0); // 得到 (H, W) 的 int64 tensor pred = pred.to(torch::kU8); // 转成uint8方便转成cv::Mat cv::Mat seg_mat(H, W, CV_8UC1, pred.data_ptr<uint8_t>());

如果你要的是有颜色的分割可视化图,需要根据pred的类别索引映射到调色板。映射方式就是传统的查表,遍历像素即可。这部分没有隐含的坑,唯一要注意的是输出的tensor必须在CPU上,如果推理在GPU上进行,用.to(torch::kCPU)拷回来再取data_ptr。

5. 推理效率优化:从“能跑”到“跑得快”

链路跑通之后,下一步就是性能调优。我这里说的调优不是夸夸其谈的“性能优化”,而是实实在在能落在代码里的几个操作。U-Net本身是个计算密集模型,512×512输入的推理在CPU上大约耗时1-3秒,在GPU上可以压到几十毫秒。如果你只能上CPU,下面这几个手段需要认真考虑。

5.1 半精度推理与融合选项

LibTorch在CPU上可以通过torch::jit::enableOptimizer、torch::jit::setTensorExprFuserEnabled做算子融合。实测下来,TensorExpr fuser对U-Net这类模型效果还算明显,能把某些结构简单的卷积+ReLU融合掉,减少中间张量的读写开销。

torch::jit::setTensorExprFuserEnabled(true);

在GPU上,启用半精度torch::kHalf是收益最明显的操作。我把40张512×512的测试图分别跑了float32和float16两轮,结果是速度提升接近1.8倍,某些算子的显存占用明显下降。要注意的是,半精度推理前必须确认模型所有层都完成了to(kHalf),尤其是BatchNorm层,sync_batch_norm和普通BatchNorm在半精度下的表现略有不同。

5.2 线程数与at::set_num_threads的默契

LibTorch底层使用at::parallel_for做算子内部的并行,这个线程数默认可能会吃掉你机器上所有核心。我在一台16核的机器上如果没有设置,U-Net推理时经常会干扰到同机的其他服务进程。合理的做法是:

at::set_num_threads(4);

如果只跑单张图推理,线程数设成物理核心数的一半往往是性价比最高的。线程数继续增加,推理延迟不再线性下降,反而可能因为线程调度开销变得不稳定。

5.3 批量推理和流式调用的取舍

线上服务通常是单张图请求,不适合把batch堆大。但如果你的场景是一次性处理一个切片序列,比如一整个三维体数据,那么构造一个(B, 3, H, W)的batch整体推理,相比逐张调入模型,会有额外的吞吐提升。LibTorch对batch维度的推理天然支持,只要模型导出时第一个维度不是固定1就行。这一点在一开始提到过,如果你已经用(1, 3, 512, 512)导出了模型,现在想改batch就需要重新导出。

我给朋友项目的建议是:如果单次推理延迟已经能满足业务指标,就保持batch=1,逻辑最简单;如果吞吐是瓶颈,再重新把模型导出成batch可变的格式。

6. 踩坑实录:一整套排查链路分享

最后这部分我挑三个真实的、花了我最多时间的问题,把完整的定位过程写出来。如果你后续遇到类似症状,可以直接顺着这条线查。

6.1 症状一:模型加载成功但输出全是同一个类别

这个问题的典型表现是:分割图变成一个纯色块,所有像素都落在同一个类别上。我当时第一反应是后处理写错了,反复查argmax逻辑,无果。后来把输出tensor直接存成二进制文件,和Python端的输出一对比,发现C++端整个输出张量数值都偏小很多,softmax之后几乎全变成了独热分布中的同一个索引。

最终定位到是输入张量的数值范围出了问题。训练时预处理是0-1归一化再做z-score,而我在C++端忘了convertTo那一步的1.0/255.0,导致喂进网络的是0-255区间的数值。模型在线性层和归一化层的共同作用下,输出logits整体偏移,softmax后少数类别全被压没了。

排查方法很简单:把你的预处理后tensor打印出前几十个浮点数,和Python端导出前对比,范围一致再继续。

表格整理一下不同错误模式的快速判断:

症状常见原因排查优先级
输出全黑/全背景输入数值范围错误、通道顺序颠倒1
输出有条纹或棋盘格内存布局错位、from_blob没做clone2
输出轮廓在但类别错乱调色板映射错误、类别索引不一致3
偶发崩溃ABI不匹配、tensor生命周期问题4

6.2 症状二:推理结果和Python端有细微差别

如果你发现C++端输出的概率值和Python端有1e-2量级的差异,而不是1e-6,有一个原因很容易被忽略:OpenCV的resize插值方式和PyTorch/Torchvision里的resize方式不同。U-Net里面通常没有resize层,但如果你预处理里做了尺寸缩放,两边用不同的插值算法,结果就是输入图本身就不一致,模型输出自然不可能吻合。

解决办法不是微调C++代码,而是让预处理链路完全对齐:要么在C++端也用双线性插值配合和PyTorch一致的坐标对齐方式,要么把预处理尽量简化,比如固定尺寸输入时直接裁剪,不做resize。我最后就是改成固定512×512输入,避免resize,从根上消灭了这类差异。

6.3 症状三:显存持续上涨

如果你的服务是长驻进程,每次推理后显存都比上次多一点点,跑几百张图之后直接OOM,那大概率是tensor没有释放。最常见是torch::from_blob创建的tensor一直引用着外部内存,或者你在循环里创建了某个无需保存的中间结果但没有重新赋值。

Trace模型如果内部有动态分支,每次forward会构建新的计算图,也容易造成显存堆积。我的做法是在循环推理的代码里显式添加:

torch::NoGradGuard no_grad; module.forward(input);

并且保证输入tensor通过.clone()持有自己独立的内存,不让外部cv::Mat在每次迭代中不断分配、又没有被回收。彻底排查可以用nvidia-smi观察显存峰值曲线,如果一直单调上升而不是平稳波动,就把循环里的tensor逐个注释掉做二分定位。

7. 我的最终做法与一点个人体会

经过上面几轮调试,最终版推理模块的结构大概是这样的:启动时加载一次TorchScript模型,每次推理执行“读图 -> 预处理 -> forward -> argmax -> 转cv::Mat”这一条链路。实测下来,512×512输入在Xeon级别CPU上单次推理稳定在1.5秒左右,GPU上可以压到40毫秒以内,和Python端的推理结果在IoU指标上几乎一致。

如果说有什么体会最想分享,那就是部署工作“前重后轻”——训练端做的准备越充分,C++端越少踩坑。尤其是模型导出时的eval模式、dummy input尺寸选择、以及预处理链路的一致性,这三件事值得在动手写C++代码前花半小时验证清楚。我这次因为导出时忘了eval模式,折腾了整整一个晚上,后来重新导出一次,问题直接消失。

另外一个小技巧是,在C++工程里写一个“自检模式”:启动时用内置的一张小图和已知的期望输出做一次对比,如果误差在阈值内再进入正式服务。这个自检模块初期帮我挡掉了不少版本升级导致的回归问题,后来也保留在了生产代码里。如果你也在做类似的LibTorch部署,建议把这个习惯带上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进