4. 卷积计算单元设计:乘累加器(MAC)设计、并行计算架构、流水线设计技巧
各位同学,今天我们来啃一块硬骨头——卷积计算单元。说白了,这就是整个加速器的心脏。你前面搭的DMA、缓存、控制逻辑,最终都是为了伺候好这个模块。我做了这么多年FPGA加速,可以负责任地告诉你:卷积计算单元设计得好不好,直接决定了你芯片的算力天花板在哪里。
4.1 乘累加器(MAC)——最基础的积木
先看最基础的东西。卷积运算的本质是什么?就是乘法和加法。一个3×3的卷积核,对输入特征图的一个窗口做运算,需要9次乘法和8次加法。我们把“一次乘法+一次加法”打包成一个单元,就叫MAC(Multiply-Accumulate)。
在FPGA里,MAC通常用DSP48E2来实现。Xilinx的7系列和UltraScale系列都内置了这个硬核。我个人习惯是:能用硬核就别用LUT搭乘法器。为什么?因为DSP48E2跑个500MHz轻轻松松,你用LUT搭一个16位乘法器,能跑到300MHz就算不错了。
核心要点:一个DSP48E2可以配置成一个25×18的乘法器,然后级联加法器。对于8位整数量化来说,绰绰有余。
来看一个简单的MAC代码示例:
module mac_unit #(
parameter DATA_WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire [DATA_WIDTH-1:0] data_in,
input wire [DATA_WIDTH-1:0] weight_in,
input wire acc_en, // 累加使能
output reg [2*DATA_WIDTH+7:0] result // 留足累加位宽
);
reg [2*DATA_WIDTH-1:0] mul_result;
reg [2*DATA_WIDTH+7:0] acc;
// 乘法阶段
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
mul_result <= 0;
else
mul_result <= data_in * weight_in;
end
// 累加阶段
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
acc <= 0;
else if (acc_en)
acc <= acc + mul_result;
else
acc <= mul_result; // 新窗口开始,重置累加
end
assign result = acc;
endmodule
这段代码看起来简单,但有个坑。我曾经在一个项目里,因为acc_en的时序没处理好,导致累加结果总是差一个时钟周期。排查了整整两天才发现——嗯,这里要注意:acc_en的拉高时机必须和乘法结果对齐。
4.2 并行计算架构——让DSP转起来
单个MAC太慢了。一个3×3卷积需要9个时钟周期才能算完一个输出像素。对于1080P的图像,这得算到猴年马月去?
所以我们要做并行。怎么做?两个维度:
- 输入通道并行:如果输入有32个通道,那就摆32个MAC,同时算
- 输出通道并行:如果输出有64个通道,那就摆64组MAC阵列
我常用的架构是“脉动阵列”(Systolic Array)。说白了,就是让数据像流水一样在阵列里流动,每个MAC只和邻居通信。这样做的好处是:布线短、频率高、扩展性好。
个人经验:我在做YOLOv3加速器时,用了16×16的脉动阵列。每个周期可以完成256次乘累加。配合DSP48E2的级联特性,跑到了450MHz。这个频率在FPGA上已经相当可观了。
下面我画了一张图,帮你理解脉动阵列的数据流:
你看这张图,红色箭头是特征图数据,从左往右流。绿色箭头是权重数据,从上往下流。每个MAC只做一件事:把当前的特征图数据和权重相乘,然后和左边传来的部分和相加,再传给右边的邻居。
这样做的好处是什么?你想想看,数据流是规则的,没有广播,没有复杂的交叉互联。在FPGA上,这种规则结构最容易跑出高频率。
4.3 流水线设计技巧——让每一级都忙起来
并行架构搭好了,但还有一个问题:时序。一个MAC从输入到输出,路径上有一个乘法器和一个加法器。如果数据位宽大一点,组合逻辑延迟可能超过一个时钟周期。
解决方案就是流水线。说白了,就是在关键路径上插入寄存器,把一个大组合逻辑拆成几个小段。
我一般会在三个地方插流水线:
- 乘法器输出后:乘法结果先寄存一拍,再送给加法器
- 加法器输出后:累加结果寄存一拍,再输出
- 数据输入处:输入的特征图和权重先寄存一拍,保证时序干净
注意:插入流水线会增加延迟(latency)。比如3级流水线,从输入到输出需要3个时钟周期。但好处是频率可以大幅提升。在卷积加速器里,我们通常不在乎几个周期的延迟,因为数据是连续流式处理的。
来看一个带流水线的MAC设计:
module pipelined_mac #(
parameter DATA_WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire [DATA_WIDTH-1:0] data_in,
input wire [DATA_WIDTH-1:0] weight_in,
input wire [2*DATA_WIDTH+7:0] partial_sum_in, // 来自左侧的部分和
output reg [2*DATA_WIDTH+7:0] result_out
);
// 流水线寄存器
reg [DATA_WIDTH-1:0] data_reg;
reg [DATA_WIDTH-1:0] weight_reg;
reg [2*DATA_WIDTH-1:0] mul_reg;
reg [2*DATA_WIDTH+7:0] sum_reg;
// 第1级:输入寄存
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
data_reg <= 0;
weight_reg <= 0;
end else begin
data_reg <= data_in;
weight_reg <= weight_in;
end
end
// 第2级:乘法 + 寄存
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
mul_reg <= 0;
else
mul_reg <= data_reg * weight_reg;
end
// 第3级:加法 + 寄存
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
sum_reg <= 0;
else
sum_reg <= mul_reg + partial_sum_in;
end
assign result_out = sum_reg;
endmodule
这段代码用了3级流水线。你可能会问:为什么partial_sum_in没有寄存?嗯,这里有个讲究。在脉动阵列里,部分和是从左边MAC传过来的,它本身已经经过了一级寄存。所以不需要再额外寄存,否则会多出一拍延迟。
避坑指南:我曾经在一个项目里,为了省资源,把流水线级数从3级减到了2级。结果时序分析报了一堆violation。后来老老实实改回3级,频率从350MHz直接飙到了480MHz。所以我的建议是:在DSP路径上,宁可多插一级流水线,也别省。
4.4 资源与性能的权衡
最后聊一下资源估算。假设我们要做一个16×16的脉动阵列,每个MAC用1个DSP48E2:
| 参数 | 数值 | 说明 |
|---|---|---|
| MAC数量 | 256 | 16×16阵列 |
| DSP48E2消耗 | 256个 | 每个MAC一个DSP |
| LUT消耗 | 约2000个 | 控制逻辑+数据通路 |
| BRAM消耗 | 约8-16个 | 权重缓存+行缓冲 |
| 理论算力 | 256 MAC/周期 | 每个周期完成256次乘累加 |
| 目标频率 | 400-500 MHz | 取决于布局布线 |
你看这个表,256个DSP在Xilinx的KU系列上大概占30%左右的资源。剩下的资源还可以放其他模块。我个人觉得,这个规模对于大多数边缘计算场景已经够用了。
好了,卷积计算单元的核心内容就这些。记住三个关键词:MAC是基础、并行是手段、流水线是保障。把这三点吃透了,你的加速器性能就不会差。
可可精品资料库