一维脉动阵列:向量点积的实现

好,咱们今天来聊一个非常经典的结构——一维脉动阵列实现向量点积。说白了,就是让数据从两端流进来,计算结果在中间汇聚。这个模式我在很多加速器里都见过,它也是理解更复杂脉动阵列的基础。

向量点积的数学本质

先回顾一下向量点积。两个长度为 N 的向量 A 和 B,点积就是对应元素相乘再求和:

C = A[0]*B[0] + A[1]*B[1] + ... + A[N-1]*B[N-1]

这个计算看起来简单,但如果你用传统的 CPU 或 GPU 去算,数据搬运的开销往往比计算本身还大。我当年做图像处理加速时,就遇到过这个问题——算力不是瓶颈,数据喂不进去才是。

脉动阵列怎么解决?它把乘法器和累加器排成一排,让数据像流水一样流过每个单元。每个单元只做一件事:乘一个数,加一个数,然后传给下一个。

一维阵列的结构设计

我们来看一个具体的例子。假设 N=4,我们需要 4 个处理单元(PE)。每个 PE 内部有一个乘法器和一个累加器。

数据流是这样设计的:

  • A 向量从阵列左端流入,每个时钟周期向右移动一个 PE
  • B 向量从阵列右端流入,每个时钟周期向左移动一个 PE
  • 每个 PE 将自己收到的 A 和 B 相乘,然后累加到本地结果中
  • 最终结果从中间的某个 PE 输出

嗯,这里要注意:数据必须同步。A 和 B 的流动速度要一致,否则乘错对就全乱了。

核心思想:每个 PE 只做一次乘加,数据在 PE 之间流动,避免了全局广播。这就是脉动阵列省带宽的秘密。

数据流时序分析

我们用一个 4 单元阵列来演示。假设 A = [a0, a1, a2, a3],B = [b0, b1, b2, b3]。

时钟周期 0:

  • PE0 收到 a0(从左)和 b3(从右),计算 a0 * b3,累加
  • 其他 PE 空闲

时钟周期 1:

  • a0 移到 PE1,a1 进入 PE0
  • b3 移到 PE2,b2 进入 PE3
  • PE0:a1 * b2,累加
  • PE1:a0 * b3,累加
  • PE2、PE3 空闲

时钟周期 2:

  • a0 到 PE2,a1 到 PE1,a2 到 PE0
  • b3 到 PE1,b2 到 PE2,b1 到 PE3
  • PE0:a2 * b1
  • PE1:a1 * b2 + a0 * b3
  • PE2:a0 * b3
  • PE3 空闲

继续推演下去,你会发现:

时钟周期 PE0 累加值 PE1 累加值 PE2 累加值 PE3 累加值
0 a0*b3 0 0 0
1 a1*b2 a0*b3 0 0
2 a2*b1 a1*b2 + a0*b3 a0*b3 0
3 a3*b0 a2*b1 + a1*b2 + a0*b3 a1*b2 + a0*b3 a0*b3
4 0 a3*b0 + a2*b1 + a1*b2 + a0*b3 a2*b1 + a1*b2 + a0*b3 a1*b2 + a0*b3

看到了吗?在时钟周期 4,PE1 的累加值正好是完整的点积结果。这就是「结果在中间汇聚」的含义——数据从两端流入,在阵列中间完成最终求和。

个人经验:我习惯把这种结构叫做「对撞式脉动阵列」。数据像两列火车对开,在中间交汇处完成计算。设计时要注意,结果输出的位置取决于阵列长度是奇数还是偶数。奇数时正中间那个 PE 输出,偶数时中间偏左或偏右的那个输出。

SVG 结构图:一维脉动阵列数据流

下面这张图展示了 4 单元阵列在时钟周期 3 时的数据分布和计算状态:

一维脉动阵列:向量点积数据流(时钟周期 3) PE0 A: a3 B: b0 PE1 A: a2 B: b1 PE2 A: a1 B: b2 PE3 A: a0 B: b3 累加: a3*b0 累加: a2*b1 + ... 累加: a1*b2 + ... 累加: a0*b3 a3→ a2→ a1→ a0→ ←b0 ←b1 ←b2 ←b3 结果汇聚点 PE1 输出最终结果 数据从两端流入:A 向量从左向右,B 向量从右向左 每个 PE 执行一次乘加,结果在中间 PE1 汇聚为完整点积

硬件实现要点

如果你要真正去写 RTL 实现这个阵列,有几个地方得特别小心:

我曾经踩过的坑:第一次做这个设计时,我忘了考虑数据对齐。A 和 B 的输入时序必须精确匹配,差一个周期结果就全错了。我的建议是:先画好时序图,再写代码。

具体来说,每个 PE 的 Verilog 代码大概长这样:

module pe (
    input  clk,
    input  rst_n,
    input  [7:0] a_in,    // 从左端流入的数据
    input  [7:0] b_in,    // 从右端流入的数据
    output reg [7:0] a_out,  // 向右端传递的数据
    output reg [7:0] b_out,  // 向左端传递的数据
    output reg [15:0] acc    // 累加结果
);

    reg [15:0] mul_result;
    
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            a_out <= 8'd0;
            b_out <= 8'd0;
            acc   <= 16'd0;
        end else begin
            a_out <= a_in;          // 数据向右传递
            b_out <= b_in;          // 数据向左传递
            mul_result <= a_in * b_in;
            acc <= acc + mul_result; // 累加
        end
    end

endmodule

你看,每个 PE 的逻辑其实非常简单。但就是这些简单的 PE 排成一排,配合精确的时序控制,就能高效完成向量点积。

性能分析

这个结构的性能怎么样?我们来算一笔账:

指标 传统串行计算 一维脉动阵列
计算周期数(N=4) 4 次乘 + 3 次加 = 7 周期 4 周期(流水线填满后)
数据带宽需求 每次需要读两个数 每个周期只需读两个数(流式)
硬件利用率 100%(单 PE) 75%(前 3 周期未填满)
扩展性 线性增加时间 线性增加硬件,时间不变

说白了,脉动阵列用面积换时间。N 个 PE 并行工作,把 O(N) 的时间复杂度降到了 O(1)(流水线填满后)。代价是硬件面积增加了 N 倍。

我的建议:如果你的向量长度很大(比如 1024),不要真的摆 1024 个 PE。实际工程中常用「分块」策略——用固定大小的阵列(比如 64 个 PE)分多次处理长向量。这个我们后面章节会详细讲。

小结

一维脉动阵列实现向量点积,核心就三点:

  • 数据对撞:A 从左向右,B 从右向左,在中间相遇
  • 流水计算:每个 PE 只做一次乘加,结果逐级累加
  • 结果汇聚:最终结果在阵列中间的 PE 输出