1. 脉动阵列:让矩阵乘法“动”起来
大家好,我是老张,在AI芯片和硬件加速领域摸爬滚打了十几年。今天想和大家聊聊一个听起来有点“硬核”,但实际非常有趣且强大的东西——高效可扩展的systolic矩阵乘法器。如果你正在做嵌入式视觉处理、AI推理加速,或者任何需要高性能矩阵运算的项目,但又被DSP资源、功耗和延迟搞得焦头烂额,那这篇文章可能就是你的“解药”。
脉动阵列(Systolic Array)这个名字听起来很学术,其实它的核心思想非常直观。想象一下,你有一个流水线工厂,原材料(数据)从流水线的一端有序进入,经过一个个工位(处理单元)的加工,最终在另一端产出成品(计算结果)。每个工位只做一件简单的事情(比如乘加运算),但所有工位同时工作,数据像血液在血管中脉动一样,规律地流动起来。这样一来,整个工厂的吞吐量就非常高,而且结构规整,特别适合用硬件(比如FPGA)来实现。
为什么矩阵乘法特别需要这种结构?因为矩阵乘法本质上就是大量的乘加运算。一个传统的串行处理器需要反复从内存中读取数据,计算,再写回,大量的时间都花在了数据搬运上,这就是所谓的“内存墙”。而脉动阵列的精妙之处在于,它通过精巧的数据流设计,让每个数据一旦被读入阵列,就能在流动过程中被反复使用,与沿途经过的多个处理单元发生“化学反应”,极大地减少了数据搬运的开销。我当年第一次在FPGA上实现一个小的脉动阵列,看到它流畅地吐出矩阵乘法的结果时,那种“优雅感”至今难忘。
所以,这篇文章的目标,就是带你从零开始,理解并动手设计一个你自己的、高效且能灵活扩展的脉动矩阵乘法器。我们不只讲原理,更会深入到Verilog代码层面,聊聊我在实际项目中踩过的坑和优化技巧。无论你是硬件新手,还是有一定经验的工程师,相信都能有所收获。
2. 核心架构:从原理到我们的设计蓝图
2.1 经典脉动阵列是如何工作的?
要设计我们自己的版本,必须先吃透经典脉动阵列的原理。我们以计算矩阵 C = A x B 为例,其中A是MxN矩阵,B是NxL矩阵。最经典的一种设计是让矩阵A的行数据从上向下流动,矩阵B的列数据从左向右流动。
阵列中的每一个小格子,我们称之为处理单元(Processing Element, PE)。每个PE内部通常有一个乘法器和一个累加器。当A的一行元素和B的一列元素在某个PE“相遇”时,它们就被相乘,结果累加到该PE本地存储的部分和上。随着时钟周期推进,A的行向下移动一格,B的列向右移动一格。经过精心设计的时序后,每个PE都完成了对应位置的一个乘积累加操作。最终,当数据流遍整个阵列,结果矩阵C的各个元素就在各个PE的累加器中“生长”完成了。
这个过程最酷的地方在于数据复用。A矩阵的每个元素,会在一条垂直线上被多个PE使用;B矩阵的每个元素,会在一条水平线上被多个PE使用。这完美匹配了矩阵乘法的计算模式,实现了极高的计算密度和能效比。
2.2 我们的改进:更通用、更“省心”的数据流
原始文章里提到的设计,已经是一个很好的起点。但我在实际项目中发现,一个真正好用的、可复用的IP,必须解决两个“麻烦事”:输入对齐和结果输出。
经典设计往往假设数据是完美对齐、按特定节奏输入的。但在真实系统中,数据可能来自不同的接口或缓存,格式五花八门。另外,计算完成后,如何高效地把分散在各个PE里的结果收集并输出,也是个问题。有些设计会为每个PE增加额外的输出寄存器和复杂的控制逻辑,这无疑增加了硬件开销和设计复杂度。
我借鉴了原始文章的思路,并做了一些强化。我的核心改进是:利用数据流本身来搬运结果。具体来说,在计算阶段,PE正常进行乘积累加。当计算全部完成后,我通过一个简单的模式切换,将整个阵列的数据流方向“改造”成一条输出流水线。这时,PE不再进行计算,而是将本地存储的最终结果,传递给右侧的下一个PE。就这样,结果像接力棒一样,从阵列最左侧的PE开始,一个一个向右传递,最终从最右侧的边界顺序输出。
这样做的好处太明显了:
- 极简的硬件开销:几乎不需要为输出增加额外的专用寄存器或复杂的读取电路。PE内部用于暂存部分和的寄存器,在输出阶段被复用为移位寄存器。
- 规则的控制:整个输出过程可以用一个统一的、简单的状态机来控制,所有PE的行为一致,非常利于时序收敛和布局布线。
- 天然的流水线:结果输出本身也成了流水线,可以和下一轮计算重叠(如果设计允许),进一步隐藏延迟。
下面这个简化的模块接口,展示了我设计的PE核心是如何支持这种模式的。关键就在于那个mode信号,它像是一个指挥棒,告诉PE现在是该“计算”还是该“传递结果”。
module pulse_arrays_pe #( parameter WIDTH_left = 8, parameter WIDTH_up = 8, parameter WIDTH_out = 16 )( input wire clk, input wire rst, input wire [1:0] mode, // 核心控制信号:0-计算,2-右移输出 input wire [WIDTH_left-1:0] left_in, // 从左邻PE来的数据 input wire [WIDTH_up-1:0] up_in, // 从上邻PE来的数据 output reg [WIDTH_left-1:0] right_out, // 向右邻PE去的数据 output reg [WIDTH_up-1:0] down_out, // 向下邻PE去的数据 output reg [WIDTH_out-1:0] result // 本地结果(部分和或最终结果) ); wire [WIDTH_out-1:0] product; // 乘法器瞬时结果 always @(posedge clk) begin if (!rst) begin right_out <= 0; down_out <= 0; result <= 0; end else begin case (mode) 2‘b00: begin // 计算模式 // 数据向前传递 right_out <= left_in; down_out <= up_in; // 乘积累加:result = result + A * B result <= result + product; end 2‘b10: begin // 输出移位模式 // 将本地结果向右传递 right_out <= result; // 清空本地结果,为下次计算准备(可选) result <= 0; end default: begin // 其他模式,如直通 right_out <= left_in; end endcase end end // 实例化一个流水线乘法器 pipeline_multiplier #( .WIDTH_A(WIDTH_left), .WIDTH_B(WIDTH_up) ) mul_inst ( .clk(clk), .a(left_in), .b(up_in), .p(product) ); endmodule3. 硬件实现的关键细节与优化策略
3.1 处理单元(PE)的微架构设计
PE是整个阵列的细胞,它的设计直接决定了阵列的性能和效率。上面给出了一个基础版本,但里面有很多细节可以打磨。
首先是乘法器的选择。在FPGA里,你可以用专用的DSP Slice,也可以用逻辑单元(LUT)搭建。对于位宽较小(比如8x8)且对时序要求苛刻的设计,直接用DSP是最稳的,因为它速度快、功耗低。但DSP数量是有限的宝贵资源。如果你的阵列规模很大,或者想做位宽可配置的通用设计,可能就需要用LUT来构建乘法器。这时,一个精心设计的流水线乘法器就至关重要了。你需要根据目标时钟频率,将乘法操作拆分成若干级流水线,每一级寄存器打拍,这样既能跑高频,又能让每个PE在一个周期内完成一次乘积累加的核心操作。我通常会为乘法器单独做一个高度参数化的模块,方便在不同项目中复用。
其次是累加器的位宽管理。两个8位数相乘,结果是16位。如果累加N次,结果位宽可能会增长。你需要提前估算最坏情况下的动态范围,为WIDTH_out参数设置足够的位宽,防止溢出。但同时,位宽越大,消耗的寄存器资源和后续的加法器逻辑也越多,需要在精度和资源间权衡。一个实用的技巧是,如果知道矩阵元素的范围(比如是定点数或经过量化的整数),可以采用饱和加法而非环绕加法,这样可以用更小的位宽保证结果正确。
最后是模式控制信号的布线。mode信号需要广播到阵列中的每一个PE。在大型阵列中,这可能会成为一个时序瓶颈。我的经验是,将这个控制信号也进行流水线打拍,或者将其作为与数据流同步的“伴随信号”一起在阵列中传播,确保它到达每个PE的延迟是可预测和一致的。
3.2 可扩展的阵列生成与连接
一个优秀的矩阵乘法器IP,必须能像乐高一样轻松拼装,适应不同规模的矩阵运算。这就需要用到Verilog的generate语句。我们的顶层模块pulse_arrays应该用参数来定义阵列的行数(Mritx_M,对应输出矩阵C的行)和列数(Mritx_L,对应输出矩阵C的列)。
连接逻辑是generate块的核心。你需要根据PE在阵列中的位置(是否在第一行、第一列、最后一行、最后一列)来决定它的上下左右连接对象。边界的PE需要连接顶层模块的输入输出端口,内部的PE则连接相邻的PE。原始文章的代码已经给出了一个很好的模板,但我在实践中会把它写得更清晰一些,比如为连线数组定义更有意义的typedef,或者将连接逻辑封装成函数,以提高代码的可读性和可维护性。
这里还有一个关键点:输入数据的对齐延迟。矩阵A的第一行元素需要直接进入第一行PE,但第二行元素需要延迟一个周期再进入,第三行延迟两个周期,以此类推,这样才能保证数据在阵列中正确“相遇”。同样,矩阵B的列数据也需要水平方向的延迟。原始设计中使用了shift_register模块来实现这个功能。这是一个非常标准的做法,你需要根据阵列的规模,实例化正确深度的移位寄存器链。确保这些延迟链的深度参数与阵列行列索引正确关联,这是调试时最容易出错的地方之一。
3.3 精妙的状态机与控制逻辑
控制逻辑是阵列的“大脑”。它不需要很复杂,但必须精确。一个典型的状态机包含三个状态:
- 空闲(IDLE):等待输入有效信号,准备好接收数据。
- 输入与计算(COMPUTE):在此状态下,有效数据被送入阵列,同时
mode信号设置为计算模式。这个状态需要持续足够的周期,确保所有数据都流过阵列并完成计算。所需周期数 =M + N + L - 2 + 乘法器流水线级数。你需要一个计数器来精确控制这个时长。 - 结果输出(OUTPUT):计算完成后,状态机切换到输出模式,将
mode信号改为输出移位模式。同时,启动另一个计数器,控制输出持续Mritx_L(阵列列数)个周期,将每一列的结果依次移出。
控制逻辑的另一个任务是生成mode_control信号。在输出阶段,你可能希望一行一行地输出结果。这时可以设计一个移位寄存器,每个周期产生一列PE所需的模式信号。例如,初始时只让最左边一列的PE处于输出模式,下一周期让模式信号右移,激活下一列,如此反复,实现结果的顺序扫描输出。
4. 实战:从代码到上板调试
4.1 仿真验证:搭建你的测试平台
写好了RTL代码,千万别急着上板,充分的仿真能帮你节省大量调试时间。我会用SystemVerilog搭建一个带约束的随机测试平台(CRT)。
首先,写一个简单的任务(task)来模拟数据输入。这个任务根据预设的矩阵维度,在正确的时钟周期,将矩阵A和B的数据按脉动阵列要求的顺序(通常是A按行,B按列)送到模块输入端口。同时,在测试平台内部,用一个行为级的矩阵乘法函数ref_model = A * B计算出期望结果(黄金参考)。
然后,在输出阶段,监视模块的输出总线。因为我们的设计是顺序输出结果的,所以测试平台需要按照同样的顺序,将输出的数据流重新“组装”成一个结果矩阵。最后,将这个组装好的矩阵与黄金参考模型进行逐元素比较。任何不匹配都需要打印详细的错误信息,包括时钟周期、矩阵坐标、实际值和期望值。
我强烈建议覆盖以下测试场景:
- 随机小矩阵(如3x3):用于快速验证功能正确性。
- 随机大矩阵(如16x16, 32x32):验证可扩展性和深度流水线的正确性。
- 边界情况:全零矩阵、单位矩阵、包含最大/最小值的矩阵,测试累加和溢出的处理。
- 背靠背(back-to-back)计算:在一组计算结束后立即开始下一组,测试状态机能否正确复位和重启。
4.2 FPGA实现与优化技巧
仿真通过后,就可以进行综合和布局布线了。这里有一些实战中的优化技巧:
资源优化:FPGA最宝贵的资源是DSP、BRAM和寄存器。我们的设计消耗的主要是DSP(用于乘法)和寄存器(用于流水线和累加)。如果DSP不够用,可以考虑时间复用一个乘法器,但这会增加控制复杂度,可能违背脉动阵列的简洁性。另一种思路是降低位宽或阵列规模。寄存器的消耗与阵列规模和流水线深度成正比,确保你的设计在目标器件资源范围内。
时序收敛:脉动阵列结构规整,通常时序比较好。但也要注意关键路径。最常见的关键路径在PE内部,是从输入寄存器 -> 乘法器 -> 加法器 -> 累加器寄存器这条链。确保乘法器和加法器都充分流水线化。如果时序报告显示建立时间(setup time)违例,可以尝试:
- 增加流水线级数。
- 将
mode等控制信号用寄存器多打一拍。 - 使用综合工具的“寄存器重定时”(retiming)选项,让工具自动优化寄存器位置。
功耗考虑:在控制状态机中,当阵列处于空闲状态时,可以生成时钟门控(clock gating)使能信号,关闭阵列内部大部分寄存器的时钟,从而显著降低静态功耗。许多FPGA工具链支持根据代码中的使能信号自动插入时钟门控逻辑。
4.3 性能评估与扩展思考
如何评价你的设计好坏?主要看三个指标:
- 吞吐量(Throughput):单位时间内能完成多少次乘加运算(MAC)。对于一个MxNxL的矩阵乘法,理想情况下,在数据流充满后,每个时钟周期都能完成 ML 次MAC操作(因为每个PE都在工作)。吞吐量接近 ML / cycle。
- 延迟(Latency):从输入第一个数据到输出第一个有效结果所需的时钟周期数。这包括了数据填充阵列的时间和乘法器的流水线延迟。
- 能效(Efficiency):每次MAC运算消耗的能量或资源。这需要在性能和功耗/面积之间取得平衡。
我们的设计已经具备了很好的可扩展性。你可以通过修改参数,轻松生成一个4x4或64x64的阵列。但真正的挑战在于处理超大规模矩阵。当矩阵尺寸远大于阵列尺寸时,你需要用到“分块”策略。将大矩阵切成小块,每次将一小块A和一小块B加载到阵列中计算,得到结果块的一部分,然后通过外部存储和控制器来协调这些块的计算与累加。这就将脉动阵列从一个完整的计算引擎,升级为一个强大的计算核心,能够应对任意规模的矩阵运算。这涉及到更复杂的数据搬运和缓存设计,是下一个层次的挑战,也是将你的设计推向实用的关键一步。
我在好几个边缘AI加速项目里都用到了这个设计思路。有一次,为了在一个资源极其有限的FPGA上实现一个轻量级卷积神经网络,我把卷积核展开成矩阵乘法,然后用一个8x8的脉动阵列来加速。通过精心设计数据流和分块,最终在满足实时性要求的同时,功耗比用通用处理器降低了整整一个数量级。这种把复杂算法用简洁、高效的硬件流淌出来的感觉,正是数字设计的魅力所在。希望我的这些经验,能帮你少走些弯路,更快地打造出属于你自己的高性能计算引擎。