☰
FPGA矩阵乘法加速:脉动阵列从原理到RTL实现
2026/10/6 22:13:49 网站建设 项目流程

做FPGA加速的人,迟早会在矩阵乘法这个坎上碰到“脉动阵列”四个字。我第一次在Xilinx文档里看到Systolic Array这个词时,第一反应是“这玩意儿是不是只存在于论文里”,后来做图像处理里的卷积加速、矩阵乘加速才发现,这东西不仅不是花架子,反而是FPGA上做密集型计算绕不开的一条正路。说白了,脉动阵列就是让数据像流水线一样在计算单元之间自动“流”过去,每个处理单元只跟邻居打交道,不需要全局广播一堆数据,矩阵乘法的性能优化空间就被打开了。这篇文章我就从“为什么要用它”讲起,一路讲到RTL怎么落地、参数怎么定、时序怎么收敛,最后把调试时踩过的坑也一并列出来。

1. 为什么矩阵乘法在FPGA上要用脉动阵列:从访存瓶颈说起

1.1 普通并行方案为什么跑不起来

矩阵乘法 C = A × B,假设A是M×K,B是K×N,那么一共要做 M×N×K 次乘累加。这个计算量本身并不复杂,复杂的是数据怎么喂给计算单元。如果按最直接的方式,把多个乘法器并联起来,每个乘法器需要同时拿到A矩阵和B矩阵的一个元素,那就意味着每个周期都要从存储里读出大量数据,再分别送到每个乘法器的输入端口。

问题就出在这里。FPGA上的数据存储无非两种:BRAM和寄存器(还有UltraRAM,这里先不提)。BRAM的端口数量是有限的,一个36Kb的BRAM在双端口模式下,一个周期最多读出两个数据。你想让32个乘法器并行工作,每周期就得提供至少64个数据,这远远超出了BRAM的读写能力。寄存器阵列倒是能提供足够宽的读取,但代价是资源爆炸——一个32×32的寄存器堆可能还行,做成分散在FPGA各处的寄存器组,光布线就能把时序拖垮。

这还没算数据广播的代价。如果A矩阵的某一行数据要同时送给所有列的乘法器,那就得从同一个存储位置把数据复制到很多个地方。在FPGA里,这种一对多的扇出会导致布线资源被大量占用,扇出过高还会明显拉低时钟频率。我实际测试过,一个32路并行、每路16bit输入的乘法器阵列,不做任何数据流优化,在Artix-7上综合后Fmax只有90MHz左右,而且BRAM端口成了明显的瓶颈,大量的时间浪费在数据搬运上。

所以“并行计算”听起来很美好,但FPGA上的瓶颈从来不是乘法器够不够,而是数据能不能及时送到乘法器的嘴边上。这时候就需要换一个思路:不是把数据搬运到计算单元旁边,而是让计算单元“流动”起来,数据从阵列的一端进来,沿途被各个计算单元按顺序使用一遍。

1.2 脉动阵列如何用“空间换带宽”

脉动阵列的基本思想,是让数据在计算单元(Processing Element,简称PE)组成的阵列里有节奏地、像脉搏一样地流动。每个PE只和相邻的PE通信,数据从阵列的边缘输入,经过一个PE后被计算、被使用,然后继续传给下一个PE。

这个设计最聪明的地方在于:它把“数据共享”变成了“数据流动”。传统方案里,一个数据要被多个乘法器使用,就得复制出多个副本,分别布线到每个乘法器。脉动阵列里,这个数据只需要输入到阵列的一端,然后沿着阵列“走”过去,每到一个PE就被使用一次。数据还是那个数据,但对带宽的需求从“一次并行供给N个”变成了“一个周期供给一个”,剩下的由时间和空间去换。

就拿矩阵乘法来说,如果让A矩阵的元素从左边进入阵列,B矩阵的元素从上面进入阵列,每个PE负责一个输出元素的累加,那么每个A元素会在同一行内从左到右流过所有PE,每个B元素会在同一列内从上到下流过所有PE。这样一来,一个A元素被这一行的N个PE都使用了一次,但只需要从片外或BRAM里读入一次。这种数据复用,直接解决了带宽瓶颈。

用个生活化的类比:传统方案相当于食堂开饭时,所有学生同时冲到窗口前,你觉得窗口够多就行,但通道不够宽,挤成一团。脉动阵列相当于把打饭窗口排成一排,学生排成队从窗口前依次走过,每个窗口只需要接待顺序走到自己面前的这个人。队伍是一个一个往前走的,通道压力小了很多,但整体的吞吐反而上来了。

1.3 什么场景真正适合用它

脉动阵列并非万能,它在特定场景下才发挥最大价值。适合的场景有三个特征:一是计算密度高,乘法累加操作远多于数据搬运;二是数据访问模式规则,比如矩阵乘、卷积、FIR滤波这类固定步长的数据流;三是数据可以被多次复用。

反过来,如果矩阵非常稀疏,比如稀疏矩阵里大部分元素是0,脉动阵列的优势就没了,因为你还得把0也当成正常数据流过去,浪费了计算周期。这时候更适合用“只在非零元素上计算”的稀疏加速方案。如果矩阵尺寸特别大,比如几千乘几千,已经远超片上存储容量,那就必须做tiling分块,把大矩阵切成一块块小矩阵,每一块用脉动阵列算,块与块之间做累加。这种情况下脉动阵列仍然有效,但要额外设计分块调度和部分和的处理逻辑。

我见过不少初学者一上来就打算做“大而全”的通用矩阵乘加速器,结果被复杂的分块逻辑和数据搬运折腾得够呛。我的建议是先从小尺寸、规则场景入手,把脉动阵列的数据流跑通、把时序收敛了,再考虑分块、多级流水这些进阶优化。基础没打牢就上高端玩法,最后往往是在Debug里消耗大量时间。

2. 脉动阵列工作原理:PE单元和数据流怎么配合

2.1 PE单元的内部结构

PE是整个脉动阵列的最小单元,负责完成一次乘累加运算。一个典型的PE内部包含:一个乘法器、一个加法器、若干寄存器(用于寄存输入数据、输出数据和累加结果),以及对应的Valid/Ready握手信号逻辑。

以16bit定点数为例,乘法器输入是两个16bit数,输出32bit,累加器再把32bit的乘法结果和之前累加的中间结果相加,得到新的部分和。这个累加器位宽通常比乘法输出再宽一些,防止多次累加后溢出。比如做256次累加,结果最大需要16+16+8=40bit,所以累加器一般留到40bit或48bit,最后再做截断或饱和处理。

别看PE结构简单,它的设计细节直接影响整个阵列的效率。最重要的一个细节是流水寄存器。如果不加流水,一次乘累加需要“取数→乘法→加法→写回”一个周期完成,组合逻辑路径太长,关键路径会卡在乘法器到加法器的链路上,Fmax根本上不去。加了流水寄存器,把乘法和加法拆到两个时钟周期,每个周期只做一级计算,Fmax可以轻松翻倍。代价是计算延迟多了几个周期,但对矩阵乘法这种批量计算任务来说,吞吐率远比单次延迟重要。

另外一个细节是数据寄存器的设计。每个PE内部通常需要寄存三个数据:A矩阵输入、B矩阵输入、累加值。为了实现“数据流经PE后继续传给下一个PE”,A和B的输入必须打一拍再输出到相邻PE;累加值则通常留在PE内部,直到整个计算完成后再输出。很多实现里会把累加值也作为数据流传给相邻PE,这个属于不同数据流映射的取舍,后面详细说。

2.2 以Output Stationary为例拆解数据流

脉动阵列的数据流映射方式有好几种,最常见的三种是Weight Stationary(权重固定)、Output Stationary(输出固定)和Input Stationary(输入固定)。它们各有适用场景,我用Output Stationary举例,因为它最直观:每个PE“负责”一个输出元素,计算过程中这个部分和一直待在PE里,A和B的数据则流动过阵列。

假设要算 3×3的矩阵乘,用一个 3×3 的PE阵列。A矩阵的元素从左边进入每一行,B矩阵的元素从上面进入每一列。初始状态下,所有PE的累加寄存器清零。第一个周期,a00从左上角PE的左侧进入,b00从左上角PE的上方进入,PE(0,0)计算 a00×b00 并累加。

第二个周期,a00向右流动到PE(0,1),a10从左侧进入PE(0,0);同时b00向下流动到PE(1,0),b01从上方进入PE(0,0)。这时候PE(0,0)计算的是 a10×b01,PE(0,1)计算的是 a00×b10……数据就这样像推牌一样,前一个数据被使用后传给下一个PE,新的数据从边缘补进来。

这样一个非常重要的规律出现了:aij 这个数据在第 i 行会依次经过所有N个PE,在每个PE里和对应列的B元素相乘。也就是说,一个A数据被复用了N次,但只从存储里读了一次。B数据同理,被复用了M次。最终经过合适的周期数后,每个PE里的累加值就是C矩阵对应位置的最终结果。

这段数据流描述看起来麻烦,写RTL时其实不复杂,核心就是“输入打拍传递”四个字。每个PE的A输出等于A输入寄存一拍,B输出等于B输入寄存一拍,这样数据在阵列里每周期前进一格,天然形成了脉动效果。

2.3 数据复用倍数怎么算

理解数据流后,可以算一下脉动阵列对带宽需求的理论值。传统并行方案里,每做M×N×K次乘累加,需要从存储读取 2×M×N×K 个操作数(除去初始数据),如果这些数据全都来自片外或BRAM,带宽需求极高。脉动阵列把数据复用到了极致,同样计算量下,A矩阵元素只需读入 M×K 次,B矩阵元素只需读入 K×N 次,C矩阵输出 M×N 次。

数据复用倍数 = 直接读写的数据量 / 脉动阵列实际读写的数据量 ≈ (2×M×N×K) / (M×K + K×N + M×N)。在 M=N=K=8 时,这个值大约等于 4.57;在 M=N=K=64 时,大约等于 21.3;矩阵越大,复用倍数越高,带宽压力越小。这就是为什么脉动阵列在深度学习加速里被广泛采用——大矩阵计算时,访存压力可以降低一个数量级。

当然这个计算是理想情况,实际还要考虑数据输入时的初始延迟、边界处理、分块开销等。但从架构选型的角度,这个复用倍数是决定“方案可不可行”的核心指标。如果复用倍数算下来不到2,那脉动阵列的收益就很有限,不如直接做并行乘法器阵列加广播,可能还更简单。

3. 一个8×8脉动阵列的完整实现过程(RTL级别)

3.1 顶层接口和参数怎么定

讲完原理,落到实践。我以一个8×8的脉动阵列为例,说明完整的RTL实现过程。这里的8×8指的是PE阵列规模,对应计算两个8×8矩阵的乘法。实际场景中PE规模不一定等于矩阵规模,但先从最简单的场景入手,方便理解。

顶层接口设计如下:

module systolic_8x8 #( parameter DATA_W = 16, parameter ACC_W = 40 )( input clk, input rst_n, input valid_in, input [DATA_W-1:0] a_in [0:7], // A矩阵一行数据,同时输入8个列元素 input [DATA_W-1:0] b_in [0:7], // B矩阵一行数据,同时输入8个行元素 output reg valid_out, output reg [ACC_W-1:0] c_out [0:7][0:7] // 8x8输出结果 );

这里有个重要的设计决策:a_in和b_in是“一阵”输入8个值,而不是每个周期只输入一个。为什么?因为脉动阵列的每个PE在同一时刻需要接收到不同位置的数据,如果数据一个一个地串行进入,需要额外的缓存和调度逻辑,吞吐率会打折扣。实际工程里,输入数据一般由DMA从DDR搬运到BRAM,再从BRAM按周期并行读出8个值喂给阵列。这8个值同时进来后,在数组内部靠寄存器打拍逐步散开。

valid_in是输入有效标志,告诉阵列“这8个值可以开始计算了”。valid_out是输出有效标志,表示C矩阵结果已经计算完毕、可以读取。rst_n是异步复位、同步释放的复位信号,这个对FPGA设计尤其重要,我后面会单独说。

3.2 PE单元Verilog代码

PE单元的代码是整个设计的地基。下面是一个带两级流水的PE实现:

module pe #( parameter DATA_W = 16, parameter ACC_W = 40 )( input clk, input rst_n, input valid_in, input [DATA_W-1:0] a_in, input [DATA_W-1:0] b_in, input [ACC_W-1:0] c_in, // 来自上一个PE的部分和(本设计中不使用) output reg [DATA_W-1:0] a_out, output reg [DATA_W-1:0] b_out, output reg valid_out, output reg [ACC_W-1:0] c_out ); reg [DATA_W-1:0] a_reg, b_reg; reg [DATA_W*2-1:0] mul_result; reg [ACC_W-1:0] acc, acc_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin a_reg <= 0; b_reg <= 0; valid_out <= 0; mul_result <= 0; acc <= 0; acc_reg <= 0; c_out <= 0; end else begin a_reg <= a_in; b_reg <= b_in; valid_out <= valid_in; mul_result <= $signed(a_in) * $signed(b_in); if (valid_in) acc <= acc + $signed(mul_result); c_out <= acc; end end endmodule

注意几点。第一,数据输出a_out和b_out本质上就是a_reg和b_reg,打了一拍后传给下一个PE。第二,multiplication用的是有符号数,因为矩阵元素可能是负数。有人说FPGA乘法器可以直接用乘号,综合工具会自动映射到DSP48,不用自己例化原语,这在Xilinx和Altera(现在叫Intel)的流程里都对,代码也更可读。第三,累加器有个小陷阱:valid_in只拉高一个周期就结束了,但累加要持续K个周期,也就是每个PE收到的valid信号要“持续”K个周期才算一次完整的乘累加。我为了简化代码,这里的valid控制是简化的,实际工程里要用一个计数器控制每个PE累加的次数,不能靠一个周期的valid_in。

完整的PE设计还需要在累加完K次后把acc锁存到输出寄存器,同时把acc清零开始下一轮计算。这个控制逻辑放在阵列级统一管理比放在PE内部更清晰,我下面讲阵列级调度时会说明。

3.3 整体阵列组装与调度时序

把8×8个PE实例化,连接方式遵循一个规则:每个PE的a_out接到右边PE的a_in,每个PE的b_out接到下边PE的b_in,数组最左边和最上边的输入来自顶层。用generate语句批量实例化,代码很简洁:

genvar i, j; generate for (i = 0; i < 8; i = i + 1) begin : row for (j = 0; j < 8; j = j + 1) begin : col pe #(.DATA_W(DATA_W), .ACC_W(ACC_W)) u_pe ( .clk(clk), .rst_n(rst_n), .valid_in(valid_in), .a_in(i == 0 ? a_in[j] : pe_array[i-1][j].a_out), .b_in(j == 0 ? b_in[i] : pe_array[i][j-1].b_out), .c_in({ACC_W{1'b0}}), .a_out(pe_array[i][j].a_out), .b_out(pe_array[i][j].b_out), .valid_out(pe_array[i][j].valid_out), .c_out(pe_array[i][j].c_out) ); end end endgenerate

这里连接方式需要注意行列索引的对应关系。A矩阵从左边进入第i行,所以第0列的a_in来自顶层的a_in[j];第i行第j列的PE的a_in来自第i行第j-1列的a_out。B矩阵从上面进入第j列,同理。C矩阵的结果最后从每个PE的c_out取出,排列成8×8的结果矩阵。

数据调度比PE连接更容易出错。一个8×8矩阵乘,输入数据不是一次性全喂进去的,而是要按周期依次输入。以Output Stationary为例,A矩阵按行输入,每周期输入一行A数据(8个值)和一行B数据(8个值)。第0周期输入A的第0行和B的第0行,第1周期输入A的第1行和B的第1行……以此类推。数据进入阵列后,经过8个周期的传播,所有PE完成8次乘累加,C矩阵才算算完。

整个过程的时钟周期数大约是:数据输入8个周期 + 数据在阵列中流动的延迟若干周期 + 输出锁存几个周期 = 大约16到20个周期。这个数字比“每次输入一行”的原始周期数要大,但这只是第一个8×8矩阵的冷启动延迟。如果连续计算多个矩阵,采用流水线重叠方式,后续每个矩阵的间隔可以缩短到8个周期甚至更短,这时候的吞吐率才真正体现脉动阵列的优势。

3.4 资源、周期和带宽估算

动手综合之前,先做一轮估算很值得。以8×8阵列、16bit定点、Xilinx Artix-7系列为例:

  • DSP48资源:每个PE一个乘法器,共64个DSP48E1。Artix-7有90到740个DSP48,根据具体型号不同,64个一般能接受。
  • BRAM资源:如果A和B数据存储在片上,假设输入缓存各8×8×16bit = 2KB,两个矩阵4KB,用BRAM完全没压力。但如果做更大的矩阵,BRAM需求会线性上升,需要考虑分块和DDR交换。
  • 周期估算:算一个8×8矩阵,按前面的估算约16~20个周期。时钟频率200MHz时,耗时约100ns。
  • 带宽需求:每8个周期输入8×16bit×2 = 32字节,等效带宽 32B / (8/200MHz) = 800MB/s。这个带宽对于DDR3或DDR4来说非常轻松。

这个估算对比普通方案就很能说明问题:如果不用脉动阵列,64个乘法器并行工作,每个周期需要从存储读128个16bit数,即256字节/周期 @200MHz,等效51.2GB/s,这个带宽已经远超DDR3的带宽,除非数据能全部放在片上寄存器组,不然根本无法持续供数。脉动阵列通过数据复用量级化地压缩了带宽需求,这是它最核心的价值。

4. 性能优化:从能跑到跑得快的几个关键手段

4.1 用吞吐率和效率说话

评价一个矩阵乘法加速器,光看“能不能跑”远远不够,关键指标是两个数:吞吐率和计算效率。吞吐率一般用每周期完成的乘累加次数来表示,单位是MACs/cycle,或者换算成GOPS(每秒千兆次操作)。计算效率则是实际吞吐率除以理论峰值吞吐率。

8×8阵列的理论峰值是64 MACs/cycle。假设时钟200MHz,理论峰值64×200M = 12.8 GMACs = 25.6 GOPS(一次乘加算两次操作)。实际连续计算大矩阵时,如果每个8×8矩阵间隔恰好8个周期,那么计算效率就是 64/64=100%,因为在每个周期里所有PE都在做有效计算。但现实里几乎没有100%,因为输入数据要对齐、流水线要填充和排空。

测量效率有个简单办法:统计计算N个矩阵需要的总周期数T,计算效率 = (N×64) / (T×64) = N/T。比如连续计算1000个8×8矩阵,总共用了9000个周期,那么效率就是1000/9000 ≈ 11%,这说明大部分周期PE在空转,需要检查数据调度哪里出了问题。实际优化时,一般先追求“效率不低于70%”,再考虑其他。低于50%就要认真检查是不是流水线没有重叠、valid信号空档太多或者分块粒度太细。

4.2 流水线深度与时钟频率的取舍

做FPGA高性能计算,Fmax直接决定算力上限。同一个设计在100MHz和300MHz时钟下,吞吐率差3倍。提高Fmax最常见的手段就是加流水寄存器,把长组合逻辑路径拆短。

PE里乘法和加法是两个独立DSP或逻辑模块,它们之间的组合逻辑路径一般是关键路径。DSP48内部本身有流水寄存器,可以配置乘法结果直接寄存到DSP输出寄存器里;加法器也可以用DSP48内部的加法器实现,并把结果寄存到DSP的输出级。这样乘和加可以各自占用一个时钟周期,逻辑上形成2级流水。代价是每个PE的输入到输出延迟从1个周期变成2~3个周期,但流水线填满后,每个周期照样能接收新的计算任务,吞吐率不受影响。这就是典型的“延迟变高、吞吐不变”设计。

实际优化Fmax时我还会做几件事:一是给所有输出端口加输出寄存器(Output Register),避免扇出过大拖累时序;二是对a_in和b_in这些扇出较大、会同时送到多个PE的信号做扇出复制或加一级全局缓冲;三是面积允许的情况下,用Multi-Region约束把阵列限制在靠近DSP的区域内,减少布线延迟。

有个误区需要注意:单纯靠加流水级可以把Fmax从100MHz拉到250MHz,但如果数据输入频率跟不上,Fmax再高也没用。流水线解决了“逻辑关键路径”的问题,但解决不了“供数节奏”的问题。前面算过带宽需求,脉动阵列的优势恰恰在于带宽要求低,所以只要DMA和BRAM缓存设计合理,供数节奏很少成为瓶颈。

4.3 定点量化与DSP48利用

浮点运算在FPGA上非常昂贵。一个单精度浮点乘法器要占用好几个DSP48和大量LUT,而且浮点加法器延迟比定点高很多。矩阵乘法如果精度要求不是特别苛刻,强烈建议用定点数。

16bit定点数做乘法,8×8矩阵乘内部累加需要40bit位宽,这是前面算过的。实际深度学习场景更激进,8bit甚至4bit定点都能工作,代价是精度和动态范围下降,需要做量化感知训练或者在数据通路中添加截断、饱和逻辑。这块内容很大,我这里只提醒一点:位宽不是越小越好,关键看你的数据分布和数值范围。比如图像像素值一般是0~255的8bit非负数,乘法结果最大65025,256次累加最大约16.6M,用25bit累加器就够了;但如果数据是有符号16bit且动态范围大,就必须按前面的公式按最坏情况算位宽。

DSP48的使用也有一些经验。Xilinx的DSP48E1内部包含 25x18bit 乘法器,这意味着两个18bit以内的数相乘正好用一个DSP48,无额外开销。超过18bit的乘法就要用多个DSP拼接,效率会掉。所以设计数据位宽时,尽量让输入位宽不超过18bit是有利的。另外,DSP48支持级联累加,多个周期的乘累加可以直接在DSP内部完成,而不需要把乘法结果拉出来进LUT加法器再存回去。综合工具一般会做这种映射,但你需要确保代码风格是“累加器 + 乘法器”的标准写法,比如acc <= acc + a*b;,避免写成阻塞赋值或者把加法器拆到多个进程中,否则综合结果可能浪费资源。

4.4 与GPU/CPU做矩阵乘的性能对比思路

聊性能优化,离不开跟CPU和GPU做个对比。很多人一听到FPGA就觉得“肯定比CPU快,肯定比GPU慢”,这个说法太粗糙了。实际对比要按“有效算力”和“能效比”来算。

CPU的强项是灵活和通用,做矩阵乘法时靠SIMD指令和高速缓存,但在16bit定点场景下,CPU的算力优势远不如32bit浮点场景。GPU的优势在于大规模并行,尤其是浮点矩阵乘,配合CUDA和cuBLAS库,性能极其强悍,但功耗也高,而且延迟大。FPGA的优势在于:一是能效比高,同样做16bit定点矩阵乘,FPGA的每瓦性能往往优于CPU,甚至接近GPU;二是延迟低,流水线一旦填满,数据进入阵列到结果出来只有十几个周期的延迟,非常适合流式处理场景;三是接口灵活,可以直接和传感器、ADC或其他硬件对接,数据不需要经过操作系统和软件栈。

我的建议是不要拿FPGA去硬刚GPU做大规模稠密浮点矩阵乘,那是拿短板碰别人长板。FPGA适合的是“你在GPU上算得很快,但功耗受限、延迟受限、接口受限”的场景。比如便携设备里的神经网络推理、雷达信号处理里的矩阵运算、软件无线电里的波束成形计算,这些场景里16bit定点够用、延迟要求严格、功耗敏感,FPGA脉动阵列正是主力方案。

5. 常见问题与排查技巧实录

5.1 时序违例:数据路径太长怎么办

跑综合后Implementation报时序违例是家常便饭,尤其是第一次做脉动阵列,几乎必遇到。最常见的违例路径出现在两个地方:一是从BRAM输出到PE阵列第一行输入,因为BRAM读取延迟和数据扇出叠加;二是PE内部从乘法器输出到下一级累加器。

遇到违例,先别看报告里密密麻麻的节点,直接按这四步来。第一步,查看关键路径的起点和终点,确认是不是跨域或跨时钟域的问题。第二步,如果路径在PE内部,优先在中间插入流水寄存器,或检查乘法器/加法器是否真的被映射到了DSP48的流水级上。第三步,如果路径在BRAM到阵列之间,尝试把BRAM的输出寄存使能打开,或者在数据进入阵列前手动加一级寄存器。第四步,如果怎么优化都差一点,试着降低一点Fmax目标,比如从250MHz降到225MHz,从“高性能冲刺”转为“稳定收敛”。实际工程里,稳定可靠的200MHz比什么都好看但上板跑不稳的300MHz有价值得多。

5.2 数据对齐错位怎么查

脉动阵列最常见的功能Bug是数据错位:C矩阵的某些元素算出来的值和预期对不上,或者整体错了一行一列。排查这个问题,最有效的方法不是看综合报告,而是写一个以周期为单位的仿真激励,把A、B、C的数据流按周期逐个打出来,跟手算的理论结果对比。

我会在RTL里临时加两个调试计数器,一个记录数据输入开始的周期,一个记录valid_out拉高的周期。如果数据在一个PE里打了一拍,那么到(0,0)PE的时候,a和b分别是第几个周期进来的数据,必须和理论值一致。逐拍核对,很快就能定位是哪个PE的连接错位了。

还有一个容易忽略的点:valid信号的控制。数据在阵列里流动需要时间,valid_out不能直接等于valid_in,必须跟着数据一起打拍。如果valid_in只拉高一个周期,后面所有计算都是无效的;如果valid_in拉高8个周期不撤,PE就会连续累加8次,计数多了一轮。实际实现里,valid信号要按“有效输入持续K个周期”来设计,并跟数据一样逐级传递。

5.3 上板与仿真不一致,多半是复位和时钟域的问题

仿真里跑得好好的,烧到板子上结果就不对,这类问题排第一的原因就是复位。异步复位如果高电平有效,在时钟沿附近释放,可能会让寄存器进入亚稳态,导致部分寄存器复位成功、部分没复位成功,阵列里的数据路径就乱了。

推荐的做法是“异步复位、同步释放”,即复位信号先经过两级同步器,再接入所有寄存器的复位端口。另外,复位时所有PE的累加器必须清零,如果忘了清零,矩阵乘的结果会带上未知的初始值,而且这个错误是间歇性的,非常难查。我建议在RTL顶层加一个复位状态机,上电后先拉低复位至少100个周期,等到所有存储器和寄存器都稳定后再释放,然后才开始接收数据。

时钟方面,如果阵列工作在200MHz,输入数据来自另一个100MHz的时钟域,就必须做异步FIFO做跨时钟域缓冲。千万不要图省事直接在RTL里跨时钟域信号打bear,那只是仿真里的幻觉。

5.4 调试工具使用心得

FPGA调试,仿真和ILA配合使用效率最高。仿真阶段先在Testbench里验证小尺寸矩阵,比如2×2、3×3,看数据流是否逐拍对齐;通过后把矩阵规模放大到8×8,在板级用ILA抓取阵列第一行和第一列的信号,验证实际数据流动是否与仿真一致。

ILA调试有个技巧:触发条件不要只设valid信号,因为valid可能每个周期都是高的,抓到的数据没有参考点。我把触发条件设置为“valid_out第一次拉高”,然后抓取触发前几十个周期的数据,就能看到整个矩阵计算的完整流程。此外,ILA深度至少设到1024,有时一个矩阵计算才20个周期,但连续调试时你希望看到多个矩阵的流水过程,深度不够会漏数据。

还有一个心得:在PE内部寄存器信号名前加统一的调试前缀,比如dbg_,这样在综合时即使被优化掉,也可以通过在综合选项里设置keep属性保留下来。上了板子以后,这些信号就是定位问题的“摄像头”。

6. 一点实操心得

做FPGA脉动阵列这一年多,我最大的感受是:这个设计的关键不是“会写代码”,而是“想清楚数据流再写代码”。PE的连接方式、valid信号的传递、累加器的清零时机、矩阵分块的方式,每一样都得在动笔写RTL之前就想明白。代码只是把你想清楚的东西翻译成硬件描述语言而已。

有个细节我反复讲过很多次:矩阵乘法的累加器位宽,一定要按最坏情况预算,宁多勿少。因为算到后面数据溢出,表现出的Bug非常隐蔽——可能只是超大矩阵时偶尔几个元素不对,排查起来耗时极长。我在一个项目里就因为累加器少了一位,花了整整两天才定位到问题。

另外一个建议是,先做小、再做快。第一次做脉动阵列,先做一个4×4或8×8的小阵列,用仿真把数据流彻底跑通,再上板验证;确认没问题后,再考虑大矩阵、分块、多通道、DMA这些进阶设计。一上来就搭大系统,出问题根本不知道是架构问题、数据流问题还是时序问题,调试难度陡增。

最后分享一个技巧:设计脉动阵列的输入调度时,尽量在Testbench里用一个软件模型模拟理想数据流,比如用Python或C写一个简单的周期级模拟器,打印出“第n周期哪个数据进入哪个PE”,再跟RTL仿真的波形对比。这个模型不需要很复杂,几十行代码就够,但它能帮你把“数据流”这个概念从纸面变成可验证的参照,比对着波形猜效率高太多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询