数据复用策略:卷积层的数据重用模式与行缓冲设计

卷积层的计算,说白了就是「滑动窗口」的重复操作。每次滑动,窗口内的数据大部分和上一次是重叠的。你想想看,如果每次计算都重新从DDR里把数据搬一遍,那带宽压力得多大?

我在做FINN部署时,遇到过最头疼的问题就是内存带宽瓶颈。明明计算单元在空转,数据却喂不上去。后来我意识到,问题不在计算,而在数据复用策略没做好。

卷积的数据重用模式

卷积层的数据重用,主要有三种模式。我个人习惯把它们叫做「行重用」、「列重用」和「通道重用」。

  • 行重用:同一行数据,在滑动窗口的横向移动中被反复使用。比如3x3卷积核,每向右滑动一列,新窗口只新增一列数据,其余两列都是上一轮用过的。
  • 列重用:不同行之间,上一行的数据在下一行计算时仍然有用。比如从第0行滑到第1行,第0行的部分数据会被第1行的窗口「踩」到。
  • 通道重用:多通道输入时,同一个像素点会被多个卷积核重复读取。这个在后续章节会细讲,今天先聚焦空间维度的重用。

嗯,这里要注意:行重用和列重用是滑动窗口最核心的优化点。如果你能把这两个做好,内存带宽至少能省掉60%以上。

行缓冲(Line Buffer)结构设计

行缓冲,说白了就是一块片上存储,专门用来缓存输入特征图的若干行数据。它的核心思想是:只存当前计算窗口需要的行,不存整张图

我记得第一次做行缓冲设计时,犯了个低级错误——我把整张特征图都存进了BRAM。结果呢?BRAM用光了,逻辑资源还剩一大半。后来我才明白,行缓冲只需要存K行(K是卷积核高度),再加上几行用于流水线。

举个例子,假设输入是32x32的特征图,卷积核是3x3。你不需要存32行,只需要存3行(或者4行,看设计)。每次计算窗口时,从这3行里取数据。窗口滑动后,新的一行进来,最旧的一行丢掉。这就是经典的「先进先出」策略。

行缓冲的典型结构:

  • 深度:等于特征图的宽度(比如32)
  • 高度:等于卷积核高度(比如3)
  • 每个存储单元:一个像素点的数据(比如8位或16位)

滑动窗口的本地存储优化

有了行缓冲,滑动窗口的数据读取就变成了「本地化」操作。每次计算时,从行缓冲里取一个KxK的窗口数据,而不是从DDR里读。

具体怎么做?我建议用双缓冲或者乒乓操作。一个缓冲在填充新数据,另一个缓冲在供计算单元读取。这样读写可以并行,不会互相等待。

// 伪代码示例:行缓冲的滑动窗口读取
// 假设特征图宽度为W,卷积核大小为K
// line_buf[K-1:0][W-1:0] 是行缓冲

for (row = 0; row < H - K + 1; row++) {
    for (col = 0; col < W - K + 1; col++) {
        // 从行缓冲中取出KxK窗口
        for (i = 0; i < K; i++) {
            for (j = 0; j < K; j++) {
                window[i][j] = line_buf[i][col + j];
            }
        }
        // 执行卷积计算
        result = conv(window, kernel);
    }
    // 滑动到下一行时,更新行缓冲
    // 新的一行数据写入line_buf[K-1],其余行下移
}

这段代码看起来简单,但实际硬件实现时有个坑:行缓冲的读写冲突。当你从行缓冲读数据时,新数据也在往里面写。如果读写地址重叠,就会读到错误数据。

我曾经踩过的坑:

有一次做3x3卷积的行缓冲,我用了单端口BRAM。结果仿真时数据一直不对,查了三天才发现是读写冲突。后来改成双端口BRAM,一个端口专门读,一个端口专门写,问题就解决了。所以,行缓冲一定要用双端口RAM,或者用寄存器阵列实现。

行缓冲的深度与宽度选择

行缓冲的深度(每行存储的像素数)一般等于特征图的宽度。但如果你用了padding,深度要加上左右padding的宽度。比如输入32x32,padding=1,那深度就是34。

宽度(存储的行数)取决于卷积核大小和你的流水线设计。最小是K行,但为了流水线不卡顿,我一般会多存1-2行。比如3x3卷积,我习惯用4行缓冲。这样在窗口滑动时,新行可以提前写入,不会阻塞计算。

卷积核大小 最小行缓冲行数 推荐行缓冲行数
3x3 3 4
5x5 5 6
7x7 7 8

你可能会问:多存一行会不会浪费资源?其实不会。多一行缓冲,可以让读写流水线更顺畅,整体吞吐量反而更高。我在项目中测试过,3行缓冲和4行缓冲相比,后者性能提升了约15%,而BRAM只多了不到5%。

行缓冲的硬件实现要点

在FPGA上实现行缓冲,有几种常见方式:

  • BRAM实现:适合大尺寸特征图。BRAM容量大,但读写有延迟(一般1-2个时钟周期)。需要配合流水线使用。
  • 寄存器阵列实现:适合小尺寸特征图(比如8x8或16x16)。寄存器零延迟,但占用大量LUT和FF。
  • 混合实现:我个人的偏好。用BRAM存整行数据,用寄存器存当前窗口的KxK数据。这样既省资源,又保证读取速度。

我的一个小技巧:

在行缓冲的写入端,可以用一个计数器来跟踪当前写入的行号。读取端用另一个计数器跟踪当前计算的行号。两个计数器之间的差值,就是行缓冲的「水位」。如果水位太低,说明写入跟不上读取,需要调整流水线节奏。

总结一下

数据复用策略的核心,就是让数据在片上多待一会儿,别急着往DDR里写。行缓冲是实现这个目标最经典的手段。你只需要记住三点:

  1. 行缓冲只存K行,不存整张图
  2. 用双端口RAM避免读写冲突
  3. 多存1-2行,让流水线更顺畅

下一章我会讲权重数据的复用策略。权重和特征图不一样,它的复用模式更偏向于「广播」而不是「滑动」。到时候我会结合具体的卷积核展开方式,给你讲讲怎么把权重也留在片上。