4、卷积算子硬件加速(一):行缓冲器设计、滑动窗口生成逻辑、乘加树架构

各位同学,今天咱们来啃一块硬骨头——卷积算子的硬件加速。说实话,我在做嵌入式AI加速器之前,一直觉得卷积就是个矩阵乘法。直到第一次在FPGA上跑YOLO,发现数据搬运比计算还慢,才真正意识到:卷积加速的核心,其实是数据流的组织

这一讲,我们聚焦三个关键模块:行缓冲器(Line Buffer)滑动窗口生成逻辑乘加树(MAC Array)。这三个东西,说白了就是卷积加速器的“三驾马车”。

4.1 行缓冲器(Line Buffer)设计

先问大家一个问题:为什么需要行缓冲器?

你想想看,卷积核在图像上滑动时,每次需要取一个3x3或5x5的窗口。如果直接从DDR里读,一次读一个像素,那效率低得可怕。我早期做过一个项目,用AXI总线逐像素读取,结果带宽利用率不到5%。

行缓冲器的核心思想:用片上SRAM缓存若干行数据,让滑动窗口的生成变成“寄存器搬移”,而不是“内存读取”。

关键设计参数:

  • 深度:等于图像宽度(W)
  • 行数:等于卷积核高度(K_h)
  • 位宽:等于像素位宽(如8bit)

举个例子,对于一个3x3的卷积核,输入图像宽度为640:

// 行缓冲器结构示意
reg [7:0] line_buf [2:0][639:0];  // 3行,每行640个像素

// 数据更新逻辑(每个时钟周期)
always @(posedge clk) begin
    if (valid_in) begin
        line_buf[0] <= {line_buf[0][639:1], pixel_in};  // 第一行移位
        line_buf[1] <= line_buf[0];                     // 第二行从第一行取
        line_buf[2] <= line_buf[1];                     // 第三行从第二行取
    end
end

嗯,这里要注意:行缓冲器的更新是“流水线式”的。新像素进来,整行数据向右移位,同时上一行的数据“下沉”到下一行。我在项目中遇到过一个问题——如果图像宽度不是2的幂次,地址生成会变得很麻烦。我的建议是:尽量把图像宽度对齐到16或32的倍数,这样地址计算可以用移位代替乘法。

个人经验:行缓冲器的深度不要直接用变量,最好用parameter定义。我在一个项目里偷懒用了localparam,结果综合时工具报了一大堆“inferring latch”的警告。后来改成parameter,配合generate语句,问题就解决了。

4.2 滑动窗口生成逻辑

有了行缓冲器,下一步就是生成滑动窗口。说白了,就是从行缓冲器的三行数据里,同时取出9个像素,组成一个3x3的矩阵。

我习惯用寄存器阵列来实现这个逻辑。每个时钟周期,从行缓冲器的对应位置“快照”出9个值:

// 3x3滑动窗口生成
reg [7:0] window [0:2][0:2];  // 3x3窗口

// 每个时钟周期更新窗口内容
always @(posedge clk) begin
    // 从行缓冲器取数
    window[0][0] <= line_buf[0][col];      // 第一行当前列
    window[0][1] <= line_buf[0][col+1];    // 第一行下一列
    window[0][2] <= line_buf[0][col+2];    // 第一行下下列
    
    window[1][0] <= line_buf[1][col];      // 第二行
    window[1][1] <= line_buf[1][col+1];
    window[1][2] <= line_buf[1][col+2];
    
    window[2][0] <= line_buf[2][col];      // 第三行
    window[2][1] <= line_buf[2][col+1];
    window[2][2] <= line_buf[2][col+2];
end

你可能会问:为什么不用RAM而是用寄存器?

原因很简单——速度。滑动窗口需要在一个时钟周期内同时读出9个像素,RAM的读端口通常只有1-2个,根本不够用。寄存器阵列可以做到“全并行读取”。

避坑指南:我曾经在一个项目中,把窗口大小设成了7x7,结果寄存器阵列消耗了49个寄存器,加上行缓冲器的存储,片上资源直接爆了。后来改用“分时复用”的策略——先读4行,再读3行,虽然延迟增加了,但资源节省了40%。

4.3 乘加树(MAC Array)架构

窗口数据准备好了,接下来就是计算。卷积的本质是:窗口内的每个像素,与卷积核的对应权重相乘,然后累加

乘加树,就是用来并行完成这个操作的。一个3x3的卷积核,需要9个乘法器和1个加法树:

// 3x3乘加树
reg [15:0] mult_out [0:8];  // 9个乘法结果
reg [15:0] acc;             // 累加结果

// 并行乘法
always @(*) begin
    mult_out[0] = window[0][0] * weight[0][0];
    mult_out[1] = window[0][1] * weight[0][1];
    // ... 以此类推
    mult_out[8] = window[2][2] * weight[2][2];
end

// 加法树(三级流水)
always @(posedge clk) begin
    // 第一级:两两相加
    sum_1 <= mult_out[0] + mult_out[1];
    sum_2 <= mult_out[2] + mult_out[3];
    // ...
    // 第二级:继续相加
    // 第三级:得到最终结果
    acc <= final_sum;
end

这里有个关键点:加法树需要流水线化。如果直接用组合逻辑做9个数相加,路径延迟会非常大,跑不到高频。我一般会做2-3级流水,具体级数取决于时钟频率和工艺。

乘加树设计要点:

参数 说明 我的建议
乘法器数量 等于卷积核大小(K_h * K_w) 3x3用9个,5x5用25个
加法树级数 log2(乘法器数量) 9个乘法器用4级加法树
流水线级数 加法树级数 + 1 3x3建议3级流水
输出位宽 输入位宽 + log2(乘法器数量) 8bit输入,9个乘法器,输出12bit

我记得有一次,为了追求极致性能,我把乘加树做成了全流水,每个时钟周期都能输出一个结果。结果综合后时序不收敛,因为加法树的最后一级路径太长了。后来我在加法树中间插了一级寄存器,频率从200MHz提到了350MHz。

4.4 三模块的协同工作

行缓冲器、滑动窗口、乘加树,这三个模块是流水线式协同的:

  1. 第1拍:新像素进入行缓冲器,数据移位
  2. 第2拍:从行缓冲器取出窗口数据,送入寄存器阵列
  3. 第3-5拍:乘加树完成计算,输出结果

说白了,整个加速器就像一个数据流水线。每个时钟周期,都有新的像素进来,同时有新的结果出去。只要流水线不被打断,吞吐率就是1个像素/时钟周期。

个人习惯:我一般会在行缓冲器的输出端加一个FIFO,用来缓冲窗口数据。这样做的好处是,当乘加树因为某些原因(比如权重更新)需要暂停时,行缓冲器可以继续工作,不会造成数据丢失。

4.5 实际项目中的权衡

讲到这里,你可能会觉得:这设计挺简单的啊。但实际项目中,有很多权衡要做:

  • 行缓冲器 vs 双缓冲:如果图像很大(比如4K),行缓冲器会消耗大量BRAM。我建议用双缓冲方案——两个行缓冲器交替工作,一个在读,一个在写。
  • 乘加树 vs 乘加单元复用:对于大卷积核(如7x7),全并行乘加树会消耗大量DSP。可以考虑用“分时复用”的方式,比如用9个乘加单元分时处理49个乘法。
  • 流水线深度 vs 延迟:流水线越深,频率越高,但延迟也越大。对于实时性要求高的场景(如自动驾驶),延迟不能太大。

嗯,最后说一句:没有最好的设计,只有最合适的设计。我在做加速器时,经常要跟算法团队吵架——他们想要更大的卷积核,我想要更小的资源消耗。最终妥协的结果是:3x3用全并行,5x5用部分并行,7x7以上用串行。

下一讲,我们会深入讨论多通道卷积的硬件加速,以及如何利用数据复用来进一步提升性能。到时候见!