第4章 卷积层的C语言建模:标准3x3卷积的C实现、数据布局与内存访问模式
好,咱们进入实战环节。这一章我带你手写一个标准3x3卷积的C语言实现。别小看这个基础版本,它是一切优化的起点。我在项目里见过太多人一上来就搞复杂优化,结果连基本功能都跑不对——嗯,咱们先把地基打牢。
4.1 标准3x3卷积的C实现
先看代码,再讲门道。
// 标准3x3卷积,单通道,无padding,stride=1
void conv3x3_baseline(
float *input, // 输入特征图 [H][W]
float *kernel, // 卷积核 [3][3]
float *output, // 输出特征图 [H-2][W-2]
int H, int W // 输入高和宽
) {
int out_h = H - 2;
int out_w = W - 2;
for (int oh = 0; oh < out_h; oh++) {
for (int ow = 0; ow < out_w; ow++) {
float sum = 0.0f;
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int ih = oh + kh;
int iw = ow + kw;
sum += input[ih * W + iw] * kernel[kh * 3 + kw];
}
}
output[oh * out_w + ow] = sum;
}
}
}
这段代码逻辑很直白:四重循环,最内层做乘累加。但说实话,我第一次写卷积时犯了个低级错误——把输入和输出的索引搞反了。你想想看,输出像素(oh, ow)对应输入窗口的左上角是(oh, ow),不是(oh+1, ow+1)。
核心要点:输出坐标(oh, ow)与输入坐标(ih, iw)的关系是 ih = oh + kh, iw = ow + kw。这个偏移关系在后续所有优化中都会反复出现。
4.2 数据布局:行主序与内存连续性
上面代码里,我用了行主序(row-major)布局。说白了就是把二维数组按行展开成一维。比如一个4x4的矩阵,内存里存的是:
row0: [0][1][2][3]
row1: [4][5][6][7]
row2: [8][9][10][11]
row3: [12][13][14][15]
访问 input[ih * W + iw] 时,同一行内 iw 连续变化,内存地址就是连续的。这很友好,因为CPU缓存喜欢连续访问。但卷积核的访问呢?
kernel[kh * 3 + kw] 中,kh在外层循环,kw在内层。对于同一个输出像素,kh从0变到2,每次跳3个元素——这就不连续了。我在项目中遇到过这种情况:同样的算法,换个数据布局,性能差了一倍。
小技巧:如果卷积核很小(比如3x3),可以把核数据转置一下,让内层循环的kw连续变化。或者干脆把核展开成9个标量变量,直接硬编码。我经常用后者,简单粗暴。
4.3 内存访问模式:空间局部性分析
咱们来分析一下上面代码的内存访问模式。以输入特征图为例:
- 行内访问:对于固定oh和kh,ow从0到out_w-1,input[ih * W + iw] 中iw连续增加。这是顺序访问,缓存命中率很高。
- 行间访问:当kh从0变到1时,ih增加1,地址跳了W个元素。如果W比较大(比如224),这一跳可能就出了缓存行。
- 输出访问:output[oh * out_w + ow] 是顺序写,没问题。
为什么会这样?因为卷积本质上是一个滑动窗口操作。窗口在行内滑动时,数据局部性好;窗口换行时,局部性就差了。
注意:当输入特征图尺寸较大(比如224x224)且缓存较小时,行间跳转会频繁触发缓存缺失。我曾经在一个嵌入式项目里,就因为没注意这个,导致卷积耗时占了整个推理的80%。后来改用分块(tiling)策略,才把时间降下来。
4.4 多通道扩展:数据布局的挑战
实际卷积很少是单通道的。假设输入有C_in个通道,输出有C_out个通道,每个输出通道对应C_in个卷积核。数据布局就变成了四维:
// 输入: [C_in][H][W]
// 核: [C_out][C_in][3][3]
// 输出: [C_out][H-2][W-2]
访问模式变成:
for (int oc = 0; oc < C_out; oc++) {
for (int oh = 0; oh < out_h; oh++) {
for (int ow = 0; ow < out_w; ow++) {
float sum = 0.0f;
for (int ic = 0; ic < C_in; ic++) {
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int ih = oh + kh;
int iw = ow + kw;
sum += input[ic * H * W + ih * W + iw]
* kernel[oc * C_in * 9 + ic * 9 + kh * 3 + kw];
}
}
}
output[oc * out_h * out_w + oh * out_w + ow] = sum;
}
}
}
你看,ic循环在最内层,意味着对于同一个输出像素,要遍历所有输入通道。每个通道的访问又是独立的——这会导致缓存被反复冲刷。我个人习惯把通道循环提到外面,或者用im2col+GEMM的方式重新组织数据。
4.5 本章知识体系
下面这张图总结了标准卷积实现的核心逻辑和数据流:
这张图把数据流和循环结构串起来了。你看,输入和核的数据通过索引映射到乘累加单元,结果写回输出。循环顺序决定了内存访问的连续性——这是性能调优的起点。
4.6 小结与避坑指南
这一章我们做了三件事:写了标准3x3卷积的C代码,分析了行主序数据布局,讨论了内存访问模式。说白了,就是搞清楚数据怎么来、怎么算、怎么存。
最后分享几个我踩过的坑:
- 索引越界:输出尺寸是H-2和W-2,不是H和W。我刚开始写的时候忘了减2,结果数组越界,查了半天。
- 数据类型:用float没问题,但如果是int8量化,要注意溢出。我曾经在定点化时没做饱和处理,结果推理结果全错。
- 边界处理:上面代码没做padding。实际项目中padding很常见,记得在输入周围补零,或者单独处理边界像素。
我的建议:先把这个基础版本跑通,用随机数据验证正确性。然后再考虑优化。很多HLS工具也支持直接综合这种C代码,但性能往往不理想——这正是我们后续章节要解决的问题。
嗯,这一章就到这里。代码虽简单,但它是后续所有优化的参照基准。下一章我们会讨论如何用HLS优化这个卷积,到时候你会看到同样的算法,性能可以差出10倍。