一维脉动阵列:线性阵列拓扑,数据复用与流水线
一维脉动阵列,说白了就是让数据像流水一样,在一条直线上串行流动。我刚开始接触这个概念时,总觉得它跟普通移位寄存器差不多。后来真正做项目才发现,这里面的门道可深了。
咱们先看一个最简单的场景:你有一堆乘法累加操作要做,比如 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。
为什么会这样?因为脉动阵列是“吃数据”的机器。它跑得再快,数据喂不进去也是白搭。所以设计时一定要算好数据带宽:阵列长度 × 数据位宽 × 时钟频率 ≤ 存储带宽。
这张图把数据流画得很清楚。蓝色是 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,看输出是不是系数之和。这种“冒烟测试”能快速发现连线错误。
最后说一句:一维脉动阵列是理解更高维阵列的基础。你把一维搞透了,二维、三维的拓扑无非是在这个基础上加维度。但基础不牢,后面会越学越懵。所以这一章的内容,我建议你亲手写一遍代码,跑一遍仿真,再综合看看时序报告。嗯,动手才是硬道理。