ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++与AI框架实战:从环境配置到推理加速

C++与AI框架实战:从环境配置到推理加速 做C开发这些年陆陆续续和不少AI框架打过交道。说实话圈里有个很奇怪的现象大家论文里写的是Python生产环境跑的是Python可一旦涉及底层算子、推理引擎、分布式通信翻来覆去全是C的活儿。甚至可以说你用过的主流框架TensorFlow、PyTorch、ONNX Runtime核心执行引擎没一个不是C写的。这篇文章不打算劝你放弃Python去拥抱C而是想聊聊C在人工智能框架里到底承担了什么角色、我们怎么用它做二次开发、环境怎么搭、代码怎么写、坑怎么踩。适合那些已经有C基础、想深入理解AI框架运行机制或者需要在生产环境里用C做推理加速的开发者。围绕C和AI框架这个主题网上能搜到很多零散的东西什么VSCode配置C环境、Visual C Redistributable安装、访问违例C0000005、回调函数例子、字符串转数组、冒泡排序、快速幂……这些看起来八竿子打不着其实串起来就是一个完整的C与AI框架实操链路。我下面会把它们揉进真实的开发场景里从环境准备、语法高频点、框架调用到问题排查一段一段讲清楚。1. 为什么人工智能框架偏偏选中C1.1 性能是硬道理要说清楚这个问题先打一个比方。Python像开着跑车在赛道上兜风写起来快、调试爽但碰到直线加速路段车子底盘是塑料的不敢踩油门。C就不一样它给你的是钢管车架和直喷发动机你油门踩到底心里有底因为每一分性能都出自你手。AI框架的核心计算无非是大量矩阵乘法、卷积、张量操作这种场景对CPU缓存命中、内存局部性、指令集优化都极度敏感。Python的GIL锁和解释型开销在这种计算量级下就是灾难一个tensor的乘法在Python里可能慢上几十倍所以框架底层必然要交给C这种编译型语言。我实际测试过一个很小的模型用PyTorch的Python接口和纯C的libtorch跑同一个推理任务单线程差距不大但一旦上多线程、批处理C的优势立刻显现。尤其是当你需要在边缘设备比如树莓派、嵌入式板卡上部署模型时内存和执行效率直接决定产品能不能跑起来。训练阶段大家无所谓反正有GPU集群但推理、部署、嵌入式场景C几乎是唯一解。1.2 内存控制力决定上限人工智能框架里最怕的是什么是内存爆炸和泄漏。模型参数动辄GB级别如果放任GC去管理你可能在训练中途就耗尽显存。C给你的是手动内存管理虽然麻烦但你可以精确控制每一块buffer的生命周期。比如做推理引擎时你可以在进程启动时就预分配一块极大的内存池后续所有张量都从池里取完全避免运行时的动态分配和碎片化。这种技术在TensorRT这种高性能推理引擎里是标配你用Python写压根做不到。另外还有个ABI兼容问题。你写的模型要部署到不同的硬件平台比如CUDA、ROCm、ARMC可以针对每种硬件写特定的编译分支然后用条件编译去适配。Python的库依赖链太脆弱动不动就版本冲突C静态链接完之后一个可执行文件扔到目标机上就能跑省心得多。这也是为什么很多工业级AI产品最终都以C动态库或者可执行文件的形式交付而不是给你一套Python脚本。2. 动手前先搭好环境VSCode中的C与AI开发配置2.1 编译器安装与配置很多新手上来就卡在环境配置上尤其是Windows用户。你打开VSCode写了个hello world结果编译报错找不到编译器然后就懵了。其实这里面的逻辑很简单VSCode本身只是个编辑器它不管编译你需要一个真正的C编译器。Windows上一般安排Mingw-w64或者Microsoft Visual CMSVC。如果你打算后面链接各种AI框架的库强烈建议直接用MSVC因为很多预编译库都是针对MSVC的ABI做的比如TensorFlow C API、ONNX Runtime的Windows包你用MinGW去链接它们大概率会栽在符号兼容性上。具体操作时我一般是先装Visual Studio Build Tools勾选“使用C的桌面开发”这一项它会把cl.exe、Windows SDK、CMake工具全给你带齐。然后在VSCode里装C/C扩展和CMake Tools扩展接着在项目根目录建一个.vscode文件夹里面写好c_cpp_properties.json指定编译器路径和IntelliSense模式。如果你不想手动写直接在VSCode命令面板里搜“C/C: Edit Configurations”它会自动生成。最关键的setting是compilerPath填上cl.exe的完整路径比如C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.38.33130\bin\Hostx64\x64\cl.exe。路径带空格没问题JSON里用双引号包好。还有个小坑MSVC编译器在命令行环境里需要先运行vcvars64.bat来设置环境变量。你如果在终端里直接敲cl会提示不是内部或外部命令。解决方法是安装VS后用VSCode的命令行窗口选择“Developer PowerShell”或者手动执行那个bat文件。我习惯在CMake Tools里配置cmake.generator: Visual Studio 17 2022这样CMake会自动帮你搞定环境不用每次手动激活。2.2 依赖库管理和CMake入门AI框架的C开发基本离不开CMake这几乎是行业标准。别嫌它语法丑但它跨平台能力是真的强。一个最小的CMakeLists.txt长这样cmake_minimum_required(VERSION 3.20) project(AIInference) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Torch REQUIRED) find_package(OpenCV REQUIRED) add_executable(run_main src/main.cpp) target_link_libraries(run_main PRIVATE torch_cpu opencv_core)我用libtorch比较多所以上面直接用find_package(Torch)。注意一点libtorch官方给的预编译包已经包含了TorchConfig.cmake你不需要手动编译整个PyTorch。但前提是你要把下载的libtorch解压后把它的share/cmake路径告诉CMake。一种做法是设置环境变量Torch_DIR另一种是直接在CMakeLists里写set(Torch_DIR D:/libs/libtorch/share/cmake/Torch)。为了图省事我通常两种都写上避免到时候找不到。依赖库管理方面别用纯手动的include_directories和link_directories那是给自己挖坑。一个上游库更新你的头文件路径就废了。正确的做法是尽量用find_package找不到的库就用FetchContent或者vcpkg拉取。比如你要用ONNX Runtimeinclude(FetchContent) FetchContent_Declare( onnxruntime URL https://github.com/microsoft/onnxruntime/releases/download/v1.17.1/onnxruntime-win-x64-1.17.1.zip ) FetchContent_MakeAvailable(onnxruntime)这样CMake会自动下载并配置好所有依赖你只需要在target里链接onnxruntime::onnxruntime就行。我实测下来比手动下载再折腾路径高效得多而且版本清晰换机器也能无缝复现。3. 核心C语法在AI框架中的高频应用3.1 随机数与数据增强AI训练和推理里随机数无处不在数据打乱、权重初始化、dropout、噪声扰动。但C标准库里的随机数生成器有好几种很多人到现在还在用srand配合rand()这在AI场景里是大忌。一方面rand()的分布质量差另一方面它在多线程环境下不是线程安全的容易出现重复序列。标准做法是使用random头文件里的mt19937和uniform_real_distribution。#include random #include vector std::vectorfloat generate_noise(size_t n, float epsilon) { std::mt19937 gen(42); std::normal_distributionfloat dist(0.0f, epsilon); std::vectorfloat noise(n); for (auto x : noise) { x dist(gen); } return noise; }这里42是种子固定下来方便复现实验结果。训练时如果你想每次结果一致就固定种子如果要做数据增强打散最好用std::random_device配合一个随机种子避免每次启动都一样。实测下来mt19937的性能在一般场景完全够用还不会像rand()那样出现周期性明显的低质量序列。还有一点在多线程环境里每个线程要有独立的生成器实例不要共享同一个mt19937。因为std::normal_distribution内部会缓存状态共享会带来竞态条件。我的习惯是每个线程初始化一个局部引擎用std::hashstd::thread::id加当前时间作为种子既保证独立又保证随机性。3.2 回调函数与异步推理在AI框架里回调函数并非一个新奇的东西但它是C开发者和Python开发者思维差异的最大体现。Python里你写一个callback lambda x: print(x)传进去就完事闭包机制把变量捕获处理得很干净。C里你要么用函数指针要么用std::function要么用lambda表达式但捕获方式值捕获、引用捕获得你自己选好不然就是一个编译错误或者悬垂引用。我做异步推理引擎时通常这样设计#include functional #include future using InferenceCallback std::functionvoid(int status, const std::vectorfloat result); void async_predict(const float* input, InferenceCallback cb) { std::thread([input, cb]() { // 这里假装做推理 std::vectorfloat result {0.1f, 0.2f, 0.3f}; cb(0, result); }).detach(); }这里std::function可以容纳普通函数、lambda表达式以及std::bind绑定后的对象非常灵活。但注意一点如果你捕获了this指针而对象被销毁了回调调用时就是一个悬垂指针轻则崩溃重则产生未定义行为。我的经验是在回调里尽量只传值或者智能指针比如std::shared_ptrModel避免裸指针的生命周期问题。还有一点回调执行完毕后一定要确保线程安全地访问共享数据。如果用回调去更新UI或者写日志加锁或者用原子变量是必须的。我踩过一次坑在回调里直接打印std::cout结果多线程下输出乱得没法看。后来改成全局互斥锁才稳定下来。3.3 字符串处理与模型输入输出模型推理的输入输出未必总是tensor很多时候你要处理JSON、解析命令行参数、把字节流转成字符串或者反过来。C的字符串处理比Python繁琐多了但也不至于那么不堪。关键是要避免用C风格的char*和strcpy那是内存泄漏和缓冲区溢出的源泉。现代C你应该使用std::string和std::string_view。热词里有个“c字符串数组初始化”很多新手写成这样char arr[3][10] { hello, world, ai };这在简单场景没问题但一旦需要动态长度你就得回到std::vectorstd::string。比如你要把模型的类别名字符串列表存进bufferstd::vectorstd::string class_names { cat, dog, bird }; std::string all std::accumulate(class_names.begin(), class_names.end(), std::string(), [](const std::string a, const std::string b) { return a.empty() ? b : a , b; });这样得到的是cat,dog,bird然后你想把这个字符串转成字节流传给网络层或者从网络层收到字节流转成字符串用std::string的构造和迭代器接口就行。还有个小技巧解析用户输入的命令行参数直接用getopt或者现在的std::from_chars因为std::from_chars是零开销、无异常的数字解析方式比strtol安全得多。4. 实战用C调用一个轻量AI框架4.1 框架选型从TensorFlow到ONNX Runtime如果你只是想在C里跑一个训练好的模型不需要自己写底层算子那选型其实很简单。TensorFlow的C API一直是噩梦级别接口变动频繁社区支持有限我周围没几个人用它做实际部署。PyTorch的libtorch体验好很多AP稳定文档也全但安装包体积巨大不适合边缘设备。我这两年用得最多的是ONNX Runtime它轻量、跨平台、支持ONNX格式而且直接提供了C APIwindows和linux都有预编译包。选ONNX Runtime还有个好处它支持自定义运算符如果你有一些特殊的Layer在标准ONNX里没有你可以自己写C算子注册进去。这一点在工业项目里简直救命。之前我给一个项目做图像分类模型里有个自定义的仿射层用Python导出ONNX时直接生成一个黑盒算子推理端用C把它的算法实现出来再注册成同名的CustomOp整个过程行云流水。另一个备选是OpenVINOIntel家的框架在CPU上推理优化得非常狠。它的C API也不复杂如果你部署的机器都是Intel处理器性能会比ONNX Runtime默认CPU后端好一截。但OpenVINO对ONNX算子支持不全我试过有一些奇怪的slice操作它编译会报错。所以我的默认方案是ONNX Runtime只有在明确需要Intel优化时才切OpenVINO。4.2 写一个图像分类的小例子下面这个例子用的是ONNX Runtime C API做的是读入一张图片、预处理、推理、输出结果。整个代码量不大但每一步都值得说清楚。#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include vector #include iostream int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, inference-demo); Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); Ort::Session session(env, Lmodel.onnx, session_options); const char* input_name input; const char output_name output; // 读取图片并resize到224x224 cv::Mat img cv::imread(cat.jpg, cv::IMREAD_COLOR); cv::resize(img, img, cv::Size(224, 224)); // 转换为float并归一化 [0,1] img.convertTo(img, CV_32FC3, 1.0 / 255.0); // HWC转CHW std::vectorfloat input_data(224 * 224 * 3); float* p input_data.data(); for (int c 0; c 3; c) for (int h 0; h 224; h) for (int w 0; w 224; w) p[c * 224 * 224 h * 224 w] img.atcv::Vec3f(h, w)[c]; // 构建输入输出张量 std::vectorint64_t input_shape {1, 3, 224, 224}; Ort::MemoryInfo memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat(memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); // 推理 std::vectorconst char* input_names { input_name }; std::vectorconst char* output_names { output_name }; auto output_tensors session.Run(Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 1); // 取最大概率对应的类别索引 float* output_data output_tensors.front().GetTensorMutableDatafloat(); size_t num_classes output_tensors.front().GetTensorTypeAndShapeInfo().GetShape()[1]; int max_idx 0; float max_val output_data[0]; for (size_t i 1; i num_classes; i) { if (output_data[i] max_val) { max_val output_data[i]; max_idx static_castint(i); } } std::cout Predicted class: max_idx with probability max_val std::endl; return 0; }重点说几个细节。第一session.Run里的输入名字必须和模型导出时的节点名一致不一致会直接报错而且错误信息很隐晦。建议先写个小脚本用ONNX Python库打印出输入输出节点名再硬编码进去。第二cv::Mat的通道顺序是BGR但ONNX模型通常训练用的RGB所以推理前应该做cv::cvtColor(img, img, cv::COLOR_BGR2RGB);不做的话模型精度会明显下降。这是新手最容易犯的错误。第三内存复用如果你在循环里边推理边处理视频帧不要把cv::resize和convertTo放在每次循环里创建新Mat最好提前分配好固定尺寸的Mat然后循环里img.copyTo(fixed_size_mat)能省不少分配开销。这个例子我用VS2022编译CMake链接了onnxruntime和OpenCV整个过程不到两百行。对于刚上手的读者强烈建议先跑通这个最简单的例子再去碰复杂的自定义算子或者多输入模型。5. 踩坑实录C与AI框架的常见问题5.1 访问违例C0000005野指针与内存泄漏热词里“c#调用c出现access violation c0000005”是很多人都会撞上的典型错误。这个错误码本质就是内存访问违例通俗讲就是你读写了不属于你的内存地址。在C与AI框架的场景里最常见的诱因有三个一是裸指针被提前释放二是数组越界三是DLL接口的ABI不匹配。我之前写一个C动态库给C#调用C#端一执行就崩事件日志里就写着C0000005。排查了半天才发现问题出在英国里返回的std::vectorfloat。C#通过P/Invoke调用C导出函数如果有C标准库类型跨越DLL边界两边用的CRT版本不一致内存布局就对不上。解决办法很简单导出函数统一用C接口比如返回float*同时提供release函数来释放内存。千万不要在C的DLL里直接返回std::string或者std::vector除非你确保两边编译器完全一致。这个坑我踩过至少三次现在已经是条件反射了。还有一次是读模型文件的指针。我用std::ifstream读onnx文件读完后忘记把流关掉再用其它接口结果那块内存被系统回收后面访问就崩了。这提醒我们任何涉及资源获取的对象一定要确保生命周期足够长或者直接用智能指针管理。给AI框架传数据时尽可能用std::shared_ptrT包裹buffer别直接传裸指针。5.2 跨语言调用时的ABI兼容问题刚才说的C#调用C只是ABI问题的一个缩影。AI框架生态里你经常会遇到C库和Python库混合调用比如你在C里加载一个动态库里面又调用了Python扩展。这种情况下最容易翻车的点就是运行时库不匹配。Windows上调试版MDd和发布版MD的堆管理器不一样你Debug编译自己的代码却链接了Release版的ONNX Runtime库那么所有跨DLL的malloc和delete都会在运行时爆炸。我的建议是所有和AI框架相关的传递都统一使用框架提供的API来分配和释放内存。以ONNX Runtime为例你用Ort::Value::CreateTensor创建的输入输出内存就不要试图用free去释放一定要让Ort处理。如果是为了自定义算子内部用了std::vector那也只在算子内部用不把它和框架的buffer直接混在一起。检测ABI问题最好的方式是在Debug模式下跑一遍测试把所有错误都调出来看看。Visual Studio有一个/gz选项可以辅助捕获一些未初始化的变量但更重要的是你必须在代码里做好断言比如检查指针是否为空、数组边界对不对。实测下来大部分访问违例都是因为一个NULL指针没检查加上一句if (!ptr) { /* log */ }就能省下几个小时的调试时间。5.3 八股文考点运算符优先级与按位运算网上很多C八股文什么运算符优先级、按位与、快速幂、单调栈之类的看起来和AI框架没关系但用起来一个比一个频繁。比如图像处理里要做二值化、Mask操作离不开按位与模型量化的时候要处理位掩码甚至矩阵转置时用到的整数运算都需要你对位运算有肌肉记忆。按位与在AI中的常见场景是在处理mask时把多个标志位打包成一个整数。比如你有一个枚举表示图像要不要做归一化、要不要翻转、要不要裁剪enum class PreprocessFlag { None 0, Normalize 1 0, Flip 1 1, Crop 1 2 }; int flags static_castint(PreprocessFlag::Normalize) | static_castint(PreprocessFlag::Flip); if (flags static_castint(PreprocessFlag::Normalize)) { // 做归一化 }这种用法在推理配置里非常常见比传一堆布尔参数清晰得多。另一个热词“快速幂算法c”本质是二分加速的幂运算在模型量化、整型转浮点的某些场景会用到但更多时候它出现在面试题里。对于AI框架开发理解快速幂的思想并不算重点重点在于你要能一眼看懂类似result (result * a) % mod这种循环递推代码因为很多底层的CUDA核函数就是这样写。还有一个热词“判断质数c优化”其实是在说循环里的边界问题。你判断一个数是不是质数不需要从2遍历到n遍历到sqrt(n)就够。这就是最基本的优化意识。在AI框架里类似的优化到处都是比如矩阵乘法里对非零元素的跳转、稀疏矩阵的存储格式。这些八股文知识本质上是训练你思考计算机是怎么高效处理数据的而不是真的让你考证书。5.4 编译配置与Redistributable的纠缠热词里反复出现“microsoft visual c redistributable”这个组件对Windows下的C AI开发几乎是必需品。你发布一个C写的推理引擎目标机器上如果没有对应版本的VC运行库启动直接报错找不到DLL。很多人以为装了最新版就万事大吉实际上不同的编译器版本需要特定的Redistributable比如VS2015、2017、2019、2022生成的运行库后缀会带版本号而且它们可以共存。要想不依赖系统Redistributable你有两个选择一是静态链接运行时库这在CMake里设置/MT而不是/MD二是直接用windeployqt这类工具把需要的DLL拷贝到目标目录。为了省心我对内部工具一般用动态库对交付给客户的产品直接做静态链接虽然.exe体积会大一点但极大地减少了环境配置投诉。另外提一句VSCode配置C环境时你只要系统里装了对应的Redistributable大部分编译问题都能避免。特别是当你用CMake来找Torch或ONNX Runtime它们的构建版本都要求系统至少安装了VC 2015-2022运行库。我一般会写一个构建脚本先检测注册表里是否安装了对应的运行库没有就调用静默安装包这样自动化构建才能跑通。6. 一个私藏的观点C是理解AI框架的最短路径最后说点经验之外的东西。我见过很多算法工程师写了好几年Python却对模型为什么跑得慢、内存峰值为什么那么高毫无概念。因为Python帮你隐瞒了太多底层细节。而C逼着你面对每一个tensor的生命周期、每一次内存拷贝、每一处计算损耗。我最初转型做AI框架优化时就是靠啃了一个月libtorch源码才真正明白什么叫做“张量的内存布局”。我个人实际操作中的体会是只要你在C里调通一个最小的推理框架后面再看TensorFlow的bazel构建、PyTorch的c10d通信基本都是一通百通。所以如果你是做AI后端、嵌入式AI、或者模型部署相关的工作不要只会Python花点时间把C与AI框架这条链路打通你会发现职业生涯里能解决的实际问题会多一个量级。最后再分享一个小技巧调试C的AI框架不要一上来就用GUI断点。先用日志把每个关键环节的输入输出形状、数值范围打印出来确认数据流没有断裂再用断点去捕获具体哪一行崩了。这个习惯帮我省掉的排查时间至少能顶一年工资。
返回列表