1. 为什么值得动手复现一个脉动阵列
矩阵乘法这件事,放在CPU上跑,大家习以为常——三层循环,编译器自动向量化,顶多再挂个OpenMP。但当你真正把矩阵规模拉到几百乘几百,还要在功耗和延迟双重约束下跑推理任务时,CPU的通用架构就开始露怯了。谷歌TPU第一代之所以能在推理场景打出漂亮的能效比,核心武器之一就是脉动阵列(Systolic Array)——一种让数据像心跳一样有节奏地在PE(Processing Element)阵列中流动的计算结构。
我第一次在FPGA上复现脉动阵列,是为了做一个定点矩阵乘法加速器,目标规模是32×32的INT8矩阵。当时踩了不少坑:PE之间的握手时序对不上、边界数据注入错位、综合后时序跑不到目标频率。后来一点点啃下来,发现这东西的原理其实不复杂,难的是把"数据流动的节奏"在硬件里精确对齐。
这篇文章就是把我从零复现脉动阵列的完整过程拆开讲。适合谁看:有Verilog基础、做过至少一个FPGA小项目(比如UART、计数器、状态机),想往AI加速器方向深入的人。能学到什么:脉动阵列的核心原理、PE的微架构设计、数据调度策略、Verilog实现细节、仿真验证方法,以及综合布线阶段的实操经验。前置知识:组合逻辑与时序逻辑的区别、阻塞赋值与非阻塞赋值的语义、基本的定点数概念。如果这些还不太熟,建议先把Verilog的计数器、移位寄存器、简单状态机写一遍再回来。
我不会只贴代码,而是把每个设计决策背后的"为什么"讲清楚——为什么PE要这样设计、为什么数据要斜着注入、为什么累加器位宽要留够。这些才是你真正能迁移到其他项目里的东西。
2. 脉动阵列到底在算什么:原理拆解与方案选型
2.1 从"一个人搬砖"到"流水线协作"
先打个比方。普通矩阵乘法像是一个人搬砖:你有一个计算单元,它要反复从内存里取A的元素、取B的元素、乘一下、加到结果里,然后再取下一对。取数据的时间远大于计算时间,计算单元大部分时间在等数据。
脉动阵列则是一队人排成方阵传砖:每个PE只负责一次乘加,算完就把数据传给右边的邻居,同时从左边接收新数据。数据像水流一样穿过整个阵列,每个PE在每个时钟周期都在干活。这就是"脉动"这个名字的由来——数据有节奏地脉动式流动。
具体到矩阵乘法C = A × B,假设A是M×K,B是K×N,那么:
- A的每一行从阵列左侧逐周期注入,向右流动
- B的每一列从阵列顶部逐周期注入,向下流动
- 每个PE(i,j)在时刻t接收到A[i][t]和B[t][j],做乘加,累加到本地寄存器
- 经过K个周期后,PE(i,j)的累加器里就是C[i][j]
关键在于:A的第i行第k个元素和B的第k列第j个元素,必须在同一时刻到达PE(i,j)。这就是数据调度要解决的核心问题。
2.2 为什么选输出固定型(Output-Stationary)
脉动阵列有好几种数据流变体,常见的有:
| 数据流类型 | 特点 | 适用场景 |
|---|---|---|
| 输出固定(Output-Stationary) | 结果留在PE本地累加,A和B流过 | 通用矩阵乘法,最直观 |
| 权重固定(Weight-Stationary) | B矩阵预加载到PE,A流过 | 推理场景,权重可复用 |
| 行固定(Row-Stationary) | A的行固定,B和部分和流动 | 卷积神经网络 |
我选的是输出固定型,理由很实际:第一,它最容易理解,PE的结构最简单——一个乘法器加一个累加器;第二,对于教学和首次复现来说,调试难度最低,因为每个PE的结果就在本地,不需要追踪流动的部分和;第三,它天然支持任意K值,只要控制好注入周期数就行。
权重固定型虽然在实际推理芯片里更常见(因为权重可以预加载、反复使用),但它需要额外的权重加载阶段和更复杂的控制逻辑。第一次做,没必要给自己上难度。
2.3 阵列规模怎么定:32×32还是8×8
阵列规模直接决定了资源消耗和设计复杂度。我一开始想直接上32×32,结果综合报告一出来,DSP用了1024个,目标器件根本放不下。后来退回到8×8先跑通,再逐步扩展到16×16。
这里有个经验公式:每个PE至少消耗1个DSP(乘法器)和1个累加器(若干LUT+FF)。8×8阵列就是64个DSP,16×16是256个,32×32是1024个。你先查一下自己板子上FPGA的DSP数量,留出30%余量给其他逻辑,就知道能上多大阵列了。
另外,阵列不一定是正方形。如果你的矩阵是M×N且M远大于N,可以考虑M×N的非方阵列,但控制逻辑会复杂一些。我建议第一次做就选正方形,8×8起步。
3. PE的微架构设计:一个乘法器加一个累加器就够了吗
3.1 PE的基本结构
一个最简PE需要以下端口:
- 输入:
a_in(来自左侧)、b_in(来自上方)、clk、rst_n、en(使能) - 输出:
a_out(传给右侧)、b_out(传给下方) - 内部:
acc(累加器寄存器)
每个时钟周期(当en有效时):
a_out <= a_in,b_out <= b_in(数据继续流动)acc <= acc + a_in * b_in(乘加)
看起来很简单,但有几个细节必须处理好。
3.2 数据位宽与定点数格式
我用的是INT8输入,INT32累加。为什么?
- 输入用INT8:这是量化推理的常见格式,8位乘法器在FPGA里映射到DSP48 slice非常高效
- 累加用INT32:K个INT8乘积相加,最坏情况下每个乘积是127×127≈16129,K=256时总和约4.1M,需要23位。留到32位有充足余量,也方便后续做移位和饱和处理
如果你要做浮点,那DSP消耗会翻好几倍,而且时序很难收敛。第一次做,老老实实用定点。
注意:INT8乘法在Verilog里要显式声明为有符号数(
signed),否则综合工具可能按无符号处理,结果全错。我在这上面浪费过一整个下午。
3.3 累加器的复位与清零策略
累加器不能一直累加,必须在每轮矩阵乘法开始前清零。有两种做法:
- 全局复位:所有PE的acc在start信号来时统一清零。简单,但需要额外的复位布线
- 首周期清零:利用第一个有效周期的en信号,在状态机里产生一个clear脉冲
我选的是第二种,因为它不依赖全局复位网络,时序更好收敛。具体做法是在控制状态机里,当K计数器为0时,让PE执行acc <= a_in * b_in(而不是累加),从第二个周期开始才累加。
3.4 关键代码片段
module pe #( parameter DATA_WIDTH = 8, parameter ACC_WIDTH = 32 )( input wire clk, input wire rst_n, input wire en, input wire clear, input wire signed [DATA_WIDTH-1:0] a_in, input wire signed [DATA_WIDTH-1:0] b_in, output reg signed [DATA_WIDTH-1:0] a_out, output reg signed [DATA_WIDTH-1:0] b_out, output reg signed [ACC_WIDTH-1:0] acc ); wire signed [2*DATA_WIDTH-1:0] mult_result; assign mult_result = a_in * b_in; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin a_out <= 0; b_out <= 0; acc <= 0; end else if (en) begin a_out <= a_in; b_out <= b_in; if (clear) acc <= {{(ACC_WIDTH-2*DATA_WIDTH){mult_result[2*DATA_WIDTH-1]}}, mult_result}; else acc <= acc + {{(ACC_WIDTH-2*DATA_WIDTH){mult_result[2*DATA_WIDTH-1]}}, mult_result}; end end endmodule这段代码里有个细节:mult_result需要符号扩展到ACC_WIDTH位再累加。如果直接加,Verilog会按最大位宽对齐,但符号位可能处理不对。我显式做了符号扩展,确保负数乘法结果正确。
4. 数据调度:让A和B在正确的时间相遇
4.1 斜向注入的数学推导
这是脉动阵列最烧脑的部分。假设阵列是N×N,我们要算C = A×B,A和B都是N×N(先考虑方阵)。
PE(i,j)需要计算:C[i][j] = Σ(k=0 to N-1) A[i][k] * B[k][j]
在时刻t,PE(i,j)应该收到A[i][t]和B[t][j]。也就是说:
- A的第i行,第k个元素,应该在时刻k到达PE(i,0),然后向右流动,在时刻k+j到达PE(i,j)
- B的第k列,第j个元素,应该在时刻k到达PE(0,j),然后向下流动,在时刻k+i到达PE(i,j)
所以注入策略是:
- A矩阵:第i行的数据,从左侧第i个端口注入,但第i行要比第0行延迟i个周期开始注入。这样A[i][0]在时刻i到达PE(i,0),A[i][k]在时刻i+k到达PE(i,0),向右流动j个周期后,在时刻i+k+j到达PE(i,j)。
- B矩阵:第j列的数据,从顶部第j个端口注入,第j列比第0列延迟j个周期开始注入。B[0][j]在时刻j到达PE(0,j),B[k][j]在时刻k+j到达PE(0,j),向下流动i个周期后,在时刻k+j+i到达PE(i,j)。
两者在PE(i,j)的到达时刻都是i+k+j,完美对齐。
4.2 用移位寄存器实现延迟注入
在硬件里,这个"延迟i个周期"怎么实现?最简单的方法是用移位寄存器链。
对于A矩阵的注入:第0行直接注入,第1行经过一级寄存器延迟,第2行经过两级,以此类推。B矩阵同理。
// A矩阵行延迟链示例(简化) reg signed [7:0] a_delay [0:N-1][0:N-1]; integer i, j; always @(posedge clk) begin for (i = 0; i < N; i = i + 1) begin a_delay[i][0] <= a_data[i]; for (j = 1; j < N; j = j + 1) a_delay[i][j] <= a_delay[i][j-1]; end end // a_delay[i][i] 就是延迟i个周期后的A第i行数据这段代码综合出来是一堆寄存器,N=8时是64个8位寄存器,资源消耗可以接受。如果N更大,可以考虑用BRAM做延迟线,但控制会复杂一些。
4.3 控制状态机的设计
整个加速器需要一个主状态机来协调:
- IDLE:等待start信号
- LOAD:从外部存储器读取A和B矩阵数据,存入输入缓冲
- COMPUTE:启动注入,运行2N-1个周期(因为最晚到达的数据需要i+k+j的最大值,i=N-1, k=N-1, j=N-1,总共3N-3个周期,但实际有效计算是N个周期,加上注入和排空,总共约3N个周期)
- DRAIN:等待最后一个PE完成累加
- OUTPUT:将PE阵列的结果读出到输出缓冲
- DONE:拉高done信号
这里有个容易搞错的地方:COMPUTE阶段到底要跑多少个周期。我一开始以为是N个周期,结果发现最后一个PE的结果要等到第3N-3个周期才稳定。正确的做法是:从第一个数据注入开始,数到最后一个数据到达最后一个PE并完成累加,总共需要(2N-1) + (N-1) = 3N-2个周期。保险起见,我跑了3N个周期。
5. 完整Verilog实现与仿真验证
5.1 顶层模块结构
顶层模块把PE阵列、延迟链、控制状态机、输入输出缓冲都例化在一起:
module systolic_top #( parameter N = 8, parameter DATA_WIDTH = 8, parameter ACC_WIDTH = 32 )( input wire clk, input wire rst_n, input wire start, input wire signed [DATA_WIDTH-1:0] a_data [0:N-1], input wire signed [DATA_WIDTH-1:0] b_data [0:N-1], output wire signed [ACC_WIDTH-1:0] c_data [0:N-1][0:N-1], output wire done ); // PE阵列例化 // 延迟链 // 控制状态机 // ... endmodule5.2 仿真测试平台
验证是重中之重。我写了一个testbench,用随机数生成A和B矩阵,然后在Verilog里算一遍参考结果,跟硬件输出对比。
// 参考模型 integer i, j, k; reg signed [ACC_WIDTH-1:0] ref_c [0:N-1][0:N-1]; initial begin for (i = 0; i < N; i = i + 1) for (j = 0; j < N; j = j + 1) begin ref_c[i][j] = 0; for (k = 0; k < N; k = k + 1) ref_c[i][j] = ref_c[i][j] + a_mat[i][k] * b_mat[k][j]; end end仿真时我用了Vivado自带的仿真器,跑1000个随机测试用例,全部通过后才上板。
实操心得:仿真通过不代表上板能跑。我遇到过仿真全对、上板结果错位的情况,原因是综合工具对数组的推断跟仿真器不一致。解决办法是避免在可综合代码里使用多维数组作为端口,改用扁平化的向量或者用generate展开。
5.3 综合与实现的关键约束
综合时我加了以下约束:
create_clock -period 10.000 -name sys_clk [get_ports clk] set_input_delay -clock sys_clk 2.000 [get_ports a_data*] set_output_delay -clock sys_clk 2.000 [get_ports c_data*]目标频率100MHz。8×8阵列在Artix-7上综合下来,DSP用了64个,LUT约2000个,FF约1500个,时序余量0.8ns,能稳定跑100MHz。
如果扩展到16×16,DSP变成256个,时序余量会降到0.2ns左右,可能需要降到80MHz。这时候可以考虑流水化PE内部的乘法器,在乘法器和累加器之间插一级寄存器,代价是增加一个周期的延迟,但时序能大幅改善。
6. 常见问题与排查技巧实录
6.1 结果全错或部分错位
症状:仿真结果跟参考模型对不上,或者只有对角线元素对。
排查思路:
- 先检查延迟链的深度是否正确。A的第i行应该延迟i个周期,B的第j列延迟j个周期。我犯过的错是把行列搞反了。
- 检查PE的
clear信号时序。如果clear晚了一个周期,第一个乘积会被丢掉。 - 检查符号扩展。用负数测试用例跑一遍,如果负数结果错,就是符号位没处理好。
6.2 时序不收敛
症状:综合后时序报告显示setup violation。
排查思路:
- 看关键路径在哪里。通常是PE内部的乘法器到累加器这条路径。
- 解决办法:在乘法器输出和累加器之间插寄存器(流水化),或者降低目标频率。
- 另一个常见问题是复位网络。如果用了全局复位,复位信号的扇出很大,时序会差。改用局部同步复位。
6.3 资源不够用
症状:综合报错说DSP或LUT不够。
排查思路:
- 先算一下理论消耗:N×N个DSP,每个PE约30个LUT,N×N×30个LUT。
- 如果DSP不够,可以考虑时分复用:用N/2×N/2的阵列跑两轮,但控制逻辑会复杂很多。
- 如果LUT不够,检查延迟链是不是用了太多寄存器。N=16时延迟链是256个8位寄存器,可以改用BRAM。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 结果全为0 | clear信号一直有效 | 检查clear脉冲宽度 |
| 结果偏大 | 累加器位宽不够,溢出 | 增加ACC_WIDTH |
| 结果偏小 | 乘法结果被截断 | 检查符号扩展 |
| 只有部分PE有输出 | 使能信号没连到所有PE | 检查en信号布线 |
| 上板后结果随机 | 跨时钟域没处理 | 加同步器 |
| 时序违例 | 关键路径太长 | 流水化或降频 |
独家避坑技巧:在PE阵列的每个PE输出上加一个ILA(集成逻辑分析仪),抓几个关键PE的acc值,跟仿真波形对比。这比盲猜快得多。Vivado的ILA用起来很方便,但注意别加太多,否则资源不够。
7. 从8×8到更大规模:扩展思路与优化方向
8×8跑通之后,下一步自然是扩展。但直接放大阵列规模不是唯一的路,有几个方向可以考虑。
方向一:增加阵列规模。16×16、32×32,资源线性增长,时序压力也线性增长。适合对吞吐量要求极高的场景。
方向二:时分复用。用8×8的阵列跑16×16的矩阵,分4轮计算。资源不变,但计算时间变成4倍。适合资源受限但延迟不敏感的场景。
方向三:权重固定型改造。把B矩阵预加载到PE的本地寄存器,A矩阵流过。这样B不需要每轮重新注入,适合推理场景中权重固定的情况。改造工作量中等,主要是控制逻辑要改。
方向四:加入量化与反量化。在PE的输入和输出加移位和饱和逻辑,支持INT8输入、INT8输出,中间用INT32累加。这是实际部署中最常见的配置。
我个人建议先把8×8的输出固定型吃透,再根据实际需求选一个方向深入。脉动阵列的精髓在于数据流动的节奏控制,这个思维模式一旦建立起来,后面做卷积加速器、Transformer加速器都会轻松很多。
最后分享一个我在调试时的小习惯:每次改完代码,先跑一个2×2的小矩阵,用纸笔算一遍期望结果,跟仿真波形逐周期对比。2×2的时序关系最容易看清,确认无误后再放大到8×8。这个笨办法帮我省下了大量盲目调试的时间。