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。这里有个经典问题:权重固定还是特征图固定?

我习惯把调度策略分成三种:

  1. 权重固定(Weight Stationary):权重加载到PE后就不动了,特征图流进来。适合权重复用度高的场景,比如全连接层。
  2. 特征图固定(Input Stationary):特征图留在PE里,权重流进来。适合特征图复用度高的场景,比如深度可分离卷积。
  3. 输出固定(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阵列数据流调度示意图(权重固定模式) PE00 PE01 PE02 PE03 PE10 PE11 PE12 PE13 PE20 PE21 PE22 PE23 PE30 PE31 PE32 PE33 特征图流动方向 权重流动方向 每个PE接收左邻的特征图 和上邻的权重 乘累加后向右下传递 ifmap weight → ↓

从这张图可以看得很清楚:特征图从左往右流,权重从上往下流。每个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阵列的控制逻辑,看看怎么用状态机来调度整个计算过程。

可可精品资料库