1. 系列定位与第十一篇的取舍
写到第十一篇,这个系列已经不再是入门普及的阶段了。前面十篇里,我们从C++的基础语法、内存管理、标准库容器一路讲到张量数据结构的搭建、自动求导引擎的实现、全连接神经网络的训练,甚至还在第十篇里手写过一个基于梯度下降的多层感知机并且让它成功在MNIST上跑到了95%以上的准确率。如果你是从第一篇追到这里的老读者,应该已经形成了一套自己的认知:深度学习框架本质上是"数据结构+算子库+自动求导"三件套的组合,C++写深度学习一点都不玄乎,核心是把数据流和计算图组织好。
第十一篇我决定换个方向——不继续堆训练代码,而是专注做推理部署这一件事。原因很简单:在真实业务里,模型训练出来只是第一步,把它跑在用户的机器上、跑在边缘设备上、跑在别人的C++项目里,这才是绝大多数C++工程师真正会遇到的场景。训练你用Python的PyTorch顺手写完就完事了,但部署这条链路从模型导出、算子适配、内存优化到精度校验,每一步都在考验C++功底。所以这一篇我用一个完整的实战案例来展开:从零手写一个CNN推理引擎,不依赖任何第三方深度学习库,纯C++实现卷积、池化、全连接、Softmax这些算子,然后加载一份训练好的权重参数,完成对灰度图像的分类任务。
这个项目本身是个通用骨架。例子我选的是手写数字识别,因为MNIST数据集公开、权重可以自己训练导出、类别只有10个,便于验证每一层的输出形状和数值对不对。但骨架一旦搭好,换到其他场景(比如恶意软件图像化识别、口腔X光片分类、工业缺陷检测)只是换数据和权重文件的事。引擎层面无需改动,这正是自己做推理引擎最大的价值所在:可定制、可裁剪、不背黑盒包袱。
为了让这一篇既延续系列的技术主线,又不至于让老读者觉得重复,我在选材上做了三个明确取舍。
第一,不重复训练逻辑。训练循环、损失函数、反向传播在第九、第十篇已经讲透了,这一篇只在加载权重时说明如何把训练好的参数导出为二进制文件,重心放在前向推理的算子上。
第二,不依赖现有框架。不用OpenCV做图像预处理(看懂行的都懂,OpenCV那套依赖链在嵌入式环境里能让人崩溃),而是自己写一个极简的BMP图像读取器,只支持8位灰度BMP,够用且完全可控。
第三,性能不是第一目标,正确性是第一目标。很多帖子一上来就谈SIMD、谈NPU调度,但如果你连卷积的im2col都没实现过,谈优化就是空中楼阁。这一篇先把功能链路走通,最后单独用一小节讲我实测下来的性能瓶颈在哪儿,以及哪些优化手段性价比最高,但不展开写死,留到后续篇目再继续深入。
2. CNN推理引擎整体架构拆解
2.1 引擎需要具备哪些核心模块
动手写代码之前,先得把整个引擎的模块图画在脑子里。我见过不少新手直接写一个巨大的predict()函数,把卷积、池化、全连接、Softmax全揉在一起,跑通了一个样例就宣称自己"实现了CNN"。这种写法的问题在换模型时立刻暴露:网络结构稍微改一改,整个函数就得推翻重写,排查问题更是无从下手。
我更推荐的方案是:把引擎拆成四个独立的层——张量存储层、算子层、网络结构层、数据读写层。
张量存储层负责管理多维数组的内存布局和基本代数操作,是整个引擎的地基。算子层实现卷积、ReLU、最大池化、全连接、Softmax这些前向计算单元,每个算子只做一件事,输入一个张量输出一个张量。网络结构层描述模型的宏观拓扑——每一层是什么类型、卷积核尺寸多少、步长多少、权重在文件里的偏移量是多少,这一层用配置结构体来描述,这样引擎代码和模型结构就解耦了。数据读写层负责把BMP图像读入内存、把二进制权重文件解析成张量。
这种分层方式带来的直接好处是调试效率大幅提升。梯度消失、权重NaN这类问题在此不讨论,因为推理没有反向过程,但算子的数值错误会一直向后传导:卷积层如果算错一个像素,池化层会把这个误差继续放大,最后Softmax输出的置信度完全是乱的。分层之后,每一层的输入输出都可以单独落盘检查,没几行代码就能定位是哪个算子出了问题。
2.2 为什么选用灰度图像作为切入点
引擎的第一个版本我把输入限定为单通道灰度图,这是有意为之的取舍。原因不复杂:单通道I/O读起来简单——一个字节就是像素值,不需要处理RGB到灰度的转换、不需要纠结BGR和RGB的内存顺序;卷积运算也直观——输入通道数为1,套公式时少一层循环,方便对照理论手算结果。等灰度图链路完全跑通,再改多通道其实是"在输入张量上多加一维"的事,算子核心代码一行都不用动。
前期简化后期扩展,这是工程上最划算的做法。假如一上来就支持RGB三通道,调试时你根本分不清是自己算错还是通道顺序写错。而单通道跑通了,至少能证明卷积求和、权值映射、步长滑动这些核心逻辑没问题。
2.3 权重格式的设计思路
部署场景里,权重文件格式是个容易被轻视、实则极为关键的设计点。我的做法是自定义一个极简的二进制格式,头部固定64字节用于存放元信息,接着按层顺序平铺所有的float数据。元信息里记录魔数、版本号、层数、输入高度、输入宽度、类别数,以及一个层信息表——每层的类型枚举、输入输出通道数、卷积核尺寸、权重数组偏移量。
这么设计的好处是读取逻辑简单且抗干扰能力强。逐个字段按二进制读入,用魔数和版本号校验文件是否匹配,一旦损坏立刻能察觉。程序里定义对应的结构体,fread一次性读入头部,再按偏移量定位每一层的权重,完全不需要准备复杂的序列化逻辑。训练端(我用PyTorch训练完)导出的方式也简单:把state_dict里的张量按序memcpy进文件即可。
这里也要提醒一个在跨环境移植权重文件时容易踩的坑:float的二进制表示在绝大多数主流平台上都是IEEE 754标准,所以常规的x86、ARM平台之间直接拷贝权重文件没问题。但字节序(大小端)在不同的嵌入式架构上可能不同,如果你拿到一份来自大端设备的权重文件,需要在加载时做一个字节序转换。我的方案是在文件头部加一个标志字节,加载时比对当前平台的字节序,不一致就跑一遍字节反转,成本可忽略。
3. 核心算子C++实现详解
3.1 张量类的设计与实现
所有算子都建立在张量之上,所以先把这个数据结构定义好。我选择用五维数组来统一表示神经网络中的各种数据:N * C * H * W是常规的特征图布局,全连接层的权重可以看成N * C * 1 * 1,这样五维里有两维恒为1,内存布局统一,算子接口也变得简洁——所有算子的输入输出都是同一个张量类型,不需要为不同形状准备不同函数。
看一下核心代码:
class Tensor { public: Tensor() = default; Tensor(const std::vector<int>& dims) { shapes_ = dims; size_ = 1; for (int d : dims) size_ *= d; data_.resize(size_, 0.0f); } float* data() { return data_.data(); } const float* data() const { return data_.data(); } int size() const { return size_; } int dims() const { return shapes_.size(); } int shape(int i) const { return shapes_[i]; } float& at(int n, int c, int h, int w) { int idx = ((n * shapes_[1] + c) * shapes_[2] + h) * shapes_[3] + w; return data_[idx]; } const float& at(int n, int c, int h, int w) const { int idx = ((n * shapes_[1] + c) * shapes_[2] + h) * shapes_[3] + w; return data_[idx]; } private: std::vector<int> shapes_; int size_ = 0; std::vector<float> data_; };初始代码里我加了更多重载,比如at(int h, int w)直接定位二维矩阵元素,还加了fill、copyFrom这些辅助方法,篇幅所限不展开。核心思想就一句话:内存是一维的,多维索引只是帮你算出线性偏移量的语法糖。
3.2 卷积层的实现:先走通朴素循环
卷积是整个CNN里计算量最大的部分,也是很多C++新人最头疼的部分。理论公式一句话能说完:输出特征图的某个位置等于输入特征图对应感受野与卷积核的加权和再加上偏置。但落到代码里,维度循环的顺序、边界条件的处理都有讲究。
我给的第一个版本是教科书式的六重循环——遍历输出batch、输出通道、输出高度、输出宽度、输入通道、卷积核尺寸(高度和宽度合并成两维循环)。代码:
void conv2d(const Tensor& input, const Tensor& weight, const Tensor& bias, Tensor& output, int stride, int pad) { int N = input.shape(0), C_in = input.shape(1); int H_in = input.shape(2), W_in = input.shape(3); int C_out = weight.shape(0); int K = weight.shape(2); // 假设卷积核是正方形 int H_out = (H_in + 2 * pad - K) / stride + 1; int W_out = (W_in + 2 * pad - K) / stride + 1; for (int n = 0; n < N; ++n) { for (int co = 0; co < C_out; ++co) { for (int ho = 0; ho < H_out; ++ho) { for (int wo = 0; wo < W_out; ++wo) { float sum = bias.data()[co]; for (int ci = 0; ci < C_in; ++ci) { for (int kh = 0; kh < K; ++kh) { for (int kw = 0; kw < K; ++kw) { int hi = ho * stride - pad + kh; int wi = wo * stride - pad + kw; if (hi >= 0 && hi < H_in && wi >= 0 && wi < W_in) { sum += input.at(n, ci, hi, wi) * weight.at(co, ci, kh, kw); } } } } output.at(n, co, ho, wo) = sum; } } } } }这个实现朴素到极致,但它有一个巨大的好处:每个像素的计算逻辑和数学公式完全对应,你可以在纸上手算一个3x3输入、2x2卷积核的示例,然后用代码跑一遍对答案。我在调试这个函数时,用的验证方法就是在input和weight里填已知的小数,打印输出,跟手算结果比对,一次就通过了,几乎没费力气。
这个版本在MNIST这种28x28小图上跑一次完整推理(2个卷积+2个池化+2个全连接)大概需要几十毫秒量级,完全可接受。如果拿224x224的输入图,性能就会掉到秒级,这时候才需要做优化。所以新手务必先跑通朴素版,确认逻辑没问题,再谈优化。
3.3 池化层与激活函数
池化我实现的是最大池化,窗口大小2x2、步长2。这也是LeNet-5风格的经典配置,输出尺寸直接减半。实现思路比卷积简单得多:每个输出像素对应窗口内四(或更少,遇到边界时)个输入像素的最大值。
void maxPool2d(const Tensor& input, Tensor& output, int poolSize, int stride) { int N = input.shape(0), C = input.shape(1); int H_in = input.shape(2), W_in = input.shape(3); int H_out = (H_in - poolSize) / stride + 1; int W_out = (W_in - poolSize) / stride + 1; for (int n = 0; n < N; ++n) { for (int c = 0; c < C; ++c) { for (int ho = 0; ho < H_out; ++ho) { for (int wo = 0; wo < W_out; ++wo) { float maxVal = -FLT_MAX; for (int ph = 0; ph < poolSize; ++ph) { for (int pw = 0; pw < poolSize; ++pw) { float val = input.at(n, c, ho * stride + ph, wo * stride + pw); if (val > maxVal) maxVal = val; } } output.at(n, c, ho, wo) = maxVal; } } } } }激活函数ReLU就更直接了——逐元素把负值置零。这几乎是所有CNN部署里最便宜的一个算子,但我仍然建议写成独立函数而不是在卷积里顺手做掉。原因在于网络结构可能调整:有的层后面跟ReLU,有的层后面不跟,独立算子方便配置组合。
3.4 全连接层与Softmax:从特征到概率
卷积和池化把图像压缩成特征向量,全连接层再对这个向量做线性变换,最后Softmax输出10个类别的概率分布。全连接层的实现实际就是矩阵乘法加偏置。我把四维特征图先展平成向量,权重矩阵的行数等于输出维度,列数等于输入维度,然后逐行做点积:
void fullyConnected(const Tensor& input, const Tensor& weight, const Tensor& bias, Tensor& output) { int inFeatures = input.size(); int outFeatures = output.size(); for (int o = 0; o < outFeatures; ++o) { float sum = bias.data()[o]; for (int i = 0; i < inFeatures; ++i) { sum += input.data()[i] * weight.at(o, i); } output.data()[o] = sum; } }Softmax我采用"先减去最大值再求指数"的标准写法。这一步新手经常忽略,直接对原始logits求exp,结果就是数值溢出——如果某个logit是100,exp(100)直接就是inf。正确做法是:
void softmax(const Tensor& input, Tensor& output) { int numClasses = input.size(); float maxVal = -FLT_MAX; for (int i = 0; i < numClasses; ++i) { if (input.data()[i] > maxVal) maxVal = input.data()[i]; } float sum = 0.0f; for (int i = 0; i < numClasses; ++i) { output.data()[i] = std::exp(input.data()[i] - maxVal); sum += output.data()[i]; } for (int i = 0; i < numClasses; ++i) { output.data()[i] /= sum; } }减最大值这个操作不会改变概率分布(指数函数的平移性质),但能让所有指数运算的输入落在非正区间,从源头上杜绝溢出。这是深度学习工程里一个教科书级的小技巧,但就是有无数人在部署时栽在这一步上。
4. 完整推理流程:从图像到分类结果
4.1 BMP图像的读取与预处理
我这个引擎不依赖OpenCV,图形读取这块用纯C++实现。BMP格式本身不复杂:文件头14字节、信息头40字节,之后才是像素数据。灰度BMP每个像素占1字节,8位色深,正好对应我们单通道特征图的输入要求。
读图的代码逻辑各级约定俗成,但有一个细节必须单独提醒:BMP的行像素数据按4字节对齐,也就是每行多余的部分会用0填充,比如宽度为28像素的灰度图,每行实际占用不是28字节而是28字节——28本身是4的倍数,所以没问题,但如果你换到宽度为30的图,每行就要占32字节,读取时必须跳过这2个填充字节,否则图是歪的。这个坑我在第一次移植到非MNIST尺寸图上时踩过,调试半天才顿悟是行对齐的问题。
读取完成之后,像素要做归一化:原始像素是0到255的整数,需要转成0到1之间的float。同时,MNIST的数据分布存在全局均值和标准差(训练集统计出来大约是均值0.1307、标准差0.3081),推理时要做同样标准的标准化,否则效果会明显变差。这个步骤看起来不起眼,但很多从零手写引擎的人在这个地方翻车,结果模型输出的置信度全在0.1附近徘徊,找半天也找不到原因。
4.2 权重文件的导出与加载
权重文件我选择在PyTorch侧导出。训练一个简单的LeNet-5变体(两个卷积层加两个全连接),然后用下面这段脚本把参数平铺写入文件:
import torch model = LeNet5() model.load_state_dict(torch.load("lenet_mnist.pt")) f = open("lenet_mnist.bin", "wb") import struct for name, param in model.state_dict().items(): arr = param.detach().cpu().numpy().flatten() f.write(arr.tobytes()) f.close()注意state_dict()的遍历顺序在PyTorch里是按照模型定义层的注册顺序来的,所以在C++侧加载时,必须按同一个顺序读取。我的做法是在C++端用一个结构体数组描述各层的权重偏移量,结构体里写明层类型、权重形状、在文件中的起始字节位置。这种做法要求模型结构变更时同步更新C++代码里的配置,显然不够灵活,但对这个项目来说是恰到好处的简单可靠。如果想彻底动态化,最实用的改进是在权重文件头部写入每层的形状信息,C++侧读取头部后自动分配张量——这是我后续打算做的扩展。
4.3 网络结构配置与推理主循环
网络结构我用一个简单的配置结构体数组来表示:
enum LayerType { CONV, POOL, RELU, FC, SOFTMAX }; struct LayerConfig { LayerType type; int in_channels, out_channels; int kernel_size, stride, pad; int input_h, input_w; int output_units; long weight_offset; // 权重在文件中的偏移(字节) }; std::vector<LayerConfig> netConfig = { {CONV, 1, 8, 5, 1, 2, 28, 28, 0, 0}, // conv1: 1->8, 5x5, pad2, 输出28x28 {RELU, 0, 0, 0, 0, 0, 28, 28, 0, 0}, {POOL, 8, 8, 2, 2, 0, 28, 28, 0, 0}, // pool1: 2x2, 输出14x14 {CONV, 8, 16, 5, 1, 2, 14, 14, 0, 0}, // conv2: 8->16, 输出14x14 {RELU, 0, 0, 0, 0, 0, 14, 14, 0, 0}, {POOL, 16, 16, 2, 2, 0, 14, 14, 0, 0}, // pool2: 输出7x7 {FC, 16, 0, 0, 0, 0, 7, 7, 120, 0}, // fc1: 16*7*7 -> 120 {FC, 120, 0, 0, 0, 0, 0, 0, 84, 0}, // fc2: 120 -> 84 {FC, 84, 0, 0, 0, 0, 0, 0, 10, 0}, // fc3: 84 -> 10 {SOFTMAX, 0, 0, 0, 0, 0, 0, 0, 10, 0} };这个配置结构用起来有一点笨拙,每个字段都要手填,但好处是全模型结构一眼就能看全。主循环就简单了——按配置逐层调用对应的算子函数,每个算子的输出张量作为下个算子的输入,中间用临时变量保存:
Tensor current(input_dims); // 填充图像数据... for (size_t i = 0; i < netConfig.size(); ++i) { const auto& cfg = netConfig[i]; switch (cfg.type) { case CONV: { Tensor weight({cfg.out_channels, cfg.in_channels, cfg.kernel_size, cfg.kernel_size}); Tensor bias({cfg.out_channels, 1, 1, 1}); loadWeight(weight, bias, cfg.weight_offset, wf); Tensor next({1, cfg.out_channels, cfg.input_h, cfg.input_w}); conv2d(current, weight, bias, next, cfg.stride, cfg.pad); current = std::move(next); break; } case POOL: { int h_out = (cfg.input_h - cfg.kernel_size) / cfg.stride + 1; int w_out = (cfg.input_w - cfg.kernel_size) / cfg.stride + 1; Tensor next({1, cfg.in_channels, h_out, w_out}); maxPool2d(current, next, cfg.kernel_size, cfg.stride); current = std::move(next); break; } // RELU、FC、SOFTMAX 类似... } } // current 里就是最后的概率分布这个主循环非常干净,所有算子的实现都在独立函数里,主循环只负责任务调度。如果你换一个模型,只需要改netConfig数组和权重文件,算子代码完全复用。这就是架构设计带来的长期收益。
5. 性能优化与排查实录
5.1 朴素实现跑通后,哪些优化性价比最高
把完整前向推理跑通之后,我在自己的机器上测了一轮耗时。CPU是常规桌面级,MNIST单张图片从BMP读取到Softmax输出,总耗时大概40毫秒。这个数字放在实时场景里勉强够用,但一点都不优雅。我分别试了三种优化手段,实测数据如下:
第一种是卷积的内存布局优化。把输入特征图按通道拆开,每个通道单独用连续内存存储(NHWC布局改为NCHW线性化并配合通道指针数组),减少at()函数里的乘法计算。这个优化实现成本低,收益约20%,代码可读性损失可接受。
第二种是开O2编译优化并启用向量化。GCC的-O2级别的自动向量化对六重循环的卷积帮助明显,实测提升约35%。前提是你代码里没有at()这种带边界检查的写法,因为它会阻碍自动向量化。我的做法是内部循环全部改为裸指针访问,把索引计算前移。
第三种是im2col+GEMM。这是工业级推理引擎最常用的卷积加速方案——把卷积运算重构成矩阵乘法,然后调用高度优化的BLAS库(比如OpenBLAS或Intel MKL)来做核心计算。我完整实现过一版,确实有效,但代码复杂度直接翻倍,而且依赖外部库。我的结论是:如果你的目标设备性能够用,朴素实现+O2完全够;如果性能不够,优先考虑im2col;如果还不够,再考虑SIMD手写汇编或调用NPU/GPU库。一步一步来,不要一开始就上火箭。
5.2 推理结果全错时的排查路径
这个章节放在实操记录里最有价值的部分,就是分享故障排查的路径。我在整个项目里踩过几个明确的坑,列出来给后来人当避雷指南。
第一个坑:权重文件字节序和内存布局不匹配。前面提过,PyTorch导出的权重是四维张量,按(out_channels, in_channels, kh, kw)的顺序排列,C++读取时必须严格按照这个顺序填入权重张量,否则卷积核就乱了。如果你发现推理结果完全随机,首先检查权重的填充顺序,把第一个卷积核的9个数打印出来和PyTorch里对比一下,立刻能发现问题。
第二个坑:输入归一化忘做或做反。如果你直接拿0到255的原始像素送入网络,而网络训练时用的是0到1归一化加标准化,那么第一层卷积的输出就会偏移,整个结果的置信度分布异常。排查方法是打印第一个卷积输出特征图的数值范围,正常情况下应该在个位数级别,如果出现几百的数值,基本可以断定输入预处理出了问题。
第三个坑:Softmax前面的logits正常,但输出概率全为0或全为1。这是我第二次实现Softmax时犯过的错——对exp的结果求平均而不是求和。概率之和必须等于1,这是Softmax层最直接的验证手段。建议你在写完这个算子后单独跑一个单测,输入[1.0, 2.0, 3.0],手算一下输出对不对,再进行全链路集成。
第四个坑:BMP的行对齐。前面提过但值得再强调一次,很多非正方形尺寸的灰度图都有填充字节,忘了跳过就会让图像扭曲变形。排查办法是读取后用程序写一个调色盘BMP出来人工观察,一眼就知道有没有歪。
5.3 精度对比:C++推理结果与PyTorch输出一致性校验
功能上跑通之后,严谨的工程师都会做一步——数值一致性校验。做法很简单:对同一张测试图片,分别在PyTorch里跑一次前向推理,在自己的C++引擎里跑一次,对比Softmax输出的10个概率值。在我的实现里,两者最大绝对误差控制在1e-5量级,这说明算子实现精度达标。
这个误差来源主要是浮点运算顺序的微小不同:比如PyTorch的卷积可能用Winograd或FFT算法,而我的朴素实现是直接乘加,舍入误差不一样,但数量级在可接受范围。如果你的误差达到1e-2以上,就有问题了——大概率是权重读取顺序错、预处理不一致,或者哪一层算子实现有bug。
校验这一步必须做,否则你永远无法确定引擎是否真正可靠。很多项目死在"看起来能出结果"的阶段,因为你不知道结果为什么是那个数字,正确性无从谈起。做一次全量校验,你才可以放心地把这个引擎集成进业务代码。
6. 性能优化之外的工程化建议
引擎能跑、结果能对上,这只是第一步。真要在项目里用,还需要解决几个工程化问题。
第一个是内存管理策略。推理引擎的生命周期通常是初始化一次、推理多次,所以张量最好在初始化阶段就分配好并复用,避免每次推理都动态申请内存。我的做法是引擎持有多个临时张量成员变量,推理时循环复用。实测下来,内存分配和释放的耗时几乎降为零,而且避免了堆碎片化。如果你追求更极端的性能,甚至可以用内存池管理所有张量,但中小型项目不建议过度设计。
第二个是错误处理机制。现在的代码里大量使用直接下标访问和assert,如果权重文件损坏或输入图片尺寸不对,程序会直接崩溃。工程项目里我更推荐在关键入口加防御性检查:读取权重文件后校验魔数和版本号,输入图片尺寸与网络配置不匹配时返回明确的错误码。这些检查的开销微乎其微,但排障体验天差地别。
第三个是多线程推理。如果你的应用需要同时处理多路图像(比如摄像头多路流),一个自然的优化方案是对batch维做并行——不同图像的推理互不依赖,可以把N维拆到多线程里。C++11的std::async或者std::thread配合原子计数器就够用了。要注意的是,如果多线程共享同一个引擎实例,算子内部的临时张量会冲突,稳妥做法是每个线程持有一份自己的中间张量,权重分量共享只读。
第四个是模型热替换。业务场景里经常会更新模型参数,如果不能重启服务就更新权重,就需要在加载权重时把所有参数读入内存后原子地替换引擎的权重指针。实现起来不复杂,但对于线上服务而言,这个能力算得上一项基本要求。
7. 从第十一篇到未来:这个引擎还能往哪里走
最后分享一点我的个人体会。手写一个推理引擎,最大价值不一定是性能有多强、功能有多全,而是它逼迫你把深度学习前向计算的所有细节都亲手过了一遍。你会彻底搞懂卷积核怎么跟特征图对齐、权重在内存里到底怎么排布、Softmax那个"减最大值"的步骤为什么必不可少——这些知识在读框架源码时也能得到,但亲手实现一遍的感受是完全不一样的。
现在这个引擎的边界我也很清楚:它只支持推理,不支持训练;只支持有限的算子集合,没有动态shape;没有SIMD极致优化,性能离工业级还有距离。但正因为骨架是清晰的,这些边界反而成了后续扩展的路线图。下一篇文章我大概率会写im2col卷积加速的具体实现,把性能瓶颈彻底打通;再往后可能会补充支持RGB输入和多分支结构,让它真正具备落地能力。
如果你是从第一篇一路读到现在的,完全可以在这个引擎的基础上继续往下做:把权重文件的格式改成带shape信息的自描述格式,加上简单JSON解析做动态网络配置,再用性能分析工具找出卷积函数的热点,尝试用手写SIMD指令改写核心循环。每一步都不难,但每走一步,你对C++深度学习这条技术栈的理解就更深一层。希望这个系列能一直陪你把这条路走完。