4. 卷积计算单元(PE)的微架构设计:乘累加阵列与数据流调度
好,咱们今天聊点硬核的。卷积加速器里最核心的模块,就是PE(Processing Element)阵列。说白了,它就是一堆乘累加器排成队,一起干活。你想想看,一个3x3的卷积核,要在特征图上滑来滑去,每个位置都要做9次乘法和8次加法。如果只有一个MAC单元,那得算到猴年马月去?
所以,我们需要一个阵列。但阵列怎么排?数据怎么喂?这就是微架构设计的精髓了。我个人习惯,在设计PE阵列前,先想清楚三个问题:数据重用、带宽匹配、控制复杂度。这三个点,任何一个没想明白,后面流片回来大概率要改版。
4.1 PE的基本单元:MAC与本地寄存器
每个PE,本质上就是一个乘累加器加上一点本地存储。我见过很多新手,一上来就把PE设计得特别复杂,又是双缓冲又是乒乓操作。其实没必要。对于卷积计算,PE的核心就三样东西:
- 乘法器:处理输入特征图(ifmap)和权重(weight)的乘法
- 累加器:把乘积累加起来,支持多周期累加
- 本地寄存器文件:存权重或者部分和(psum),减少对总线的访问
我在项目中遇到过一种情况:为了省面积,把乘法器位宽砍了一半。结果呢?精度不够,卷积结果全是噪声。后来老老实实换回全精度乘法器,面积虽然大了点,但功能一次过。嗯,这里要注意:PE的位宽选择,一定要和你的量化策略对齐。int8就是int8,别想着用int4去凑。
核心设计原则:PE的本地寄存器深度,决定了数据重用的能力。我建议至少能存下一整行特征图的数据,否则频繁从SRAM搬数据,功耗会很高。
4.2 乘累加阵列的拓扑结构
阵列怎么排?常见的有两种:脉动阵列(Systolic Array)和广播阵列(Broadcast Array)。我个人的经验是,脉动阵列适合规则卷积,广播阵列适合不规则或者稀疏卷积。
咱们先看脉动阵列。每个PE只和邻居通信,数据像流水一样在阵列里流动。这样做的好处是:布线简单、时钟频率高。缺点呢?数据流调度比较死板,一旦某个PE卡住了,整条流水线都得等。
广播阵列就不一样了。所有PE同时从总线上拿数据,各自算各自的。好处是灵活,坏处是总线带宽压力大。你想想看,如果阵列是8x8,一共64个PE,每个PE每周期都要读一个数,那总线得有多宽?
我做过一个项目,用的是混合架构:行内广播,行间脉动。同一行的PE共享输入数据,行与行之间用脉动方式传递部分和。这样既保证了数据重用,又控制了总线宽度。效果还不错,大家可以参考一下。
小技巧:设计阵列大小时,先算一下你的SRAM带宽能支持多少PE同时工作。带宽不够,阵列再大也是白搭。我曾经吃过这个亏,8x8的阵列,结果带宽只够喂饱4x4,剩下的PE都在空转。
4.3 数据流调度策略
数据流调度,说白了就是决定什么时候把什么数据送到哪个PE。这里有个经典问题:权重固定还是特征图固定?
我习惯把调度策略分成三种:
- 权重固定(Weight Stationary):权重加载到PE后就不动了,特征图流进来。适合权重复用度高的场景,比如全连接层。
- 特征图固定(Input Stationary):特征图留在PE里,权重流进来。适合特征图复用度高的场景,比如深度可分离卷积。
- 输出固定(Output Stationary):部分和留在PE里,权重和特征图都流进来。适合需要大量累加的场景,比如大卷积核。
我在实际项目中,大部分时候用的是权重固定+输出固定的混合策略。为什么?因为卷积核通常不大(3x3或5x5),权重可以一次性加载到所有PE里。然后特征图按行扫描,每个PE负责一个输出通道的部分和累加。这样既减少了权重搬运次数,又避免了部分和频繁写回。
注意:数据流调度一旦定下来,控制逻辑的复杂度就基本确定了。我曾经见过一个团队,为了追求极致性能,搞了动态可重构的数据流调度。结果控制逻辑比PE阵列还大,功耗直接翻倍。所以,不要过度设计。能用静态调度解决的问题,就别上动态。
4.4 一个简单的PE阵列示例
咱们来看一个4x4的脉动阵列,权重固定模式。每个PE内部结构如下:
// 简化的PE代码,仅用于说明数据流
module pe #(
parameter DATA_WIDTH = 8
) (
input clk, rst_n,
input [DATA_WIDTH-1:0] ifmap_in, // 来自左邻居
input [DATA_WIDTH-1:0] weight_in, // 来自上邻居
output [DATA_WIDTH-1:0] ifmap_out, // 传给右邻居
output [DATA_WIDTH-1:0] weight_out, // 传给下邻居
output [2*DATA_WIDTH-1:0] psum_out // 累加结果
);
reg [DATA_WIDTH-1:0] weight_reg;
reg [2*DATA_WIDTH-1:0] psum_reg;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
weight_reg <= 0;
psum_reg <= 0;
end else begin
weight_reg <= weight_in; // 权重从上往下传
psum_reg <= psum_reg + ifmap_in * weight_reg; // 乘累加
end
end
assign ifmap_out = ifmap_in; // 特征图从左往右传
assign weight_out = weight_reg;
assign psum_out = psum_reg;
endmodule
你看,代码其实很简单。每个PE只做一件事:接收左边的特征图,接收上边的权重,乘起来累加,然后把特征图往右传,权重往下传。整个阵列就像一张网,数据有序流动。
但实际工程中,远没有这么简单。你需要考虑:
- 阵列边界上的PE,数据从哪里来?
- 累加结果什么时候输出?是每周期输出还是等卷积窗口算完?
- 如果特征图尺寸不是阵列尺寸的整数倍,怎么处理边界?
这些问题,我在后面的章节会详细讲。这里先留个悬念。
4.5 数据流调度的时序分析
咱们用一张图来展示数据在阵列中的流动过程。假设卷积核是3x3,特征图是5x5,阵列是4x4。
从这张图可以看得很清楚:特征图从左往右流,权重从上往下流。每个PE在接收到数据后,立即做乘累加,然后把数据传给下一个PE。这样,整个阵列就像一个流水线,每个周期都有新的数据进来,也有新的结果出去。
但这里有个坑:阵列的填充时间。数据从左上角流到右下角,需要经过8个周期(4行+4列)。在这8个周期里,阵列的输出是不完整的。所以,实际有效计算时间,要减去这个填充时间。我在做性能评估时,一定会把这个因素算进去,否则估算出来的TOPS会虚高。
4.6 实际工程中的避坑指南
最后,分享几个我踩过的坑:
- 复位状态:PE内部的累加器,复位后一定要清零。我遇到过复位信号没处理好,累加器初始值是X态,仿真没问题,上板就挂。
- 数据对齐:特征图和权重到达PE的时间必须对齐。如果因为布线延迟导致错位,结果就全错了。我一般会在PE入口加一级流水寄存器,保证时序收敛。
- 溢出处理:累加器的位宽要足够。8bit乘8bit是16bit,累加16次就需要20bit。别省那几位,否则溢出后结果直接报废。
- 功耗控制:PE阵列是全芯片的功耗大户。我习惯在空闲时给PE送时钟门控,或者直接拉低使能信号。曾经有个项目,PE阵列占了芯片60%的功耗,加了门控后降到30%。
总结一下:PE微架构设计,核心就是数据流调度。想清楚数据怎么流、怎么重用、怎么对齐,剩下的就是体力活了。别把PE设计得太花哨,简单、可靠、可扩展,才是工程上的王道。
好了,这一章就聊到这儿。下一章咱们会深入PE阵列的控制逻辑,看看怎么用状态机来调度整个计算过程。