一维脉动阵列:线性阵列拓扑,数据复用与流水线

一维脉动阵列,说白了就是让数据像流水一样,在一条直线上串行流动。我刚开始接触这个概念时,总觉得它跟普通移位寄存器差不多。后来真正做项目才发现,这里面的门道可深了。

咱们先看一个最简单的场景:你有一堆乘法累加操作要做,比如 FIR 滤波器。传统做法是串行算,一个时钟算一个乘加。但如果你有 N 个乘加器,能不能让它们同时干活?当然能。一维脉动阵列就是干这个的。

核心思想:每个处理单元(PE)只做一件事——接收数据、乘加、把结果传给下一个 PE。数据在 PE 之间像脉搏一样跳动,所以叫“脉动”。

线性阵列拓扑

一维脉动阵列的拓扑结构很简单:PE0 → PE1 → PE2 → … → PE(N-1)。每个 PE 只跟左右邻居通信。没有全局总线,没有交叉开关。

我做过一个 8 阶 FIR 的加速器,用的就是这种结构。当时有个同事非要用全互联的 mesh 拓扑,结果布线把后端工程师逼疯了。线性阵列的好处就是布线简单,时序容易收敛。

拓扑类型 连线复杂度 时钟频率 适用场景
线性阵列 O(N) 高 FIR、卷积、矩阵向量乘
网格阵列 O(N²) 中 矩阵乘、图像处理
全互联 O(N²) 低 小规模、高灵活性

你想想看,如果每个 PE 都要跟所有其他 PE 通信,光连线就能把芯片面积撑爆。线性阵列的连线只跟邻居走,后端布局布线轻松很多。

数据复用与流水线

一维脉动阵列的精髓在于数据复用。什么意思?就是一份数据被多个 PE 重复使用,不用每次都从存储器里读。

拿 FIR 滤波器举例。输入数据 x[n] 进入阵列后,会依次经过每个 PE。每个 PE 都存着一个固定的系数 h[k]。当 x[n] 流到 PE_k 时,它跟 h[k] 做乘加,结果累加到部分和里。

这里的关键是:输入数据只加载一次,但被所有 PE 复用。数据在阵列里流动,系数在 PE 里固定不动。这就是“数据复用”的典型模式。

我的经验:数据复用率越高,访存带宽需求越低。但要注意,数据流动需要时间。阵列越长,流水线填充的延迟越大。我一般建议阵列长度不超过 16,否则填充延迟会吃掉性能收益。

流水线呢?说白了就是让每个 PE 都在干活,没有空闲。理想情况下,每个时钟周期,每个 PE 都在做乘加。数据像流水一样,前一个 PE 算完,结果立刻传给下一个 PE。

但这里有个坑:数据依赖。如果 PE_k 的计算结果要等 PE_{k-1} 算完才能用,那就没法流水了。好在 FIR 滤波器没有反馈路径,天然适合流水线。

我曾经踩过的坑:在实现 IIR 滤波器时,我直接把 FIR 的脉动阵列搬过来用,结果发现反馈路径导致数据依赖,流水线根本跑不起来。后来不得不改成半并行结构。所以记住:脉动阵列只适合无反馈的线性系统。

经典 FIR 滤波器实现

咱们直接看代码。一个 4 阶 FIR 滤波器,系数 h[0]~h[3],输入 x[n],输出 y[n]。

// 每个 PE 的内部逻辑
module pe #(
    parameter WIDTH = 16
)(
    input  clk,
    input  rst_n,
    input  [WIDTH-1:0] x_in,      // 输入数据
    input  [WIDTH-1:0] sum_in,    // 来自前一个 PE 的部分和
    output [WIDTH-1:0] x_out,     // 传递给下一个 PE 的数据
    output [WIDTH-1:0] sum_out    // 累加后的部分和
);

    reg [WIDTH-1:0] h;            // 固定系数
    reg [WIDTH-1:0] x_reg;
    reg [WIDTH-1:0] sum_reg;

    // 系数初始化(实际项目中从寄存器或 ROM 加载)
    initial begin
        case (PE_ID)
            0: h = 16'h0001;      // h[0]
            1: h = 16'h0002;      // h[1]
            2: h = 16'h0003;      // h[2]
            3: h = 16'h0002;      // h[3]
        endcase
    end

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            x_reg   <= 0;
            sum_reg <= 0;
        end else begin
            x_reg   <= x_in;                     // 数据流水
            sum_reg <= sum_in + x_in * h;        // 乘加
        end
    end

    assign x_out   = x_reg;
    assign sum_out = sum_reg;

endmodule

顶层模块就是把 4 个 PE 串起来:

module fir_4tap #(
    parameter WIDTH = 16
)(
    input  clk,
    input  rst_n,
    input  [WIDTH-1:0] x,
    output [WIDTH-1:0] y
);

    wire [WIDTH-1:0] x_chain [0:3];
    wire [WIDTH-1:0] sum_chain [0:3];

    // PE0:第一个 PE,sum_in 接 0
    pe #(.WIDTH(WIDTH)) pe0 (
        .clk(clk), .rst_n(rst_n),
        .x_in(x),
        .sum_in(0),
        .x_out(x_chain[0]),
        .sum_out(sum_chain[0])
    );

    // PE1~PE3:级联
    genvar i;
    generate
        for (i = 1; i < 4; i = i + 1) begin : pe_chain
            pe #(.WIDTH(WIDTH)) pe_inst (
                .clk(clk), .rst_n(rst_n),
                .x_in(x_chain[i-1]),
                .sum_in(sum_chain[i-1]),
                .x_out(x_chain[i]),
                .sum_out(sum_chain[i])
            );
        end
    endgenerate

    // 输出取最后一个 PE 的 sum_out
    assign y = sum_chain[3];

endmodule

这段代码看起来简单,但有几个关键点要注意:

  • 数据 x 在每个 PE 里都打了一拍。这样 x 在阵列里流动时,每个 PE 看到的是不同时刻的输入。PE0 看到 x[n],PE1 看到 x[n-1],以此类推。正好对应 FIR 的延迟链。
  • 部分和 sum 也在流动。PE0 算完 x[n]*h[0],结果传给 PE1。PE1 加上 x[n-1]*h[1],再传给 PE2。最后 PE3 输出完整的 y[n]。
  • 流水线深度 = 阵列长度。输入 x[n] 要经过 4 个时钟周期才能得到对应的 y[n]。这就是流水线延迟。

性能分析:4 阶 FIR,4 个 PE,每个时钟周期输出一个结果。吞吐率 = 1/clk。相比串行实现(需要 4 个时钟周期算一个输出),吞吐率提升了 4 倍。

嗯,这里要注意:吞吐率提升的前提是输入数据能连续供应。如果数据源跟不上,阵列就会空转。我在项目中遇到过这种情况——DMA 带宽不够,阵列每 3 个时钟才能拿到一个数据,结果吞吐率只有理论值的 1/3。

为什么会这样?因为脉动阵列是“吃数据”的机器。它跑得再快,数据喂不进去也是白搭。所以设计时一定要算好数据带宽:阵列长度 × 数据位宽 × 时钟频率 ≤ 存储带宽。

一维脉动阵列 FIR 滤波器数据流 PE0 h[0] PE1 h[1] PE2 h[2] PE3 h[3] x[n] y[n] 数据流说明 蓝色箭头:输入数据 x[n] 从左向右流动,每个 PE 打一拍 绿色箭头:部分和 sum 从左向右累加,最终在 PE3 输出 y[n] 每个 PE 内部:sum_out = sum_in + x_in × h[k]

这张图把数据流画得很清楚。蓝色是 x 数据,绿色是部分和。你想想看,x[n] 进入 PE0 时,PE0 算的是 x[n]×h[0]。下一个时钟,x[n] 到 PE1,PE1 算 x[n]×h[1]。同时 PE0 开始算 x[n+1]×h[0]。这就是流水线。

我个人习惯在仿真时先验证单个 PE 的行为,再验证整条链。用几个简单的测试向量,比如输入全 1,看输出是不是系数之和。这种“冒烟测试”能快速发现连线错误。

最后说一句:一维脉动阵列是理解更高维阵列的基础。你把一维搞透了,二维、三维的拓扑无非是在这个基础上加维度。但基础不牢,后面会越学越懵。所以这一章的内容,我建议你亲手写一遍代码,跑一遍仿真,再综合看看时序报告。嗯,动手才是硬道理。