4. 卷积运算的硬件实现:卷积计算原理、滑动窗口生成器、乘加树(MAC Array)设计
各位同学,今天我们来聊聊卷积运算的硬件实现。说实话,这是整个AI加速器最核心的部分。我在做第一个加速器项目时,就是在这个环节踩了不少坑,今天把这些经验分享给你们。
4.1 卷积计算原理——先搞懂数学在干什么
卷积运算,说白了就是“窗口滑动 + 对应相乘 + 全部相加”。你想想看,一个3x3的卷积核,在输入特征图上从左到右、从上到下地滑动,每停一次,就把窗口内的9个像素和卷积核的9个权重分别相乘,然后把这9个乘积加起来,得到一个输出值。
数学上可以写成:
Output[y][x] = Σ Σ Input[y+dy][x+dx] * Weight[dy][dx] + Bias
其中dy和dx遍历卷积核的行和列。嗯,这里要注意,实际硬件实现时,我们通常把Bias放在最后加,或者干脆在权重初始化时就融合进去。
我个人习惯把卷积拆成三个维度来看:
- 输入通道维度(C_in):每个通道独立做卷积,然后累加
- 输出通道维度(C_out):每个输出通道对应一组独立的卷积核
- 空间维度(H x W):就是窗口滑动的过程
我在项目中遇到过一个问题:很多人只关注计算本身,忽略了数据复用的重要性。其实卷积的硬件实现,本质就是在“计算”和“数据搬运”之间做权衡。
核心要点:卷积硬件加速的关键在于——如何让数据尽量待在片上,减少对外部存储器的访问。这决定了你的加速器能跑多快。
4.2 滑动窗口生成器——数据怎么喂给计算单元
滑动窗口生成器,名字听着高大上,其实干的就是一件事:把输入特征图的数据,按照卷积核的大小,一窗口一窗口地组织好,喂给后面的乘加树。
为什么需要专门的模块来做这个?因为数据在存储器里是线性排列的,而卷积需要的是二维窗口。你想想看,如果直接读,你得不停地计算地址偏移,效率极低。
我常用的方案是行缓冲器(Line Buffer)结构。以3x3卷积为例:
// 伪代码描述行缓冲器的工作流程
// 假设输入宽度为W,使用3个行缓冲器
LineBuffer[0] // 存储第y行数据
LineBuffer[1] // 存储第y+1行数据
LineBuffer[2] // 存储第y+2行数据
// 每个时钟周期,从3个行缓冲器中各取3个数据
// 组成3x3 = 9个数据,送给MAC阵列
Window[0..2] = {LineBuffer[0][x:x+2],
LineBuffer[1][x:x+2],
LineBuffer[2][x:x+2]}
这里有个细节:行缓冲器通常用双端口BRAM实现,一个端口写新数据,另一个端口读旧数据。我曾经在这个地方吃过亏——单端口BRAM读写冲突,导致窗口数据错位,调试了整整两天才找到原因。
实战技巧:行缓冲器的深度要等于输入特征图的宽度。如果图像宽度不是2的幂次,记得用寄存器做边界处理,别让地址越界。
对于stride > 1的情况,滑动窗口生成器需要做“跳读”。比如stride=2,每两个像素才取一个窗口。这时候行缓冲器的更新节奏也要跟着变,否则数据会乱。
4.3 乘加树(MAC Array)设计——计算的核心引擎
乘加树,英文叫MAC Array(Multiply-Accumulate Array),是整个加速器的“心脏”。它的任务很简单:把滑动窗口送来的数据,和对应的权重,做乘加运算。
一个基本的MAC单元长这样:
// 一个MAC单元的RTL描述(简化版)
module mac_unit #(
parameter DATA_WIDTH = 8
)(
input logic [DATA_WIDTH-1:0] data_in,
input logic [DATA_WIDTH-1:0] weight_in,
input logic clk,
input logic rst_n,
input logic acc_clear, // 累加清零信号
output logic [2*DATA_WIDTH+7:0] result // 防止溢出
);
logic [2*DATA_WIDTH-1:0] product;
logic [2*DATA_WIDTH+7:0] accumulator;
assign product = data_in * weight_in;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n)
accumulator <= '0;
else if (acc_clear)
accumulator <= product; // 新窗口的第一个乘积累加
else
accumulator <= accumulator + product;
end
assign result = accumulator;
endmodule
嗯,这里要注意:累加器的位宽一定要留够余量。比如8位数据乘8位权重,乘积是16位,如果卷积核是3x3=9个点,累加结果最大可能到16+4=20位。我建议再多留几位,防止多个通道累加时溢出。
实际设计中,我们会把多个MAC单元排列成阵列。常见的结构有:
- 一维MAC阵列:一次处理一个输出通道的所有空间位置
- 二维MAC阵列:同时处理多个输出通道和多个空间位置
- 脉动阵列(Systolic Array):数据像流水一样在阵列中传递,效率最高
我个人比较推荐脉动阵列,因为它的数据复用率最高。以Google TPU为例,用的就是256x256的脉动阵列。当然,咱们做FPGA实现,资源有限,一般用16x16或32x32的阵列就差不多了。
避坑指南:我曾经设计过一个32x32的MAC阵列,仿真时一切正常,上板后功耗直接超标。后来发现是组合逻辑路径太长,导致翻转率过高。解决办法是在MAC单元之间插入流水线寄存器,每做一次乘加就寄存一次。虽然增加了latency,但频率能跑上去,功耗反而降了。
4.4 整体架构与数据流
把上面三个部分串起来,一个完整的卷积硬件加速器就出来了。我画了一张结构图,方便大家理解:
数据流是这样的:外部存储器先把输入特征图和权重加载到片上BRAM。滑动窗口生成器从输入缓冲中读取数据,拼成窗口后送给MAC阵列。同时权重缓冲把对应的权重也送过去。MAC阵列做完乘加后,结果写回输出缓冲,最后再写回外部存储器。
整个过程中,控制单元负责调度一切——什么时候加载数据,什么时候开始计算,什么时候写回结果。说白了,它就是个“交通警察”。
设计要点总结:
- 滑动窗口生成器用行缓冲器实现,注意边界处理和stride逻辑
- MAC单元要留够累加位宽,防止溢出
- 脉动阵列的数据复用率最高,但要注意流水线平衡
- 控制单元的状态机要处理好“数据加载”和“计算”的流水
好了,这一章的内容就到这里。卷积的硬件实现,说白了就是“数据怎么搬、怎么算、怎么存”这三个问题。搞懂了这些,后面的池化、全连接层实现就水到渠成了。
个人建议:初学者可以先从3x3卷积、单通道、单输出通道开始实现,跑通了再扩展到多通道。别一上来就想搞大阵列,容易把自己绕晕。
公众号:蓝海资料掘金营,微信deep3321