1. 项目概述:为什么要在现代C++里手写卷积?这不是在“造轮子”
我带过不少刚从Python深度学习框架转过来的工程师,他们第一次看到用C++手写2D卷积时,第一反应几乎都是:“PyTorch和TensorFlow不是现成的吗?还手写?图啥?”——这问题问得特别实在,也特别关键。但答案不是“为了炫技”,而是为了真正理解那个被封装在 nn.Conv2d 背后、每天调用上千次却从未细看的黑箱到底在做什么。 卷积不是魔法,它是一组确定的、可推演的、有物理意义的数学操作;而C++,是唯一能让你把每个内存字节、每次浮点运算、每一块缓存行都攥在手里的语言。 这个项目标题里的“from Scratch”(从零开始),指的不是从零发明算法,而是从零构建对计算本质的掌控感。你不需要成为编译器专家,但必须清楚地知道:当 input.block(i, j, k, k) 被调用时,Eigen究竟在内存里做了什么拷贝?当 cwiseProduct().sum() 执行时,SIMD指令是否真的被触发?当padding为1时,边界上那些“不存在”的像素,程序到底是用0填充、还是镜像反射、还是循环取值?这些细节,在Python里是框架替你决定的;在C++里,是你自己写的逻辑。关键词“Artificial Intelligence”在这里不是空泛的标签,它指向一个具体事实:所有AI模型的底层算力基石,最终都落在C/C++实现的BLAS库、CUDA内核和推理引擎上。你写的这段卷积代码,和NVIDIA cuDNN里某个kernel的逻辑结构,本质上是同源的。所以,这不是在重复造轮子,而是在亲手锻造一把理解整个AI工业链的解剖刀。适合谁来读?如果你是正在学《数字图像处理》的学生,这段代码能让你把课本上的公式变成可调试的变量;如果你是嵌入式AI工程师,你需要知道如何在没有GPU的MCU上部署轻量卷积;如果你是框架开发者,你需要明白autograd反向传播时,这个前向卷积的梯度是如何逐层回传的。它不假设你精通模板元编程,但要求你愿意盯着一行 for(int i = 0; i < rows; ++i) ,去想清楚 i 的取值范围为什么是 input.rows() - kernel.rows() + 1 ,而不是别的数。
2. 卷积的数学本质与工程实现:从信号处理到图像特征提取
2.1 卷积的原始定义与现代机器学习的语义迁移
卷积(Convolution)这个词,最早出现在19世纪的信号处理领域,它的数学定义是一个积分运算: (f * g)(t) = ∫ f(τ)g(t-τ) dτ 。这个式子描述的是:一个输入信号 f ,经过一个系统响应 g (比如一个滤波器)后,输出是什么。这里的 * 不是乘法,而是“卷积算子”。把它搬到离散的数字世界,积分就变成了求和: (f * g)[n] = Σ f[m]g[n-m] 。再进一步,当我们处理一张二维图像时,输入 I 是一个矩阵,卷积核 K 也是一个矩阵,输出 F (Feature Map)同样是矩阵。此时的离散卷积公式就变成了: F[i][j] = Σₘ Σₙ I[i+m][j+n] * K[m][n] 。注意,这里 m 和 n 的索引范围,完全由卷积核 K 的尺寸决定。这个公式,就是所有后续代码的“宪法”。但在机器学习语境下,我们给它赋予了全新的语义: I 不再是“电压随时间变化的波形”,而是“图像的像素强度分布”; K 不再是“电路的频率响应曲线”,而是“可学习的特征探测器”; F 也不再是“滤波后的音频信号”,而是“高维特征空间中的激活响应”。一个3×3的Sobel核 Gx = [[-1,0,1],[-2,0,2],[-1,0,1]] ,在信号处理中是用来检测一维信号的边缘突变;在CNN里,它被初始化为网络的第一层权重,然后通过反向传播,自动调整成最适合识别猫耳朵纹理的模式。这种语义的迁移,正是深度学习的魔力所在——它把一个固定的数学工具,变成了一个可训练的、数据驱动的参数化函数。所以,当你在C++里写 result(i, j) = sum; 时,你不仅在计算一个数值,更是在构建一个未来可以被梯度更新的计算图节点。
2.2 Valid、Same、Full三种卷积模式的物理含义与尺寸推导
在实际工程中,我们绝不会只用一种卷积模式。它们的区别,本质上是对“边界如何处理”这一问题的不同回答,而每种回答都对应着不同的应用场景和计算开销。
-
Valid Convolution(有效卷积) :这是最“诚实”的模式。它严格遵守数学定义,只在卷积核能完全覆盖输入区域时才进行计算。如果输入是
m×n,卷积核是k×k,那么输出尺寸就是(m-k+1) × (n-k+1)。为什么是+1?因为想象一下,一个3×3的核在6×6的图上滑动:它第一个位置覆盖第0-2行、0-2列;最后一个位置覆盖第4-6行、4-6列(索引从0开始)。行方向上有6-3+1=4个起始位置(0,1,2,3),列方向同理。所以输出是4×4。这种模式会不断缩小特征图尺寸,对于深层网络,几层下来可能就只剩下一个点,因此通常只用于浅层或特定任务。 -
Same Convolution(同尺寸卷积) :这是最常用的模式。目标是让输出尺寸和输入尺寸完全一致。要达到这个目的,就必须在输入四周“补”上一圈或多圈像素。补多少?设padding为
p,则输出尺寸为(m + 2p - k + 1) × (n + 2p - k + 1)。令其等于m × n,解方程得p = (k-1)/2。所以,当k是奇数(如3,5,7)时,p是整数,可以完美实现same padding。这也是为什么深度学习中几乎所有的卷积核尺寸都是奇数——它保证了对称padding的可行性。在代码里,p=1对应3×3核,p=2对应5×5核。 -
Full Convolution(全卷积) :这种模式追求“最大覆盖”,卷积核的中心可以从输入矩阵的任意位置开始,包括那些只有部分重叠的位置。此时,输出尺寸是
(m+k-1) × (n+k-1)。它在某些信号处理场景(如互相关)中有用,但在CNN中极少使用,因为会引入大量由零填充带来的无意义响应。
这三种模式的选择,不是拍脑袋决定的。比如,在一个目标检测模型中,你希望最后的特征图能精确对应到原图的每一个像素区域(用于生成anchor box),那你必须用same padding;而在一个图像超分辨率任务中,你可能需要full convolution来捕获更广域的上下文信息。理解它们的尺寸公式,是避免在搭建网络时出现“尺寸不匹配”错误的第一道防线。
2.3 从数学公式到C++代码: block() 操作背后的内存真相
原始文章中那行 input.block(i, j, kernel_rows, kernel_cols) ,看起来简洁优雅,但它掩盖了一个关键的性能事实: block() 是一个 视图(View) ,而不是一个 拷贝(Copy) 。这是Eigen库设计的精妙之处,也是C++区别于Python的核心优势。在Python的NumPy中, input[i:i+k, j:j+k] 默认会创建一个新的数组副本,占用额外内存;而在Eigen中, block() 返回的是一个 Block<Matrix> 对象,它内部只存储了指向原矩阵 input 内存起始地址的指针,以及 i,j,k,k 这几个偏移量。当你调用 .cwiseProduct(kernel).sum() 时,Eigen的表达式模板(Expression Templates)会在编译期将整个计算链路优化成一个高效的循环,直接在原内存上操作,避免了任何中间数组的分配。你可以把它想象成一个“内存切片器”,它不移动数据,只给你一个“窗口”,让你透过这个窗口去看数据。这就是为什么 Convolution2D_v2 版本能省掉 padded 矩阵的内存分配——它根本不需要把padding后的数据“做出来”,只需要在计算时,当 i,j 滑动到边界时,动态地计算出当前窗口在原 input 矩阵中实际有效的 [input_i, input_j]


425

被折叠的 条评论
为什么被折叠?



