脉动阵列架构基础

各位同学,今天我们来聊聊脉动阵列。说实话,我第一次接触这个概念是在做图像处理加速器的时候。当时被一堆里的术语搞得头晕,什么「数据复用」、「广播」、「权重固定」……后来真正动手做了一版设计,才明白这东西到底好在哪。

脉动阵列,说白了就是一种让数据像心跳一样在计算单元之间规律流动的架构。你想想看,传统的计算方式,数据从内存取出来,算完再存回去,来回折腾,功耗和延迟都上去了。脉动阵列的思路是:让数据待在阵列里,边移动边计算,一次取出来多用几次。

核心思想:每个处理单元(PE)只做简单的乘加运算,数据在相邻PE之间传递,避免频繁访问片外存储器。

数据复用与广播

这里有个关键问题:数据怎么在阵列里流动?我见过不少新手设计,上来就把所有数据同时广播到所有PE。结果呢?布线资源爆炸,时序收敛困难。其实脉动阵列有三种常见的数据流动方式:

  • 权重固定(Weight Stationary):权重存在PE里不动,输入数据在阵列中流动。我习惯用这种方式做卷积,因为权重复用率高。
  • 数据固定(Data Stationary):输入数据待在PE里,权重在阵列中流动。适合某些特定的矩阵运算场景。
  • 输出固定(Output Stationary):部分和结果留在PE里,输入和权重都流动。这种方式我用的少,但有些低功耗设计会选它。

你可能会问:「这三种方式到底选哪个?」嗯,没有标准答案。我在做边缘检测加速器时,试过权重固定和数据固定两种方案。最后发现,如果卷积核尺寸小(比如3x3),权重固定更省带宽;如果输入特征图很大,数据固定反而更优。

我的经验:别纠结理论上的最优解。先搭一个简单的权重固定阵列跑仿真,看看数据复用率和带宽瓶颈在哪。很多时候,实际瓶颈不在计算,而在数据搬运。

经典脉动阵列结构

经典的脉动阵列长什么样?我画个图你就明白了。下面这个结构是二维脉动阵列,常用于矩阵乘法或卷积运算。

二维脉动阵列结构图 输入数据流 权重数据流 PE(0,0) PE(0,1) PE(0,2) PE(1,0) PE(1,1) PE(1,2) PE(2,0) PE(2,1) PE(2,2) 输出部分和 输入数据流动 权重数据流动 输出部分和

看到这个图了吗?每个PE只和上下左右的邻居通信。数据从左边流进来,权重从上面流下来,部分和往下累加。这种结构的好处是:每个PE只需要连接4个邻居,布线简单,频率容易跑高。

注意:我曾经在一个项目中,把阵列规模从8x8扩到16x16,结果时序直接崩了。原因很简单:PE之间的连线变长,组合逻辑延迟增加。所以阵列规模不是越大越好,要综合考虑工艺节点和时钟频率。

权重固定 vs 数据固定

这两种设计选择,我花了不少时间才真正搞明白。咱们用个表格对比一下:

特性 权重固定 数据固定
权重存储 存在PE本地寄存器 在阵列中流动
输入数据 在阵列中流动 存在PE本地寄存器
适用场景 卷积核复用率高(如3x3卷积) 输入数据复用率高(如全连接层)
带宽需求 低(权重一次加载多次使用) 低(输入一次加载多次使用)
控制复杂度 中等(需要控制权重加载时机) 中等(需要控制输入加载时机)

我个人更偏爱权重固定。为什么?因为卷积运算中,同一个卷积核要滑过整个特征图。权重固定后,一次加载就能用很多次,省带宽。我在做YOLO加速器时,权重固定方案让片外带宽需求降低了40%。

但数据固定也有它的舞台。比如在做全连接层时,输入数据是固定的特征向量,权重矩阵很大。这时候把输入数据固定在PE里,让权重流动,反而更高效。

避坑指南:我曾经犯过一个错误——在权重固定阵列里,忘了考虑权重更新的开销。每次更新权重时,所有PE都要重新加载,如果更新频繁,带宽反而比数据固定还高。所以,如果你的卷积核经常变(比如动态卷积),权重固定可能不是好选择。

一个简单的PE设计示例

说了这么多理论,咱们看个实际的PE代码。这是权重固定模式下,一个PE的简化Verilog描述:

module pe #(
    parameter DATA_WIDTH = 8
)(
    input  clk,
    input  rst_n,
    input  [DATA_WIDTH-1:0] data_in,    // 来自左侧PE的输入数据
    input  [DATA_WIDTH-1:0] weight_in,  // 来自上方PE的权重(权重固定模式下,实际只加载一次)
    input  [DATA_WIDTH*2-1:0] psum_in,  // 来自上方PE的部分和
    output [DATA_WIDTH-1:0] data_out,   // 输出到右侧PE
    output [DATA_WIDTH-1:0] weight_out, // 输出到下方PE
    output [DATA_WIDTH*2-1:0] psum_out  // 输出到下方PE的部分和
);
    reg [DATA_WIDTH-1:0] weight_reg;    // 权重本地寄存器
    reg [DATA_WIDTH*2-1:0] psum_reg;
    
    // 权重加载(仅在权重更新时使能)
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            weight_reg <= 0;
        else if (weight_load_en)
            weight_reg <= weight_in;
    end
    
    // 乘加运算
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            psum_reg <= 0;
        else
            psum_reg <= psum_in + data_in * weight_reg;
    end
    
    // 数据直通
    assign data_out   = data_in;
    assign weight_out = weight_reg;  // 权重固定模式下,向下传递的是本地存储的权重
    assign psum_out   = psum_reg;
    
endmodule

这段代码很简单,但包含了脉动阵列的核心:每个PE做乘加,数据直通,权重本地存储。实际项目中,你还需要考虑位宽扩展、饱和处理、流水线级数等问题。

好了,关于脉动阵列的基础就聊到这。记住一句话:脉动阵列的本质是用数据流动换带宽,用局部性换效率。下次你设计加速器时,不妨先画个数据流图,看看哪些数据可以复用,再决定用哪种固定方式。

一句话总结:脉动阵列不是银弹,但在卷积、矩阵乘法这类规则运算中,它确实是最优雅的硬件实现方式之一。

可可精品资料库