尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

纯C++手写CNN推理引擎:从张量布局到卷积与Softmax实战

发布时间:2026/9/28 21:21:23

资讯中心
01
ARTICLE

纯C++手写CNN推理引擎:从张量布局到卷积与Softmax实战

纯C++手写CNN推理引擎:从张量布局到卷积与Softmax实战
C和深度学习这两个词摆在一起很多人第一反应是别扭深度学习不是Python的天下吗PyTorch写个模型几分钟为什么要回到C里去折腾我做过这几年模型落地相关的工作说实话训练阶段你确实离不开Python的生态但一旦模型要上嵌入式设备、要接入工业视觉软件、要在线上以毫秒级延迟跑服务C几乎就是绕不开的一站。这一篇是这个系列第五篇前面几篇把C基础、矩阵运算、张量设计和网络结构定义都铺垫完了这一篇的目标很明确不依赖任何深度学习框架用纯C把卷积、池化、全连接、Softmax这些算子逐一手写出来组装成一个能真正完成图像分类的CNN推理引擎。如果你是刚入门的同学这篇同样适合你。我不打算堆一个生产级的推理框架而是带你走一遍模型到底是怎么被算出来的这条路。读完之后你至少能回答三个问题张量怎么存、卷积怎么算、权重文件怎么加载。如果你已经在用Python完成过训练、正为怎么把模型搬到C服务里发愁这篇也能给你一个可行的参考起点。1. 要不要用纯C手写推理引擎1.1 训练、推理和部署其实是三个世界先说一个很多人忽略的事实训练和部署的环境完全不同。训练阶段你用Python、PyTorch、GPU集群追求的是快速迭代一天跑几十个实验部署阶段你面对的是客户机、边缘盒子、工控机追求的是低延迟、可裁剪、不依赖几十个Python包。两者之间的桥梁往往就是C。打个比方训练像驾校练车Python生态是把教练、练车场、考试车全套给你配好推理部署像上路跑运输你要自己控制油耗、路线、车辆检修C就是这个阶段最顺手的工具。LibTorch、ONNX Runtime、TensorRT这些库当然成熟我一向建议能用就用但前提是你得知道它们内部到底替你做了什么。否则遇到一个模型在Python里跑得好好的转到C就崩溃/结果错误的问题你会非常被动因为无从下手排查。1.2 这个项目的边界理解级推理引擎不是框架替代品手写推理引擎目标不是替代TensorRT而是给自己建立一条可以调试、可以解释的技术链路。动手实现一遍之后你会真正理解卷积层输出的尺寸是怎么算出来的、特征图在内存里是怎么排布的、全连接层的权重矩阵为什么是这个形状、Softmax为什么要在代码里做减最大值处理。这些认知的价值在面试时体现得尤其明显。现在不少C岗位和算法工程岗位的面试都会要求手撕代码包括手写卷积、手写矩阵乘法、手写Softmax。你在项目里真实踩过这些坑和背八股的效果完全不一样。另外这个引擎后面要接ONNX Runtime还是TensorRT都只是换一个后端的问题你脑子里已经有一条清晰的数据流地图。1.3 需要的前置技能和开发环境先说基本功。C这边我默认你已经掌握数组、指针、std::vector、结构体和链表这些基础如果这些还不太熟可以先回去翻系列前两篇。深度学习这边只要有CNN的基本概念就行卷积核、stride、padding、池化、全连接这些名词不陌生就好具体的数值计算细节我这篇会一步步展开。开发环境我用的是Visual Studio Code配C/C扩展加上CMake和g。编译器最低C11就够我建议直接上C17后面写模板和文件操作会更顺手。调试器一定要会用Windows上就是VS的调试器或者gdbLinux/macOS下是gdb/lldb。这类代码一次性写对几乎不可能断点调试和AddressSanitizer是你最重要的两个帮手后面第4章我会专门演示怎么用它定位越界。2. 先把两块地基打牢张量布局与卷积计算2.1 张量为什么要躺平成一条一维数组C里没有一个叫多维数组的高层类型所以所有的张量最终都要线性化存储。最常见的内存布局有两种NCHW和NHWC。NCHW表示把每个通道的数据聚在一起H和W是连续排布的NHWC则把每个像素点的所有通道值排在一起。PyTorch默认NCHWTensorFlow很多模型用NHWC。本篇以NCHW为例因为它和PyTorch的权重导出格式对齐起来最省事。我习惯在Tensor类里同时维护shape和strides两个数组。shape记录每个维度的长度strides记录每个维度上走一步要跳过多少元素。比如一个{1, 3, 32, 32}的张量strides就是{3072, 1024, 32, 1}。元素(n, c, h, w)的一维偏移量就是((n * 3 c) * 32 h) * 32 w。这样设计的好处是后面做变形、展平、切片的时候只需要改shape和strides不用真的搬数据。这里我吃了不少亏所以多说一句所有算子的实现到最后都是在算偏移量。偏移算错结果要么错得莫名其妙要么直接越界崩溃。调试时第一件事永远是拿笔算一遍当前层的输入和输出形状确认每个at(n, c, h, w)调用访问的是你预期的那块内存。2.2 卷积输出尺寸公式不是背的是推出来的卷积输出尺寸的公式很简单OH (H 2 * pad - K) / stride 1OW同理。很多人只记公式不理解为什么我来拆一下。假设输入长度是H卷积核长度是K步长是S上下各补pad个0。第一步卷积核覆盖的起点范围是[0, K-1]加上两边pad之后有效的滑动起点个数就是H 2 * pad - K 1。每隔S个位置取一个起点所以结果就是floor((H 2 * pad - K) / S) 1。举个例子输入32x323x3卷积pad1stride1(32 2 - 3) / 1 1 32输出尺寸不变。stride改成2就是(32 2 - 3) / 2 1 16特征图直接缩小一半。很多人在这一步出错是因为忘了输出的第一个位置是起点0不是起点1于是公式里少了一个1。建议每个网络都先画一张类似下面的尺寸表跑代码之前先用手算一遍。2.3 卷积计算的两种实现朴素四重循环和im2col第一个实现方式也是最容易理解的就是朴素循环对每个输出像素把输入上对应的窗口和卷积核做点积再加bias。代码大概长这样省略了batch维度默认单张图void conv2d(const Tensor input, const Tensor weight, const Tensor bias, Tensor output, int stride 1, int pad 0) { int N input.shape[0], C input.shape[1]; int H input.shape[2], W input.shape[3]; int OC weight.shape[0]; int KH weight.shape[2], KW weight.shape[3]; int OH (H 2 * pad - KH) / stride 1; int OW (W 2 * pad - KW) / stride 1; for (int n 0; n N; n) for (int oc 0; oc OC; oc) for (int oh 0; oh OH; oh) for (int ow 0; ow OW; ow) { float sum bias.data[oc]; for (int ic 0; ic C; ic) for (int kh 0; kh KH; kh) for (int kw 0; kw KW; kw) { int ih oh * stride - pad kh; int iw ow * stride - pad kw; if (ih 0 || ih H || iw 0 || iw W) continue; sum input.at(n, ic, ih, iw) * weight.at(oc, ic, kh, kw); } output.at(n, oc, oh, ow) sum; } }这段代码里的边界检查是重中之重它保证了padding区域的零填充效果同时避免越界访问。朴素循环的优点是直观、好调试缺点是有很多重复计算速度不够快。第二个方式是im2col把每个滑动窗口覆盖的数据拉成一行组成一个大矩阵再和权重矩阵做GEMM通用矩阵乘法。这样可以把卷积转换为成熟的BLAS矩阵乘法库很多框架底层都是这么干的。代价是输入数据会被复制多次内存占用明显增大。这一篇先用朴素循环跑通正确性第4章性能优化部分再讨论im2col。2.4 权重初始化C随机数不是随便写个rand()训练阶段权重初始化很讲究推理阶段你当然直接加载训练好的权重不需要随机初始化。但我强烈建议你在引擎里保留一个随机初始化的入口用来做自检测试。这时候就用上了c随机数这个话题。第一个原则不要用rand()可控性太差。用std::mt19937这个伪随机数生成器配合std::normal_distribution或std::uniform_real_distribution。第二个原则固定种子。std::mt19937 gen(42)里的42写死保证每次运行生成同一个序列否则你很难判断这次结果不对到底是代码bug还是随机数抖动带来的。初始化的方差也不能随便拍脑袋。常用的Xavier初始化规定权重方差取2 / (fan_in fan_out)。对第一个卷积层来说fan_in 3 * 3 * 3 27输入通道3核3x3fan_out 8 * 3 * 3 72输出通道8核3x3标准差就是sqrt(2 / (27 72)) ≈ 0.142。分布取均值为0的正态分布。这个数值直接决定ReLU层的激活值会不会整体偏向负数后面第4章会讲相关的坑。3. 实操从零搭一个能跑通的CNN推理引擎3.1 工程骨架与CMake构建配置我习惯把项目按这个结构组织simple-cnn/ ├── CMakeLists.txt ├── include/ │ ├── tensor.h │ ├── layers.h │ └── model.h └── src/ ├── main.cpp ├── tensor.cpp ├── layers.cpp └── utils.cppCMakeLists.txt的核心内容如下cmake_minimum_required(VERSION 3.16) project(SimpleCNN) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(simple_cnn src/main.cpp src/tensor.cpp src/layers.cpp src/utils.cpp ) target_include_directories(simple_cnn PRIVATE include) # 调试阶段建议打开警告和AddressSanitizer target_compile_options(simple_cnn PRIVATE -Wall -Wextra -fsanitizeaddress -fno-omit-frame-pointer) target_link_options(simple_cnn PRIVATE -fsanitizeaddress)关于vscode配置c/c环境我多说一句。VSCode的C调试需要两个文件tasks.json负责编译launch.json负责启动调试器。我通常只保留一个构建任务内容是cmake -B build cmake --build build然后F5调试时直接加载build/simple_cnn。这样每次改动源码按F5就会自动重编译并停在断点上。比在终端里反复敲命令高效得多。注意-fsanitizeaddress只在Debug阶段开启发布版本一定要去掉否则性能和内存开销不可接受。3.2 张量类和卷积/池化层的核心代码张量类不需要做得多花哨但两个基础能力必须有多维索引转一维偏移、以及按shape分配连续内存。我这里用通用版本实现strides按反向累积计算class Tensor { public: std::vectorint shape; std::vectorint strides; std::vectorfloat data; explicit Tensor(const std::vectorint s) : shape(s) { size_t total 1; for (int d : s) total * d; data.assign(total, 0.0f); compute_strides(); } void compute_strides() { strides.resize(shape.size()); int acc 1; for (int i (int)shape.size() - 1; i 0; --i) { strides[i] acc; acc * shape[i]; } } size_t offset(const std::vectorint idx) const { size_t off 0; for (size_t i 0; i idx.size(); i) { off (size_t)idx[i] * strides[i]; } return off; } float at(int n, int c, int h, int w) { return data[offset({n, c, h, w})]; } const float at(int n, int c, int h, int w) const { return data[offset({n, c, h, w})]; } };offset({n,c,h,w})这种写法依赖C的隐式构造把花括号列表转换为std::vectorint实测可用但要注意性能每次访问都创建一个临时vector。教学项目里无所谓做真实引擎时建议写死四维索引公式或者提供at4这样的快速路径。卷积层用第2章的朴素循环实现我把它放进layers.cpp。池化层同样简单MaxPool每次取窗口最大值void maxpool2d(const Tensor input, Tensor output, int k, int stride) { int N input.shape[0], C input.shape[1]; int H input.shape[2], W input.shape[3]; int OH (H - k) / stride 1; int OW (W - k) / stride 1; for (int n 0; n N; n) for (int c 0; c C; c) for (int oh 0; oh OH; oh) for (int ow 0; ow OW; ow) { float best -std::numeric_limitsfloat::infinity(); for (int kh 0; kh k; kh) for (int kw 0; kw k; kw) { float v input.at(n, c, oh * stride kh, ow * stride kw); if (v best) best v; } output.at(n, c, oh, ow) best; } }这里没有越界问题因为输出尺寸公式保证了每个窗口都完整落在输入范围内。ReLU层最简单但也最容易写错注意是就地修改还是复制一份。我建议推理引擎里用就地修改省一次内存拷贝void relu_inplace(Tensor t) { for (float v : t.data) { v v 0.0f ? v : 0.0f; } }3.3 全连接层与Softmax把特征变成概率全连接层本质就是矩阵乘法。输入经过展平后变成形状[N, D]的二维张量权重形状是[M, D]输出形状是[N, M]。核心循环void linear(const Tensor input4d, const Tensor weight, const Tensor bias, Tensor output2d) { int N input4d.shape[0]; int D 1; for (int i 1; i input4d.shape.size(); i) D * input4d.shape[i]; int M weight.shape[0]; for (int n 0; n N; n) for (int m 0; m M; m) { float sum bias.data[m]; for (int d 0; d D; d) { sum input4d.data[n * D d] * weight.at(m, 0, d, 0); } output2d.at(n, 0, m, 0) sum; } }这里我直接把四维输入当作一维连续内存访问因为展平后的数据顺序天然就是行主序连续排列不需要真的拷贝。所以写input4d.data[n * D d]完全正确这也是前面强调NCHW布局带来的红利。Softmax是最容易出数值问题的地方先把稳定版本贴出来std::vectorfloat softmax(const std::vectorfloat logits) { float maxv *std::max_element(logits.begin(), logits.end()); std::vectorfloat expv(logits.size()); float sum 0.0f; for (size_t i 0; i logits.size(); i) { expv[i] std::exp(logits[i] - maxv); sum expv[i]; } for (float v : expv) v / sum; return expv; }为什么要先减最大值因为exp(100)会直接溢出成inf但exp(0)1是安全的。所有值同时减去同一个常数只会让每个exp都缩放相同的倍数最终归一化后结果不变。这是推理引擎里少数几个必须现在就养成的习惯。3.4 一个32x32输入的二分类网络完整拼装为了让推理链路完整可跑我设计一个极小的CNN输入是32x32的3通道图像输出2个类别。结构如下层输入shape输出shape参数量Conv1 3x3/1 pad1, 8个输出通道[1,3,32,32][1,8,32,32]224ReLU[1,8,32,32][1,8,32,32]0MaxPool 2x2 stride2[1,8,32,32][1,8,16,16]0Conv2 3x3/1 pad1, 16个输出通道[1,8,16,16][1,16,16,16]1168ReLU[1,16,16,16][1,16,16,16]0MaxPool 2x2 stride2[1,16,16,16][1,16,8,8]0Flatten[1,16,8,8][1,1024]0Linear 1024-32[1,1024][1,32]32800ReLU[1,32][1,32]0Linear 32-2[1,32][1,2]66Softmax[1,2][1,2]0参数量统计一下Conv1是8 * (3 * 3 * 3) 8 224Conv2是16 * (8 * 3 * 3) 16 1168第一个全连接是1024 * 32 32 32800最后是32 * 2 2 66合计34258个参数。这个规模已经能跑通完整的图像-分类概率链路部署在CPU上对延迟的压力也不大。推理主流程分五步读入图片解码成32x32x3的float数组数值范围从0-255归一到[-0.5, 0.5]。按训练时的网络结构逐层前向卷积、ReLU、池化、卷积、ReLU、池化、展平、全连接、ReLU、全连接。得到最后的2个logits送入Softmax。取概率最大的下标作为预测类别。打印或返回这个类别。我见过太多人在这步踩坑Python端训练时做了归一化C端推理时忘了做或者做了不一样的归一半径结果模型输出全是一团浆糊。请记住推理端的数据预处理必须和训练端完全一致。这是部署项目里第一重要的经验没有之一。权重文件建议用自定义二进制格式不要直接丢一个裸的float数组。我的格式是头部4字节magic比如CNDL、int32版本号、int32层数后面每层依次记录层类型、当前层数据长度、以及对应的float数据。这样未来模型结构一变C端至少能通过版本号和shape校验快速发现问题而不是静默算错。4. 调试实录四个让我抓狂的坑4.1 Access ViolationC内存越界是如何引爆的第一次跑完整推理程序直接崩了报错是经典的access violation c0000005或者Segmentation fault。这个错误在Python里几乎不会遇到因为Python会给你一个明确的下标越界提示C这里完全是未定义行为可能崩溃也可能悄悄读出错误数据后者更危险因为程序不报错你只会觉得结果怎么这么奇怪。我那次崩溃的元凶是卷积层里的负索引。padding1时oh0, kh0会算出ih 0 * 1 - 1 0 -1如果代码里直接访问input.at(n, ic, ih, iw)就会越界。此时ih -1被转成size_t后变成一个巨大的正整数读到的是随机内存轻则输出nan重则Segmentation fault。排查手段按顺序来。第一用gdb跑一遍崩溃时bt看调用栈定位到具体函数和行号。第二打开AddressSanitizer也就是我在CMake里加的-fsanitizeaddress它会直接告诉你第几行访问了非法地址。第三在at()方法里加一条Debug断言assert(offset(...) data.size())防患于未然。说到这顺带提一下热词里c#调用c出现access violation c0000005的场景。真实项目里C#通过P/Invoke调用C DLL报这个错绝大多数原因其实还是C这一侧内部就把内存写坏了或者导出函数没有按cdecl/stdcall约定声明成功托管和非托管的数据类型没有对齐。无论哪种情况思路都是一样的先让C独立跑稳定再谈语言互操作。C侧稳定了C#调用90%的问题自然消失。4.2 Softmax数值溢出与ReLU坏死第二个坑是Softmax溢出。第一次我偷懒没减最大值直接exp(logits[i])结果当logits里出现10以上exp(10)已经很大到20附近就开始膨胀出nan了。二分类还好多分类的最后一层输出经常会有个几位数一旦溢出所有概率都会变成nanargmax结果完全不可信。解决方案就是第3章那个稳定写法减最大值没有别的捷径。ReLU的坑更隐蔽训练好的网络跑着跑着你发现某一层的激活输出全是0。排查出来常常是权重初始化时方差设得太小、偏置设得太负导致加权求和后的值一直是负数ReLU把负值全部清零梯度在训练阶段传不回去表现为死神经元。这个现象虽然更多出现在训练期但推理期如果发现某层特征图全零也要第一时间怀疑初始化和偏置量而不是怀疑你自己代码写错了。还有一类问题不在基础算子而在BatchNorm。PyTorch训练好的模型里Conv后面通常跟一个BN层推理时BN的均值和方差是固定的可以合并进前一层的卷积权重里。如果C端只实现Conv和ReLU忽略了BN的融合输出和对不上。这一步是最常见的PyTorch和C结果不一致原因不是算子写错而是少了BN层。4.3 随机数种子不固定推理结果神出鬼没第三个坑来自随机数。我在自检测试里用随机初始化的权重跑了一遍第一次输出类别A第二次输出类别B整个人懵了。代码逻辑明明没变结果却在跳后来才发现权重初始化用的是std::rand()种子由系统时间决定每次运行都不一样。调参经验告诉我一个原则调试阶段一切随机源都要固定。std::mt19937配一个固定种子比如42保证每个测试用例都能复现。推理阶段更是如此加载权重文件就老老实实加载不要在初始化阶段混入随机扰动。否则你没法判断结果异常是代码逻辑的错还是随机噪声的错。养成固定种子的习惯后你会发现报bug的概率下降一大截因为问题变得可以稳定复现、可以稳定验证修复。4.4 性能慢先看内存布局再看并行朴素卷积在Debug模式下跑一个32x32的输入可能都要几百毫秒第一反应别急着上OpenMP先用-O2重新编译试试。我实测下来光是开-O2通常就有几倍到十几倍的差距因为编译器会做循环展开和寄存器优化比你还空手写的微优化强得多。第二步是调整循环顺序让最内层的访问尽量沿内存连续方向走。NCHW布局下最后一个维度是W所以卷积核宽度方向的循环kw对应iw ow * stride - pad kw它是最接近连续的。尽量把kw的循环放在内层减少缓存换入换出。第三步才有必要上im2col和OpenMP。im2col把卷积变成矩阵乘法后可以在全连接层和卷积层之间复用同一个GEMM实现OpenMP则直接给外层循环加一句#pragma omp parallel for让batch维度和输出通道维度并行。注意OpenMP的调度开销网络太小的时候收益不明显图像分辨率起来之后才划算。做性能优化一定要先profile不要凭感觉。提示推荐顺序是先保证正确、再开优化。用AddressSanitizer把内存问题清干净之后再去谈速度否则优化出来的快很可能是建立在错误结果上的快。5. 这套推理引擎能用到哪些真实场景5.1 用C做一个真正的二分类系统热搜词里有深度学习模型cnn识别恶意软件和基于深度学习的口腔疾病图像识别系统这两个方向其实都能套上我前面搭的推理引擎区别只是输入和网络规模。恶意软件识别可以把程序文件转换成固定长度的特征向量或者把PE文件的字节序列组织成灰度图用CNN做二分类口腔疾病图像识别则是典型的医学图像分类输入的X光片或内窥镜图像在进入网络前先缩放到统一尺寸。这类系统落地时有两个共同点一是对延迟敏感临床工作站或杀毒引擎不可能等Python脚本慢慢跑二是对稳定性要求高不能因为模型文件损坏或尺寸不匹配就静默出错。所以模型文件的版本管理、shape校验、加载失败的处理这些C感很强的工作恰恰是Python原型里最容易被忽略的。我的自定义二进制权重格式核心价值就在这里。5.2 工业视觉里的C深度学习生态如果你做工业视觉halcon深度学习、VisionMaster的深度学习版本这些工具应该不陌生。它们的共同特点是训练阶段用各自的工具或Python框架完成部署阶段都提供C接口让你把训练好的模型加载到相机软件或者工控机程序里实时推理。这就意味着C工程师至少要能看懂张量在哪一层变了形状、哪一层是耗时大头否则出了问题只能反复联系算法团队帮我看看为什么跑慢了。另外深度学习云平台目前的主流用法也是训练上云部署下沉到边缘。云端用GPU集群训练导出精简模型边缘设备用C推理。我的迷你引擎虽然性能远不如专业库但它把整条链跑通了你在这个过程里积累的模型格式、预处理对齐、调试方法论换到任何平台都通用。5.3 顺着这个项目准备的C面试题很多热词都指向C面试和八股比如c八股c面试冒泡排序算法cc结构体链表基本语法c字符串数组初始化。我负责任地说亲手写完这个推理引擎你面试时能聊的东西会非常具体为什么用float而不是double、std::vector扩容的摊销代价、const传参为什么是常态、结构体在内存里怎么对齐、链表和数组在局部性上的差异。这些东西不再需要死记硬背因为你在写张量类的时候已经踩过一遍了。这个项目往外扩展也很有意思。深度强化学习里常用的策略网络和价值网络本质就是MLP或者CNN行为采样时需要在C环境模拟器里快速推理这时候你手上已经有了一套可用的张量类和全连接层实现。甚至LLM底层那些矩阵乘法、Attention里的KV Cache核心仍然是你熟悉的张量布局和内存管理只是规模更大、优化更狠。有了这个地基你再去看ONNX Runtime源码或者TensorRT的优化文档理解成本会低很多。最后再分享两个让我少踩坑的小习惯。第一个在推理引擎的main里加一个--check模式从Python端导出同一模型的一组中间层输出C逐层比对误差能到1e-5以内整个链路才算真的通。第二个模型文件头一定带版本号和每一层的shape不要只存裸的float数组模型调整后重新导出时C端如果发现shape对不上宁可报错退出也不要静默读错。我在实际项目里被这两种问题坑过不止一次现在这些校验已经是我所有部署代码的标配了。如果你准备动手写自己的第一个C推理引擎建议直接从Conv层开始跑通这一整条链路之后你会发现之前学的C基础和深度学习原理突然都串起来了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。