资讯动态

C#上位机通过C++ DLL离线调用PaddleX模型:三类任务统一接口实践

发布时间:2026/10/17 3:09:56 来源:尧图企业网站定制
1. 这个方案要解决什么问题C#上位机离线调用PaddleX模型的三条路接到一个设备视觉质检项目时我一开始是有点慌的。模型侧已经定好了用PaddleX生态里训练和导出的模型包含图像分类、目标检测、图像分割三类任务精度和速度都验证过。但上位机软件是C#写的WinForms现场客户明确提出部署机器不能装Python环境不能起额外的推理服务进程所有推理必须在一个DLL里搞定方便主程序加载和升级。这就意味着我必须在C侧把PaddleX的推理能力封装成一个标准DLL然后让C#用P/Invoke调用。这个需求乍看不高实际动手才发现中间全是细节。模型文件好说PaddleX导出的产物本来就是标准推理格式难的是跨语言层的接口设计、图像内存搬运、三类任务后处理差异以及C和C#两侧结构体定义必须完全一致。这些坑我一个个趟过来之后觉得有必要把完整方案写下来给同样在做C#桌面端集成深度学习推理的人一个可复现的参考。先说结论我最终采用的路径是C# - C DLL - paddle_inference预测库 - PaddleX导出模型DLL内部按分类、检测、分割三个任务类型分别做预处理和后处理对外只暴露三个函数创建引擎、执行推理、销毁引擎。这个方案跑通之后C#侧代码非常干净业务逻辑里只需要处理标准的返回结构体不需要关心任何预测库细节。1.1 三个场景逼我放弃Python推理服务最早我考虑过最简单的方法C#通过HTTP调用本机Python FastAPI服务Python端用PaddleX加载模型推理返回JSON结果。这个方法开发速度快PaddleX的Python接口非常成熟很多算法工程师都是这么联调的。但它有几个硬伤在工业落地场景里几乎是致命的。第一现场机器要装Python、装PaddleX、装一堆依赖库版本稍有冲突整个环境就废了。客户现场没人愿意维护这东西。第二HTTP服务要配置开机自启、异常重启、端口占用检测这些运维工作量全部落在交付团队头上。第三图像走HTTP传输有序列化和网络开销工业相机采图频率高的场景单张图像来回拷贝两次延迟明显增加。第四有些客户的安全策略不允许开放本地端口。这四条理由叠加起来Python推理服务方案基本被否掉了。另一种思路是把模型转成ONNX再用ONNX Runtime的C#绑定直接调。这条路对单个模型的桌面端集成确实可行C#项目里加一个NuGet包就行了不需要自己写DLL听起来也很诱人。但问题在于PaddleX生态里不少模型转ONNX后会遇到算子兼容问题尤其是一些自定义预处理和后处理算子、动态shape相关的导出折腾起来非常痛苦。我试过几个检测模型导出后精度和原始Paddle推理有细小偏差排查起来很费劲。如果模型可以顺利转这个方案性价比很高一旦碰到转不动的算子就是个无底洞。所以最后落到C DLL封装paddle_inference预测库。这条路前期工作量最大但一旦封装好之后换模型、加新任务类型都只是在DLL内部增加配置C#侧几乎不用动。它没有运行时依赖不需要Python环境不占用额外端口性能也最好。对要做成产品的桌面端来说这是最稳妥的兜底方案。1.2 方案对比子进程、ONNX Runtime、C DLL我把当时评估的三个方案整理成了一张对比表方便后面项目直接抄作业。对比维度Python子进程HTTPONNX Runtime C#绑定C DLL paddle_inference开发速度最快快较慢需要C基础运行环境依赖Python完整环境NuGet包即用需要VC运行库/GPU运行库模型兼容性PaddleX全兼容看算子转换情况PaddleX全兼容跨进程开销高无无线程并发控制服务端控制进程内控制句柄级控制运维成本高低低适合场景快速验证/原型纯托管项目产品化桌面端1.3 最终的选型边界这个方案适合什么样的项目我总结一下边界条件。如果你的C#程序是纯工具类小软件模型也简单用ONNX Runtime就好没必要上C DLL如果你在现场有专门的算法服务器Python服务跑在独立机器上那也犯不着封装DLL。但当你的程序是交付给客户、运行在无法预知环境的Windows机器上、又要保证性能和稳定性时C DLL封装就是最值得投入的路线。我这次确定方案时给自己定了几条硬性约束DLL内部不依赖PaddleX的完整Python运行时只用paddle_inference预测库DLL对外接口只暴露纯C风格函数和普通结构体不做C类导出所有内存分配和释放都在同一侧完成避免跨语言内存所有权混乱。这几条在后面实现时帮我避开了大量互操作问题。2. 动手前的版本匹配与模型格式确认这一步错全盘崩很多人一上来就写代码结果编译通过、一加载模型就崩最后发现是预测库版本和模型版本不匹配。Paddle Inference对编译器和运行库有严格对应关系这部分必须先敲定。2.1 paddle_inference预测库怎么选Paddle Inference的Windows预测库在官方下载页面可以找到分为CPU版和GPU版每个版本都会标注对应的CUDA、CUDNN、编译器等信息。选库时我建议按这个顺序确认先确认目标机器有没有NVIDIA显卡再确认CUDA版本最后考虑开发机的Visual Studio版本。这里有个容易忽视的点预测库本身是用某个VS版本编译的你在自己项目里链接它不一定要求VS版本完全一致但运行库要兼容。官方说明里一般会写推荐VS2015/2017/2019我实际测试时用VS2019链接官方发布的VS2017版本库也能正常工作前提是平台工具集和运行库设置匹配。比较稳妥的做法是直接用官方推荐的VS版本别在这个位置省时间。CPU版和GPU版的选择还影响后续DLL的发布体积。CPU版依赖的第三方DLL大概几十兆GPU版要额外带CUDA、CUDNN的运行DLL加起来体积会上百兆而且必须保证目标机器上的显卡驱动版本满足要求。如果客户现场有显卡但驱动很老GPU版反而跑不起来我最后在部分机器上改用CPU多线程推理速度和GPU差不少但稳定。选型之后还要确认一个关键参数推理库采用静态链接还是动态链接。官方下载的预测库包里同时提供了paddle_inference.lib和paddle_inference.dll链接lib相当于把导入库绑到程序里真正运行时还是要找DLL。我建议程序通过隐式链接方式依赖paddle_inference.dll这样DLL升级时不用重新编译C层。2.2 PaddleX导出的标准推理模型长什么样PaddleX训练完模型后导出推理模型一般会得到一个目录里面包含model.pdmodel、model.pdiparams、model.yml不同套件可能有细微差别。这套文件就是标准的Paddle Inference推理格式理论上所有Paddle套件导出的模型都能被同一个预测库加载差别只在输入输出张量的形状和预处理参数上。这里要特别提醒目录里的YAML文件非常关键它记录了模型的输入尺寸、均值方差、归一化方式、是否BGR转RGB等信息。分类、检测、分割三类模型的YAML内容差异很大C层初始化时要把这些参数一并读取出来。我最初偷懒把预处理参数硬编码在代码里后来换了一个模型就出错了改成从YAML读取后才一劳永逸。对于检测模型还要确认导出时是否是端到端输出。PaddleDetection系列模型有些导出后会包含NMS后处理节点输出直接是解码好的框有些则输出原始预测结果需要在C侧手动解码和NMS。我在做第一个检测模型时没注意这个结果C侧拿着原始输出硬套NMS绕了一大圈。建议在Python端先用PaddleInference工具或者直接netron查看输出节点的维度确认清楚再写C代码。2.3 让模型先在自己的C程序里跑通不要直接跳到DLL封装第一次接触Paddle Inference时先写一个最简C控制台程序只做一件事加载模型读入一张本地图片完成一次推理打印输出张量形状。这一步跑通的意义非常大它能验证预测库和模型的搭配、验证预处理后处理逻辑、暴露路径和依赖DLL的问题。我当时的做法是先写一个不需要OpenCV的纯数组版本手动填一个固定尺寸的输入张量看推理输出是否正常。确认张量流动没问题之后再引入OpenCV做图像预处理。这样排查问题时可以把图像编码环节和模型推理环节分开定位。最简单的CMakeLists大概是这样cmake_minimum_required(VERSION 3.15) project(PPTest) set(CMAKE_BUILD_TYPE Release) set(PADDLE_DIR D:/libs/paddle_inference_install_dir) set(OPENCV_DIR D:/libs/opencv) include_directories(${PADDLE_DIR}/paddle/include) link_directories(${PADDLE_DIR}/paddle/lib) include_directories(${OPENCV_DIR}/include) add_executable(pp_test main.cpp) target_link_libraries(pp_test paddle_inference opencv_world )链接时我建议将paddle_inference.lib和OpenCV的lib都从属性面板里手动添加避免用自动链接宏导致版本错乱。第一次编译通过但不代表能运行运行时如果提示找不到paddle_inference.dll就把预测库包里的DLL目录加到系统PATH或者全部复制到exe同级目录下。这一步之后才进入DLL封装阶段。3. DLL接口设计一个句柄三组API分类检测分割统一入口DLL内部的模型加载、推理、后处理逻辑再复杂对外暴露的面必须足够简单。我最终设计的是一个句柄加三个函数PaddleX_Create负责创建引擎PaddleX_Process负责执行推理PaddleX_Destroy负责销毁引擎。C#侧只需要维护一个IntPtr句柄所有复杂状态都在C侧内部保存。3.1 为什么接口层必须用C风格而不是C类C#调用C DLL有两种常见路线一种是直接导出C类利用Visual Studio的/CLR或者C/CLI做托管包装另一种是导出普通C语言函数用extern C去掉名字修饰再用C#的DllImport加载。我选后者原因很实际。C类导出后类名和函数名经过名字修饰在C#里声明的时候要写修饰后的符号名非常丑且脆弱。更麻烦的是跨DLL边界传递C对象如果两侧编译器版本和标准库实现不一致对象布局可能不同轻则数据错乱重则直接崩溃。C风格函数配合不透明指针void*句柄可以完全规避这类问题C#拿到的永远只是一个IntPtr怎么操作全由C内部决定。另一个原因是DLL的升级兼容性。用C风格接口只要函数签名不变DLL内部随便重构C#侧一行代码都不用改。用C类导出一旦类增加成员变量接口就可能有兼容性风险。做桌面端产品时这是很重要的维护优势。3.2 统一结果结构体如何容纳三类任务分类、检测、分割的推理结果差异很大分类是一组置信度分数和类别ID检测是多组目标框坐标加上对应类别和置信度分割是逐像素的类别ID图。要设计一个统一的返回结构体又不能让C#侧解析太复杂我定义成这样#pragma pack(push, 1) typedef struct { int task_type; // 0-分类 1-检测 2-分割 int count; // 分类的TopK个数或检测框数量分割时固定为1 int img_w; // 分割结果的宽度 int img_h; // 分割结果的高度 float* scores; // 置信度数组长度count int* ids; // 类别ID数组长度count float* boxes; // 检测框数组长度count*4顺序xmin,ymin,xmax,ymax int* mask; // 分割结果长度img_w*img_h值为像素类别ID } PaddleXResult; #pragma pack(pop)为什么用分开的指针而不是一个二维数组因为C#侧Marshal只能按线性内存读取分开之后分类模型只读scores和ids检测模型读boxes分割模型读mask各自逻辑互不干扰。我特意加了task_type字段C#侧可以根据这个字段决定解析分支三段代码完全解耦。这里还要注意对齐问题。C的结构体默认有对齐规则如果C#侧按Sequential自然对齐来声明很容易在某些字段之间插入填充字节导致解析错位。统一加#pragma pack(push, 1)是最省心的做法代价是内存访问稍微慢一点点但对这种低频跨语言调用来说完全可以忽略。3.3 Create / Process / Destroy 三阶段实现头文件里的接口声明如下#ifdef __cplusplus extern C { #endif #ifdef DLL_EXPORT #define API __declspec(dllexport) #else #define API __declspec(dllimport) #endif // 创建推理引擎 API void* PaddleX_Create( const char* model_dir, const char* task_type, // cls / det / seg int device_id, // CPU模式传-1GPU模式传0或指定卡号 int cpu_threads, char* err_buf, int err_buf_len); // 执行推理输入BGR三通道连续图像数据 API int PaddleX_Process( void* engine, const unsigned char* image_data, int width, int height, PaddleXResult* result, char* err_buf, int err_buf_len); // 销result中所有动态数组 API void PaddleX_ReleaseResult(PaddleXResult* result); // 销毁引擎 API void PaddleX_Destroy(void* engine); #ifdef __cplusplus } #endif这里有一个设计细节PaddleX_Process内部会分配scores、ids、boxes、mask这些数组所以额外提供了一个PaddleX_ReleaseResult来统一释放。C#侧必须严格按顺序调用先Marshal.Copy读出数据再调用ReleaseResult释放。内存所有权完全由C边管理C#只负责在读取完成后通知C释放。Create内部的实现我用了策略模式根据task_type创建不同的引擎子类。预测器、预处理参数、后处理逻辑都封装在各引擎类里最外层只做异常捕获和错误信息格式化void* PaddleX_Create(const char* model_dir, const char* task_type, int device_id, int cpu_threads, char* err_buf, int err_buf_len) { try { if (std::string(task_type) cls) { auto cfg CreateClsConfig(model_dir, device_id, cpu_threads); auto* engine new ClsEngine(cfg); engine-Init(); return engine; } else if (std::string(task_type) det) { auto* engine new DetEngine(model_dir, device_id, cpu_threads); engine-Init(); return engine; } else if (std::string(task_type) seg) { auto* engine new SegEngine(model_dir, device_id, cpu_threads); engine-Init(); return engine; } SetError(err_buf, err_buf_len, unknown task type); } catch (std::exception e) { SetError(err_buf, err_buf_len, e.what()); } return nullptr; }所有公开函数都要用try/catch包住防止C异常跨DLL边界传到C#。C#那边如果收到一个野异常程序只能在事件日志里留下一条笼统的记录排错非常痛苦。我规定所有API在异常时将错误信息写入err_buf并返回错误码C#侧通过返回值判断成功失败再读取err_buf拿详细信息。Process函数内部的图片数据接收有个约定要写清楚我要求C#侧传入已经解码成BGR格式的三通道连续内存不传JPEG/PNG字节流。这样C端不需要引入图像解码逻辑直接把指针包成cv::Matint PaddleX_Process(void* engine, const unsigned char* image_data, int width, int height, PaddleXResult* result, char* err_buf, int err_buf_len) { try { cv::Mat img(height, width, CV_8UC3, (void*)image_data); EngineBase* e static_castEngineBase*(engine); e-Predict(img, *result); return 0; } catch (std::exception ex) { SetError(err_buf, err_buf_len, ex.what()); return -1; } }这里有个非常容易踩的坑cv::Mat构造时只包了外部数据指针不复制像素。如果Predict内部把img传给异步线程或者缓存了cv::MatC#侧一旦释放bufferC这边就是悬垂指针。我要求Predict内部所有操作都同步完成并且绝不保存cv::Mat引用只用它的数据做一次拷贝或者原地预处理。4. 核心后处理代码softmax、NMS、argmax在不同模型里的处理差异三类模型的预处理部分大同小异都是resize、归一化、转CHW真正体现差异的是后处理。模型输出的原始张量不是直接能用的结果必须根据任务类型做不同的解码这一步最容易出错。4.1 分类模型TopK排序要跟训练时的数据格式对齐分类模型输出是一个概率分布向量形状一般是[1, num_classes]。C侧要做的只是softmax之后取概率最高的K个类别。我写分类后处理时踩过一个细节有些模型输出层已经带softmax有些输出的是logits需要自己再做一次softmax。判断方法很简单拉出输出张量的数值范围如果是0到1之间且加起来接近1说明已经softmax过如果有正有负、绝对值可能很大就要手动softmax。void ClsEngine::Postprocess(const float* output_data, int num_classes, PaddleXResult result) { std::vectorstd::pairfloat, int scores; scores.reserve(num_classes); for (int i 0; i num_classes; i) { scores.emplace_back(output_data[i], i); } std::partial_sort(scores.begin(), scores.begin() topk_, scores.end(), [](const auto a, const auto b) { return a.first b.first; }); result.count topk_; result.scores new float[topk_]; result.ids new int[topk_]; result.task_type 0; for (int i 0; i topk_; i) { result.scores[i] scores[i].first; result.ids[i] scores[i].second; } result.boxes nullptr; result.mask nullptr; }TopK默认取5就够用如果业务需要更多可以在Create参数里带上配置。分类模型还有一个容易忽略的点模型的类别文件需要在C#侧另配一份比如0对应缺陷A、1对应缺陷BC侧不要管这些业务映射只返回ID让上层去查表。这样DLL就保持纯净换业务时不用重新编译。4.2 检测模型解码、阈值过滤与NMS的实现细节检测模型的后处理最复杂因为它经历了“解码 - 过滤 - NMS”三个阶段。先说解码。PaddleDetection系列导出后的输出格式常见有两种一种是Nx6的矩阵每行是[class_id, score, xmin, ymin, xmax, ymax]另一种是多个输出张量需要根据anchor或者grid信息自己解码。如果模型导出时带了NMS节点那就只有第一种格式我推荐优先导出这种端到端的模型C侧少写一堆解码代码。拿到[N, 6]的输出后先按置信度阈值过滤比如默认0.5。这一步很粗暴直接把低于阈值的行丢掉。然后是NMS处理同一个目标被多个框重复框住的问题。NMS算法的标准流程是反复选当前最高置信度的框删除与它IoU大于阈值的其他框直到列表为空。我用的实现如下std::vectorDetBox NMS(const std::vectorDetBox boxes, float iou_thresh) { std::vectorDetBox sorted boxes; std::sort(sorted.begin(), sorted.end(), [](const DetBox a, const DetBox b) { return a.score b.score; }); std::vectorDetBox result; std::vectorbool removed(sorted.size(), false); for (size_t i 0; i sorted.size(); i) { if (removed[i]) continue; result.push_back(sorted[i]); for (size_t j i 1; j sorted.size(); j) { if (removed[j]) continue; float iou CalcIoU(sorted[i], sorted[j]); if (iou iou_thresh) removed[j] true; } } return result; }IoU阈值一般取0.5如果目标密集重叠多可以调到0.45如果同类别之间限制严格可以分类别做NMS。我产品里的检测模型是每个类别分别跑NMS因为不同类别重叠时不应该互相抑制。这个差异在C里很简单按class_id分组再调用NMS即可。检测结果写入PaddleXResult时boxes数组是扁平化的第i个目标的坐标在boxes[i40]到boxes[i43]。C#侧解析时对应读取就行。4.3 分割模型argmax的结果为什么要缩小数据搬运量分割模型的原始输出形状一般是[1, num_classes, H, W]每个像素在所有类别上有一个分数。要把这个张量变成每个像素一个类别ID只需要在通道维度做argmaxvoid SegEngine::Postprocess(const float* output_data, int classes, int out_h, int out_w, PaddleXResult result) { int* mask new int[out_h * out_w]; for (int p 0; p out_h * out_w; p) { float max_val output_data[p]; int max_cls 0; for (int c 1; c classes; c) { float val output_data[c * out_h * out_w p]; if (val max_val) { max_val val; max_cls c; } } mask[p] max_cls; } result.mask mask; result.img_w out_w; result.img_h out_h; }注意这里默认模型输出的HxW和输入图像一致不需要回缩。如果模型内部做了多次下采样输出分辨率小于原图就需要用OpenCV把mask resize回原尺寸。我的做法是先产生原始分辨率mask再做一次最近邻插值放大到输入尺寸。最近邻插值能保持类别ID不产生新值双线性插值会插出来非整数类别绝对不能用。分割结果的传输量是所有任务里最大的一张512x512的mask用int数组表示就是1MB。如果单张图片还好高频连续推理时每次都Marshal.Copy 1MB累积开销不小。我在接口里保留int*形式但额外加了一个接口约定C#侧解析完mask后要立刻ReleaseResult避免内存堆积。对于超大图后续可以考虑改成PNG编码或者RLE压缩但目前的优化点不在这里先保证能稳定跑起来更重要。5. C#调用层结构体布局、图像搬运、内存释放一整套写法C侧准备完了C#侧反而是最容易写错的部分。结构体布局不一致、字符串编码错误、内存固定方式不对都会导致看似正常的调用突然崩溃。我把C#侧的完整写法整理如下。5.1 DllImport声明里的三个隐藏陷阱第一个陷阱是调用约定。我C侧统一用__cdeclC#侧必须显式声明CallingConvention.Cdecl不能依赖默认值。如果不匹配在某些平台上参数解析会错乱现场表现为函数返回值不对或者程序直接崩溃。第二个陷阱是字符串封送。Create接口的model_dir和task_type是const char*C#侧string默认会按Ansi封送为char*但项目里如果设置了Unicode编码就必须在DllImport上显式写CharSet CharSet.Ansi。我建议所有涉及字符串的API都写清楚CharSet不要靠运气。第三个陷阱是错误缓冲区的初始化。C#侧要预分配一个足够大的StringBuilder并且提前设置Capacity否则调用方传入的缓冲区长度是0C写入时会直接越界。[DllImport(PaddleXInfer.dll, CallingConvention CallingConvention.Cdecl, CharSet CharSet.Ansi)] internal static extern IntPtr PaddleX_Create( string modelDir, string taskType, int deviceId, int cpuThreads, StringBuilder errBuf, int errBufLen); [DllImport(PaddleXInfer.dll, CallingConvention CallingConvention.Cdecl)] internal static extern int PaddleX_Process( IntPtr engine, IntPtr imageData, int width, int height, ref PaddleXResult result, StringBuilder errBuf, int errBufLen); [DllImport(PaddleXInfer.dll, CallingConvention CallingConvention.Cdecl)] internal static extern void PaddleX_ReleaseResult(ref PaddleXResult result); [DllImport(PaddleXInfer.dll, CallingConvention CallingConvention.Cdecl)] internal static extern void PaddleX_Destroy(IntPtr engine);结构体定义要和C侧完全对应[StructLayout(LayoutKind.Sequential, Pack 1)] internal struct PaddleXResult { public int TaskType; public int Count; public int ImgW; public int ImgH; public IntPtr Scores; public IntPtr Ids; public IntPtr Boxes; public IntPtr Mask; }我在C侧结构体里没有放bool字段就是为了避免跨语言bool大小不一致的问题。C的bool是1字节C#的bool默认封送成4字节如果必须用boolC#侧要加[MarshalAs(UnmanagedType.I1)]。但最稳妥的做法是接口设计时就避开bool用int代替。5.2 图像从Bitmap到C内存的搬运方式C#侧从工业相机拿到的图像通常是Bitmap对象要转换成BGR三通道连续内存传给C。最直接的方式是LockBits然后把PixelFormat转成Format24bppRgb再复制到byte[]数组。拿到byte[]数组之后有两种传给C的姿势。第一种是直接把byte[]作为参数传给DllImportCLR会做一次封送自动固定内存并传入首地址。这个写法简单但因为封送器会创建副本对于大图多了一笔开销。第二种是手动固定内存用GCHandle把byte[]钉住然后取AddrOfPinnedObject。我推荐第二种因为工业相机图像往往在10MB以上能省一次拷贝就省一次。byte[] bgrData BitmapToBgrBytes(bitmap); GCHandle gch GCHandle.Alloc(bgrData, GCHandleType.Pinned); try { IntPtr imgPtr gch.AddrOfPinnedObject(); StringBuilder errBuf new StringBuilder(1024); PaddleXResult result new PaddleXResult(); int ret PaddleX_Process(engine, imgPtr, bitmap.Width, bitmap.Height, ref result, errBuf, errBuf.Length); if (ret ! 0) { throw new Exception(errBuf.ToString()); } ParseResult(result); } finally { gch.Free(); }有一个很隐蔽的问题GCHandle只能钉住托管数组不能钉住Bitmap内部的原生内存。如果直接用Bitmap.Scan0传给CBitMap可能被GC移动必须LockBits先固定。我封装了一个BitmapToBgrBytes函数内部强制做一次LockBits把数据复制到byte[]再对byte[]做GCHandle。这样虽然多了一次Bitmap到数组的拷贝但比类对象直接传指针安全得多。5.3 结果解包与释放顺序先子指针后结构体结果解析是C#侧最容易泄漏内存的地方。PaddleXResult里的指针指向C堆内存C#并不会自动释放必须在读取完之后显式调用PaddleX_ReleaseResult。读取的顺序和释放的顺序有讲究一定要先把需要的数据全部拷贝到托管侧再释放千万别先释放再读。private static void ParseResult(ref PaddleXResult result) { int taskType result.TaskType; int count result.Count; if (taskType 0) { float[] scores new float[count]; int[] ids new int[count]; Marshal.Copy(result.Scores, scores, 0, count); Marshal.Copy(result.Ids, ids, 0, count); // 业务处理 } else if (taskType 1) { int boxCount result.Count; float[] boxes new float[boxCount * 4]; Marshal.Copy(result.Boxes, boxes, 0, boxCount * 4); float[] scores new float[boxCount]; int[] ids new int[boxCount]; Marshal.Copy(result.Scores, scores, 0, boxCount); Marshal.Copy(result.Ids, ids, 0, boxCount); // 业务处理 } else if (taskType 2) { int len result.ImgW * result.ImgH; int[] mask new int[len]; Marshal.Copy(result.Mask, mask, 0, len); // 业务处理 } }释放时用ref关键字传递结构体让DLL侧能拿到的地址然后内部delete所有非空指针。finally { PaddleX_ReleaseResult(ref result); }这里有一个常见的误区PaddleX_ReleaseResult只是释放内部指针指向的堆内存不释放结构体本身因为结构体是C#侧在栈上定义的。C侧收到ref传进来的结构体地址后要先判断每个指针是否为空逐个delete再把所有指针置空。这样可以保证同一个结构体即使被误释放两次第二次也是安全的空指针delete。6. 实测数据与踩坑记录性能和稳定性比想象中更需要打磨DLL跑通之后并不意味着结束性能调优和跨语言稳定性才是真正让人头疼的部分。我拿三个模型分别做了CPU和GPU两套环境测试记录下实测耗时分布和遇到的坑。6.1 本机实测耗时CPU多线程/GPU两套环境测试环境是测试机一台普通i7工控机、一块中端显卡。CPU推理统一设4线程GPU推理统一设单卡。模型分别是分类用轻量级MobileNetV3检测用PP-YOLOE系列分割用PP-LiteSeg系列。任务类型CPU单张耗时GPU单张耗时备注分类 224x224约12ms约3ms主要耗时在预处理resize检测 640x640约85ms约18msNMS在CPU上占了一部分时间分割 512x512约70ms约15msmask传输到C#也会占2-3ms分割 1024x1024约240ms约40ms分辨率翻倍耗时接近四倍CPU上比较明显的瓶颈是预处理阶段OpenCV的resize和通道转换在单线程下对640x640图要占15-20ms。要优化的话可以改用IPP或者多线程并行预处理但大部分桌面端场景单帧80ms也能接受这个优化优先级不高。GPU推理时有一个细节如果每帧都重新创建Tensor或频繁CudaMemcpy性能会比预期差很多。我后来发现paddle Inference内部会缓存输入输出缓冲区所以不要每次用完之后手动释放Tensor让预测器复用缓冲区速度会明显变好。6.2 DLL发布时的运行库依赖问题DLL在开发机上跑得好好的拷到客户机器上一加载就崩这个坑几乎每个人都会遇到。Windows下主要是两个原因缺少VC运行库或者缺少paddle_inference依赖的第三方DLL。VC运行库方面我建议C项目编译时选择多线程静态链接/MT这样CRT完全静态打入DLL不依赖目标机器装VC Redistributable。这个选项在VS项目属性里的C/C - 代码生成 - 运行库里设置。代价是DLL体积会增大几兆但换来的是免安装运行环境非常值得。paddle_inference依赖的DLL可以通过官方包里的README确认CPU版通常需要paddle_inference.dll、mkldnn相关DLL、iomp5md.dll等GPU版还要加上cudart、cublas、cudnn这些。最简单的发布方式是直接把预测库整个bin目录里的DLL一并带到目标机器的exe或DLL同级目录不要只拿paddle_inference.dll一个。我早期的错误就是只拷了主DLL到现场反复崩溃排查了很久。另一个和发布相关的坑是路径。DLL内部加载模型文件用的是相对路径但C#程序的当前工作目录未必是exe所在目录比如从快捷方式启动时工作目录可能指向某个固定位置。我在Create接口里要求传入绝对路径的model_dir并且内部统一用std::filesystem::path处理避免Windows下反斜杠转义问题。模型文件路径本身如果包含中文某些旧版paddle库处理不好我会额外提示部署时路径尽量全英文。6.3 多实例与并发句柄不是线程安全的C#上位机经常有多线程需求比如相机采图线程、UI线程、算法线程分离。我的经验是每个推理句柄内部对应一个paddle预测器而预测器本身不是线程安全的。C#侧最安全的做法是每个业务线程创建独立的推理句柄互不共享。如果需要共享就必须在C#侧加锁把Process调用串行化。我测试过同一个句柄在两个线程同时Process的行为两个线程的输入会被交错污染输出结果不稳定偶尔会崩。原因很简单paddle预测器内部的输入输出Tensor是成员变量并发写同一个Tensor必然出问题。所以我强烈建议在最外层就分配好句柄一个句柄只服务一个逻辑线程。如果模型太大每个线程各加载一份会撑爆内存这时更合理的做法是开一个独立的推理线程所有业务线程通过任务队列把图像交给它由它串行执行推理再把结果回传。这个架构在C#侧实现起来也是标准做法BlockingCollection加上一个后台Task就能搞定。我在最终交付版本里采用的是这种模式稳定性比多句柄直接并行要好很多。最后聊两个实操中沉淀下来的小习惯这套方案从开发到交付我沉淀了几个比较实用的习惯。一个是错误缓冲区的长度一定要给足我在C#侧统一设成1024字符C侧写入错误信息时会做truncate防止缓冲区溢出导致程序崩溃。排查模型加载失败时这个错误信息缓冲区基本就是唯一线索给大了绝对没坏处。另一个是在Create接口里加了一个日志开关参数C侧可以在需要时开启paddle的详细日志输出到本地log文件平时则关闭。这个设计帮我解决过一次现场疑难问题用户的显卡驱动和程序预期不匹配导致GPU推理结果全错。打开日志后paddle会打出CUDA版本不匹配的警告几秒钟就定位到了。这种跨语言集成方案坑是固定的那么几个踩过一次之后就会形成自己的套路。希望这篇记录能让你在C#调用PaddleX推理模型的路上走得顺利一些。后续如果实现里碰到其他问题欢迎一起交流排查经验。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑