第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 本章知识体系

下面这张图总结了标准卷积实现的核心逻辑和数据流:

标准3x3卷积数据流与内存访问模式 输入特征图 [H][W] 行主序 连续行内访问 行间跳跃W步 卷积核 [3][3] 行主序 kh循环跳3元素 kw循环连续访问 乘累加 sum += in * ker 输出特征图 [H-2][W-2] 顺序写访问 oh*out_w+ow ih*W+iw kh*3+kw oh*out_w+ow 循环嵌套顺序 oh → ow → kh → kw (最内层做乘累加) 注意:ic循环在多通道时位于kh/kw外层或内层,影响缓存行为

这张图把数据流和循环结构串起来了。你看,输入和核的数据通过索引映射到乘累加单元,结果写回输出。循环顺序决定了内存访问的连续性——这是性能调优的起点。

4.6 小结与避坑指南

这一章我们做了三件事:写了标准3x3卷积的C代码,分析了行主序数据布局,讨论了内存访问模式。说白了,就是搞清楚数据怎么来、怎么算、怎么存。

最后分享几个我踩过的坑:

  • 索引越界:输出尺寸是H-2和W-2,不是H和W。我刚开始写的时候忘了减2,结果数组越界,查了半天。
  • 数据类型:用float没问题,但如果是int8量化,要注意溢出。我曾经在定点化时没做饱和处理,结果推理结果全错。
  • 边界处理:上面代码没做padding。实际项目中padding很常见,记得在输入周围补零,或者单独处理边界像素。

我的建议:先把这个基础版本跑通,用随机数据验证正确性。然后再考虑优化。很多HLS工具也支持直接综合这种C代码,但性能往往不理想——这正是我们后续章节要解决的问题。

嗯,这一章就到这里。代码虽简单,但它是后续所有优化的参照基准。下一章我们会讨论如何用HLS优化这个卷积,到时候你会看到同样的算法,性能可以差出10倍。

可可精品资料库