资讯详情 C++手写CNN:从NCHW布局到反向传播的完整实现指南
📅 2026/10/12 13:50:17
简介面向具备一定 C 基础、对卷积神经网络感兴趣的初学者该实现围绕图像分类场景演示卷积层、池化层、全连接层与激活层等核心组件的工作机制适合希望深入理解深度学习底层实现、摆脱“调包”式开发的学习者。压缩包共 29 个文件主体是 13 个 .h 头文件与 13 个 .cpp 源文件分别承担接口声明、层实现和训练流程控制另附 CMakeLists.txt、README 说明文档便于在 Ubuntu 16.04 下安装 Eigen3 后直接配置生成可执行程序并快速定位各网络层对应源码。资源整体仅 24KB代码精简无冗余能够逐文件跟踪前向传播与反向传播的梯度计算路径学习时可以顺着 Net 类和其他实现类理解数据如何在各层间流动。实现中包含卷积、池化、ReLU/Sigmoid 等激活函数、全连接及 Softmax 输出层并覆盖权重初始化、批量归一化、Dropout 和数据预处理等关键技巧同时提供 Cifar、Minst 数据加载示例方便用真实数据集验证模型。目前已有 3025 人学习对于想从 C 角度掌握 CNN 内部结构、理解各层数学推演的读者是一份结构清晰、便于二次开发的参考工程。1. C手写CNN从零搭一个能训练的网络需要哪几步先说结论卷积神经网络的简单实现C不需要几万行代码几百行纯标准库代码就能跑完前向、反向、参数更新一整套训练流程。它的价值不在性能而在于把每一层的计算细节全部摊开没有框架帮你管理梯度所有中间量都在你自己的代码里出错时能一层一层追。适合刚啃完卷积推导、想动手验证的人也适合需要把推理逻辑写进C管线的工程师。我习惯把整个实现拆成数据布局、前向、反向、训练循环四块下面按这个顺序逐一落地。2. 先定数据布局NCHW的Tensor与层接口这样设计2.1 组件清单手写CNN需要哪些层手写实现第一步不是写卷积而是先确定网络由哪些组件组成每个组件负责什么。常见做法是把网络抽象成一个层列表每一层只需要对外暴露三个能力前向算输出、反向算梯度、更新参数。组件前向职责反向职责是否含参数卷积层滑窗求和加偏置计算输入梯度和权重梯度权重、偏置池化层局部降采样取窗口最大值把梯度填回最大值的位置无全连接层矩阵乘加偏置计算输入梯度和权重梯度权重、偏置激活层 ReLU非线性变换抹掉负数用前向掩码过滤梯度无Softmax交叉熵输出概率分布直接返回输出与标签的差无// layer.hpp #pragma once #include vector // 每一层的接口只有三个方法对应训练流程的三个时间点 class Layer { public: virtual ~Layer() default; // 前向把输入数据变成输出数据并缓存反向需要的中间量 virtual std::vectorfloat forward(const std::vectorfloat input) 0; // 反向接收来自上一层的梯度计算并返回本层的输入梯度 virtual std::vectorfloat backward(const std::vectorfloat grad) 0; // 更新参数learning_rate 由外层训练循环统一控制 virtual void update(float learning_rate) 0; };逻辑说明这里用纯虚基类目的是把“网络长什么样”和“网络怎么训练”拆开。训练循环只看到 forward、backward、update 三个动作至于中间是卷积、池化还是全连接完全不关心。写加法层、平均值池化层时只需要继承这个基类并实现三个方法网络主体代码一行不用改。参数说明backward 的返回值是“本层输入应该收到的梯度”而不是“本层参数的梯度”。参数的梯度保存在各层内部由 update 消费。这个接口划分是手写实现里最容易歪的地方——把返回值和参数梯度混在一起会导致梯度传播错位。2.2 为什么在C实现里优先选NCHW布局图像数据在内存里是一个长数组但同一张图有样本、通道、高度、宽度四个维度排列顺序直接决定了下标怎么算。C手写实现里最常见的两种排列是 NCHW 和 NHWC我第一版实现固定用 NCHW。维度NCHWNHWC内存顺序样本N、通道C、高H、宽W样本N、高H、宽W、通道C卷积时找通道通道是连续的一大块偏移直观通道间要跨大步长索引换算多一步手写阶段排错打印某个通道时起点容易算调试时打印某个像素的所有通道值更连续优化阶段需要做通道维度的对齐对SIMD更友好但前提是先踩通索引// tensor.hpp // 一个极简Tensor只保存数据和形状信息 class Tensor { public: Tensor(std::vectorint shape) : shape_(shape), data_(Product(shape), 0.0f) {} int C() const { return shape_[1]; } int H() const { return shape_[2]; } int W() const { return shape_[3]; } // NCHW布局(n, c, h, w) 位置的内存偏移 int Offset(int n, int c, int h, int w) const { return ((n * C() c) * H() h) * W() w; } std::vectorfloat data_; std::vectorint shape_; };逻辑说明Offset 是整个实现索引正确的根基。手写卷积时最常见的错是漏掉某一维给定一个尺寸为 (1,3,32,32) 的输入误把 h 对应的步长当成 W 而不是 W 乘以 C结果训练 loss 看起来在下降实际索引已经越界。选定 NCHW 后所有层包括全连接层的展平操作都要统一按这个偏移公式来算。参数说明这里用 std::vector 而不是裸指针是为了让生命周期管理交给标准库避免手动释放内存的负担。第一版实现里Tensor 不需要重载运算符只需要一个能算偏移和能存数据的容器。等你把 NCHW 的索引彻底理顺了再考虑换成裸指针加引用计数来省拷贝时间。2.3 层与层之间怎么传数据缓存怎么存反向传播需要每一层前向的输入和输出。前向过程中数据被逐层改写如果不存中间量反向时只能从第一层重算。手写实现里的方案很简单每层前向结束后把输出复制进一个缓存数组。// train_batch.cpp // 一个batch的前向同时把所有层的输出缓存下来 std::vectorstd::vectorfloat caches; caches.push_back(input); for (auto layer : layers) { input layer-forward(input); caches.push_back(input); }逻辑说明caches 的大小等于层数加一。反向时按倒序取caches.back() 是最后一层的输出用来计算输出层梯度每向上走一层就 pop 掉一个缓存。这个顺序如果反了梯度就会错位训练会以一种“能降但很慢”的假象出现。手写阶段不要省这块内存等逻辑稳定后再做精细化缓存只保留反向真正需要的输入和掩码。参数说明缓存数组本身不参与任何层的前向计算它只服务于反向。如果网络有10层batch size 是16每层输出是 1x16x16 的特征图缓存占用大约 16×16×16×4 字节乘以层数这个量级完全可接受。3. 前向传播逐层落地卷积、池化、全连接与Softmax的实现3.1 卷积层滑窗实现循环顺序与边界处理卷积输出尺寸由一个固定公式决定输出高等于 (输入高 2×pad − 卷积核高) / stride 1宽同理。pad1、kernel3、stride1 时输出尺寸与输入一致这是最常用的配置。// conv_forward.cpp // 最简滑窗卷积不做im2col刻意保留直观写法 void ConvForward(const std::vectorfloat input, const std::vectorfloat weight, const std::vectorfloat bias, std::vectorfloat output, int N, int C_in, int H_in, int W_in, int C_out, int K, int pad, int stride) { 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 oc 0; oc C_out; oc) { for (int oh 0; oh H_out; oh) { for (int ow 0; ow W_out; ow) { float sum bias[oc]; for (int ic 0; ic C_in; ic) { for (int kh 0; kh K; kh) { for (int kw 0; kw K; kw) { int ih oh * stride - pad kh; int iw ow * stride - pad kw; // 越界位置按零填充跳过不真的在内存里扩边 if (ih 0 || ih H_in || iw 0 || iw W_in) continue; int in_idx ((n * C_in ic) * H_in ih) * W_in iw; int w_idx ((oc * C_in ic) * K kh) * K kw; sum input[in_idx] * weight[w_idx]; } } } int out_idx ((n * C_out oc) * H_out oh) * W_out ow; output[out_idx] sum; } } } } }逻辑说明循环顺序刻意设为“样本 → 输出通道 → 输出位置 → 输入通道 → 卷积核内坐标”这样每个输出像素完整累加后再写内存排错时可以在任何位置打印中间值。性能上它确实不够快但第一版的目标是把逻辑做对。等跑通 im2col 优化时这个函数会被矩阵乘法替代但验证阶段的直观写法不要提前扔掉。参数说明pad 和 stride 必须与反向传播里用到的完全一致。零填充的判断是“越界即跳过”这和常用的深度学习框架里 padding 的结果一致但负坐标判断不能省。stride 为 2 时输出尺寸几乎减半很多网络用 stride2 的卷积替代池化参数行为完全等价。3.2 最大池化与ReLU前向简单反向要留后手最大池化没有参数前向就是在每个窗口里挑最大值。但反向必须知道“输出像素的梯度给到输入哪个位置”所以前向要同时保存一个 mask记录每个窗口最大值在输入中的下标。// pooling_forward.cpp // 最大池化窗口大小 pool_size步长与窗口相同 void MaxPoolForward(const std::vectorfloat input, std::vectorfloat output, std::vectorint mask, int N, int C, int H_in, int W_in, int pool_size) { int H_out H_in / pool_size; int W_out W_in / pool_size; output.assign(N * C * H_out * W_out, 0.0f); mask.resize(N * C * H_out * W_out); for (int n 0; n N; n) { for (int c 0; c C; c) { for (int oh 0; oh H_out; oh) { for (int ow 0; ow W_out; ow) { int max_val INT_MIN; int max_idx 0; for (int kh 0; kh pool_size; kh) { for (int kw 0; kw pool_size; kw) { int ih oh * pool_size kh; int iw ow * pool_size kw; int idx ((n * C c) * H_in ih) * W_in iw; if (input[idx] max_val) { max_val input[idx]; max_idx idx; } } } int out_idx ((n * C c) * H_out oh) * W_out ow; output[out_idx] max_val; mask[out_idx] max_idx; } } } } }逻辑说明mask 是关键。它保存的是输入数组的绝对下标而不是窗口内的相对坐标。反向时拿着这个下标直接做加法即可少一次坐标换算。如果保存相对坐标反向还要根据当前输出的位置重新反推绝对下标多一层出错风险。ReLU 的前向与反向同源前向是dst[i] src[i] 0 ? src[i] : 0.0f反向要把前向时“是否大于0”的结果作为掩码乘到梯度上。注意反向不能用“当前数值是否大于0”代替因为前向之后权重会被更新同一位置的数值已经变化掩码必须来自前向那一刻。参数说明pool_size 常用取 2步长与窗口一致输出尺寸直接减半。池化没有可学习参数所以更新参数时它什么都不用做但 forward 和 backward 必须成对实现。3.3 全连接层与Softmax矩阵乘加偏置数值稳定少一步都不行全连接层把输入特征展平成一个向量然后做矩阵乘法加偏置。它的前向代码很短但权重矩阵的存储布局容易把人绕晕。// fc_forward.cpp // 全连接层前向output input * weight^T bias // weight 形状为 (num_units, in_features)按行存储 void FullyConnectedForward(const std::vectorfloat input, const std::vectorfloat weight, const std::vectorfloat bias, std::vectorfloat output) { int in_features weight.size() / output.size(); for (int i 0; i (int)output.size(); i) { float sum bias[i]; for (int j 0; j in_features; j) { // weight 第 i 行第 j 列在下标 i * in_features j 处 sum input[j] * weight[i * in_features j]; } output[i] sum; } }逻辑说明weight 按“行是输出神经元、列是输入特征”存储因此第 i 个输出必须读取第 i 行。不少初写实现默认按列存权重结果矩阵关系整体转置训练好的模型拿去推理时精度掉得莫名其妙。这一点在初始化权重时就要定死前后统一。// softmax_forward.cpp // Softmax先减最大值保证 exp 不溢出 void Softmax(std::vectorfloat output) { float max_val *std::max_element(output.begin(), output.end()); float sum 0.0f; for (float x : output) { x std::exp(x - max_val); sum x; } for (float x : output) x / sum; }逻辑说明减最大值这一步不能省。float 的 exp 在输入超过约 88 时会溢出为 inf网络输出层如果出现较大数值不减最大值的 Softmax 会算出 inf/inf结果直接变成 NaN。减去最大值不会改变概率分布只是把所有数平移到安全区间。这段代码依赖algorithm和cmath两个头文件。参数说明全连接层的偏置初始化为 0 即可权重初始化范围见第 5 章这里的关键是 output 向量的大小必须提前设置成神经元数量代码里用output.size()推断输入特征数调用前要保证 output 尺寸正确。4. 反向传播与参数更新输出层到卷积层的逐层梯度代码4.1 从输出层往回数梯度在每层做了什么反向传播的起点是损失函数对输出层输出的梯度。如果损失用交叉熵且输出层是 Softmax那么输出层梯度直接是 output 减去 one-hot 标签不需要计算雅可比矩阵这是最有名的简化。代价是一旦损失换成均方误差要多乘一个 Softmax 的导数项代码结构会变。// backward_tail.cpp // 输出层梯度Softmax 交叉熵 的梯度恰好是 output - label for (int i 0; i (int)output.size(); i) grad_output[i] output[i] - label[i]; // ReLU 反向用前向掩码过滤梯度 for (int i 0; i (int)grad_input.size(); i) grad_input[i] grad_output[i] * (forward_cache[i] 0 ? 1.0f : 0.0f); // 最大池化反向把梯度累加到 mask 记录的输入位置 std::fill(grad_in.begin(), grad_in.end(), 0.0f); for (int i 0; i (int)grad_pooled.size(); i) grad_in[mask[i]] grad_pooled[i];逻辑说明池化反向的“散布”是反直觉点梯度不是铺开分配到窗口所有像素而是只给最大值位置其余像素梯度为 0。实现时 grad_in 必须先清零再累加否则不同输出位置可能映射到同一输入像素梯度会被叠加两次甚至多次。参数说明这里的 label 如果是类别索引而不是 one-hot 向量要先转换转换错误会表现为 loss 能下降但精度始终上不去。卷积层的反向是整段代码里最容易出错的部分它没有新的数学推导但展开成代码后循环层级很多// conv_backward.cpp // 卷积反向共享前向的循环骨架 // grad_out 是上一层传来的梯度grad_in 是返回给输入层的梯度 // grad_w 会在参数更新阶段被使用 void ConvBackward(const std::vectorfloat grad_out, const std::vectorfloat input, const std::vectorfloat weight, std::vectorfloat grad_in, std::vectorfloat grad_w, int N, int C_in, int H_in, int W_in, int C_out, int K, int pad, int stride) { std::fill(grad_in.begin(), grad_in.end(), 0.0f); std::fill(grad_w.begin(), grad_w.end(), 0.0f); 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 oc 0; oc C_out; oc) { for (int oh 0; oh H_out; oh) { for (int ow 0; ow W_out; ow) { float grad grad_out[((n * C_out oc) * H_out oh) * W_out ow]; for (int ic 0; ic C_in; ic) { for (int kh 0; kh K; kh) { for (int kw 0; kw K; kw) { int ih oh * stride - pad kh; int iw ow * stride - pad kw; if (ih 0 || ih H_in || iw 0 || iw W_in) continue; int in_idx ((n * C_in ic) * H_in ih) * W_in iw; int w_idx ((oc * C_in ic) * K kh) * K kw; // 两个核心累加输入梯度和权重梯度 grad_in[in_idx] grad * weight[w_idx]; grad_w[w_idx] grad * input[in_idx]; } } } } } } } }逻辑说明grad_in 这行理解成“输入像素 i 参与了哪些输出像素的计算就把那些输出梯度按权重系数累加回来”grad_w 这行理解成“某个权重 w 参与了哪些输出像素的计算就把那些输出梯度按输入像素值累加回来”。两行代码结构很像一个用 weight[w_idx] 做系数一个用 input[in_idx] 做系数。自查方法是grad_w 的累加里如果出现了 weight 本身那一定写错了。参数说明反向的 pad、stride 必须与前向完全一致。前向里越界位置用 continue 跳过反向也必须有同样的判断逻辑只是梯度累加时不要忘记用当前输入像素的值乘以梯度。实际调试中“前向跳过了越界、反向却没跳过”会表现为边界像素梯度莫名变大或变小。4.2 参数更新顺序与一个batch的训练循环训练循环的骨架是先前向、再反向、最后统一更新参数。这里的顺序纪律比任何单独一层都重要。// train_batch.cpp void TrainOneBatch(std::vectorLayer* layers, const std::vectorfloat batch_input, const std::vectorint batch_label) { // 1. 前向并缓存每一层输出 std::vectorstd::vectorfloat caches; caches.push_back(batch_input); for (auto* layer : layers) caches.push_back(layer-forward(caches.back())); // 2. 输出层梯度Softmax 交叉熵 std::vectorfloat grad ...; // 由输出与标签计算得到 // 3. 按倒序反向传播 for (auto it layers.rbegin(); it ! layers.rend(); it) grad (*it)-backward(grad); // 4. 所有梯度算完再统一更新参数 for (auto* layer : layers) layer-update(0.001f); }逻辑说明更新必须放在整个反向结束之后。如果每一层在 backward 末尾立刻更新自己的参数后续层在反向计算中会读到被改过的权重梯度就混入了“新旧参数交替”的状态大网络下会表现为 loss 抖动、收敛不稳。注意更新顺序是训练里最容易被忽略的纪律之一。宁可多写一个循环也不要在 backward 内部顺带 update。参数说明学习率 0.001 是手写实现里比较稳妥的起点。loss 下降很慢时可以放大到 0.01loss 震荡就降回 0.001。batch size 常见取 16 或 32一次只调一个超参数。每个 epoch 结束后用 std::shuffle 打乱训练样本顺序并固定随机种子保证可复现。5. 手写CNN避坑五个让我返工到怀疑人生的细节5.1 权重初始化不当训练loss纹丝不动现象迭代几百轮后 loss 在小范围震荡验证集精度稳定在随机猜测水平调学习率也没有本质变化。原因权重初始化范围太大让 ReLU 输入落在正饱和区之外产生大量死神经元初始化全 0 则所有神经元完全对称梯度方向也对称整个网络退化成一个线性模型。这个阶段的表现不是 loss 变成 NaN而是“怎么调都不动”。解决用均匀分布随机初始化范围取1/sqrt(fan_in)fan_in 是当前层的输入单元数。这个比例来自“尽量保持每层输出方差与输入一致”的推导不是凭空拍出来的。// init_weight.cpp // 均匀分布初始化scale 1/sqrt(fan_in) float scale std::sqrt(1.0f / fan_in); for (float w : weight) w (rand_uniform() * 2.0f - 1.0f) * scale;参数说明rand_uniform 返回 [0,1) 的均匀随机数这里映射到 [-scale, scale]。偏置固定初始化为 0。手写实现里最容易漏掉的是对每一层都执行这个初始化而不是只在构造网络时初始化一次。5.2 Loss跳成NaN且再也回不来现象训练到某一步 loss 打印出 nan之后所有日志全是 nan重新加载之前保存的参数也难以恢复。原因最常见两处。Softmax 没做减最大值exp 溢出产生 inf学习率过大参数更新一步迈出太远后续层梯度数值发散。数据里有异常像素值也可能诱发但手写小网络里前者是头号原因。解决Softmax 前先减最大值学习率调小一个量级再试。定位技巧在 loss 首次变成非有限值的那一步把每层输出打印出来数值异常的那一层一眼就能定位。另一条习惯是每轮训练检查 loss 是否有限发现 nan 立即保存现场退出不要继续迭代。5.3 池化层缓存与前向不一致训练完成后推理出错现象训练时 loss 正常下降收敛也不错但保存参数后用同一套代码推理输出结果与训练时对不上。原因池化的反向依赖前向保存的 mask。如果代码里存在两套“最大池化”实现一套给训练、一套给推理哪怕窗口大小相同索引规则也可能不一致。更隐蔽的情况是 mask 在反向之后被清空下一轮前向没有重新生成。解决训练与推理严格共用同一个前向函数mask 的生成和保存都放在 forward 内部。这样整个网络只有一个池化逻辑不存在两套实现漂移的问题。5.4 Tensor浅拷贝导致同一块内存被释放两次现象程序运行时偶发崩溃用内存检测工具跑一遍报告 double free 或堆越界。原因C 的默认拷贝构造函数是浅拷贝。Tensor 如果持有裸指针一次myTensor2 myTensor1会让两个对象的指针指向同一块内存析构时这块内存被释放两次。解决第一版就把 Tensor 内部的std::vectorfloat直接作为成员而不是float*。vector 的拷贝是深拷贝生命周期由标准库管理这是最简单的后悔药。如果为了性能坚持用裸指针至少禁用拷贝构造改成显式的移动语义否则后续调试成本远高于省下的拷贝开销。5.5 数据没归一化收敛慢到怀疑人生现象loss 持续下降但速度极慢把学习率调大又会震荡最终精度能上去但需要数倍于预期的迭代次数。原因输入像素值范围是 0 到 255而权重初始化为接近 0 的小随机数。第一层的梯度被大数值输入放大量级远大于后续层梯度整个网络的有效学习率被拖向保守区间。解决进网络前把所有像素除以 255缩放到 0 到 1。// normalize.cpp // 输入预处理图像数据进网络前先整体缩放 for (float x : input) x / 255.0f;参数说明除以 255 是快速方案。更规范的做法是用训练集统计出均值和方差做标准化推理时也必须用训练集的统计量不能用当前 batch 的统计量否则训练与推理的分布不一致精度会失真。6. 用梯度检查验证实现再谈优化该从哪里下手6.1 梯度检查的原理与最小可复现脚本反向传播最容易犯的错是“看起来对实际在微妙的地方错了”。梯度检查是专门针对这种问题的验证手段用数值方法近似计算梯度再与反向传播算出的梯度做对比。数学定义是数值梯度约等于 (loss(wε) − loss(w−ε)) / (2ε)。// grad_check.cpp // 对比某个权重的数值梯度与反向传播梯度的相对误差 double CheckWeight(Net net, const std::vectorfloat input, const std::vectorfloat label, Layer layer, int weight_index) { float eps 1e-4f; float w_orig layer.weight(weight_index); layer.set_weight(weight_index, w_orig eps); float loss_plus net.ForwardLoss(input, label); layer.set_weight(weight_index, w_orig - eps); float loss_minus net.ForwardLoss(input, label); layer.set_weight(weight_index, w_orig); double numerical_grad (loss_plus - loss_minus) / (2.0 * eps); net.Backward(input, label); double analytic_grad layer.weight_grad(weight_index); return std::fabs(numerical_grad - analytic_grad) / std::max(1.0, std::fabs(numerical_grad) std::fabs(analytic_grad)); }逻辑说明eps 取 1e-4 是经验值。eps 太小会让浮点舍入误差占主导太大又偏离真实梯度。相对误差小于 1e-4 基本可信小于 1e-6 说明这一项非常精确。检查时不用全量权重每层随机挑 2 到 3 个位置即可全量跑会慢到不可用。参数说明检查时固定随机种子、固定输入数据否则每次 shuffle 顺序不同结果无法比较。把梯度检查做成一个独立程序用一个极小的网络跑一遍误差超过 1e-3 先停下修代码不要带着疑虑继续往上加层。6.2 后续优化im2col与多线程的发力方向网络跑通后性能优化有两块价值最大。一是 im2col把卷积的每个窗口拉成一行所有窗口拼成一个大矩阵卷积就变成一次大规模矩阵乘法访存更规整还能复用已有的矩阵乘实现代价是输入数据会产生多份重复拷贝内存占用增加。二是多线程在卷积的输出通道维度上并行每个线程负责一部分输出通道权重是只读共享输入只读每个输出通道写自己那块内存天然没有竞争。我现在的习惯是每新增一个算子先把梯度检查跑成绿色再继续。这个动作救过我很多次最终发现多数问题不是算法推导失误而是坐标换算、索引偏移这类低级错误。希望这篇笔记的代码和避坑清单能帮你更快跑通自己的版本把时间花在真正该花的地方。本文还有配套的精品资源点击获取