☰
脉动阵列原理与AI芯片设计实战:从矩阵乘法到TPU架构
2026/10/7 6:08:26 网站建设 项目流程

1. 从一次面试翻车说起:为什么脉动阵列值得单独拎出来讲

几年前我去面试一家做AI加速器的公司,技术负责人问了我一个问题:“如果让你从零设计一个矩阵乘法加速器,你会怎么组织数据流?”我当时脑子里第一反应是“并行展开、多PE堆叠”,然后就开始讲怎么把乘加单元铺开、怎么调度。对方听完沉默了几秒,说了一句让我记到现在的话:“你描述的是一个SIMD阵列,不是脉动阵列。你知道两者最本质的区别在哪吗?”

那次面试我挂了,但这个问题我回去查了很久。后来我才明白,脉动阵列(Systolic Array)之所以在AI芯片领域被反复提起,不是因为它“快”,而是因为它解决了一个更根本的问题:在算力、带宽和功耗三者之间找到那个微妙的平衡点。尤其是当Transformer类模型把矩阵乘法推到极致的时候,脉动阵列几乎成了绕不开的架构选择。

这篇文章我想把脉动阵列这件事从头到尾讲清楚。不管你是刚接触AI芯片的学生,还是正在做加速器设计的工程师,或者只是好奇“为什么TPU要用脉动阵列”的技术爱好者,我都会从基本原理讲到实操细节,从数据流设计讲到踩坑经验。文章会涉及不少硬件层面的东西,但我会尽量用生活化的类比来解释,保证你不需要有流片经验也能看懂。

先给一个最直观的理解:脉动阵列就像一条工厂流水线,数据像血液一样在阵列中“脉动”流动,每个计算单元只做一件小事,但所有单元同时在工作。它的核心思想是让数据复用最大化,让访存最小化。这个思路在矩阵乘法这种“数据量大、计算密集”的场景里,效果极其明显。

2. 脉动阵列到底在解决什么问题

2.1 从矩阵乘法说起:为什么它成了AI芯片的命门

任何做过深度学习推理的人都知道,卷积、全连接、注意力机制,底层拆开来看全是矩阵乘法。一个典型的Transformer层,QKV投影、注意力打分、输出投影、FFN两层,加起来就是四次大矩阵乘。参数量动辄几十亿,每次推理要做的乘加运算(MAC)数量是天文数字。

问题在于,通用处理器(CPU)和图形处理器(GPU)在处理这种任务时,都有各自的瓶颈。CPU的ALU数量有限,大部分时间花在取指、译码、访存上;GPU虽然并行度高,但它的寄存器文件和共享内存之间的数据搬运功耗,往往比计算本身还大。有研究数据表明,在65nm工艺下,一次32位浮点乘加的能耗大约是3.1pJ,而从DRAM取一个32位数据的能耗是它的几百倍。也就是说,算得再快,喂不进去数据也是白搭。

脉动阵列的思路就是:既然访存这么贵,那我就让数据在芯片内部多流动、少往返。数据从边缘流入,经过一个个计算单元,每个单元顺手做一次乘加,然后把结果往下一个单元传。数据像波浪一样在阵列中推进,每个周期都有新的数据进入、旧的数据流出,整个阵列始终处于工作状态。

2.2 和SIMD阵列的本质区别:数据复用方式不同

很多人会把脉动阵列和SIMD阵列搞混,包括当年的我。两者的PE(处理单元)都是二维排列,都做并行计算,但数据流动方式完全不同。

SIMD阵列里,每个PE通常从共享内存或寄存器文件里读取操作数,计算完再写回去。数据是“广播”式的,所有PE在同一时刻拿到相同的数据,然后各自和不同的权重相乘。这种方式的问题在于,每次计算都要访问一次存储,带宽压力大,功耗也高。

脉动阵列里,数据是“流动”的。以经典的输出驻留(Output Stationary)或权重驻留(Weight Stationary)数据流为例,权重预先加载到PE中并保持不动,激活值从左侧流入,部分和从上往下累加。每个PE只负责一次乘加,然后把激活值传给右边的PE,把部分和传给下面的PE。数据在阵列中“脉动”前进,每个周期只移动一步。

这个区别带来的效果是巨大的。在SIMD阵列中,如果阵列是N×N,每个周期需要从内存读取2N个操作数;而在脉动阵列中,每个周期只需要从边缘输入N个激活值和N个权重(权重可以预加载),内部的数据搬运全部在PE之间完成。访存次数从O(N²)降到了O(N),这就是脉动阵列最大的价值。

2.3 为什么AI芯片特别适合脉动阵列

脉动阵列不是新东西,上世纪80年代CMU的H.T. Kung就提出了这个概念,最早用于信号处理和线性代数。但它在AI芯片时代才真正爆发,原因有几个。

第一,AI计算以矩阵乘法为主,计算模式规整,非常适合脉动阵列这种“规则数据流”架构。第二,AI推理对延迟有一定容忍度,但对吞吐量和能效比要求极高,脉动阵列的高数据复用率正好契合。第三,现代AI芯片的片上存储(SRAM)容量越来越大,可以把权重和部分激活值缓存在片上,进一步减少对DRAM的访问。

Google的TPU v1是最著名的脉动阵列商用案例,256×256的MXU(矩阵乘法单元),峰值算力92 TOPS,功耗只有40W左右。这个能效比在当时的GPU面前是碾压级的。后来TPU v2、v3、v4不断迭代,脉动阵列的规模和数据流设计也在进化,但核心思想没变。

3. 脉动阵列的核心原理拆解

3.1 一个PE长什么样:乘加单元的最小结构

脉动阵列的基本单元是PE(Processing Element),每个PE的核心就是一个乘加器(MAC)加几个寄存器。最简单的PE结构包括:一个乘法器、一个加法器、一个用于暂存权重的寄存器、一个用于暂存部分和的寄存器,以及若干用于数据传递的寄存器。

以权重驻留型脉动阵列为例,每个PE的权重在计算开始前加载好,计算过程中保持不变。激活值从左侧输入,进入PE后与权重相乘,乘积加上从上方传来的部分和,结果传给下方PE;同时激活值直接传给右侧PE。整个过程只需要一个时钟周期,PE内部没有复杂的控制逻辑,也不需要访存。

这种设计的精妙之处在于,PE的结构极其简单,可以大规模复制。一个256×256的阵列就是65536个PE,每个PE只有几百个门电路,整体面积和功耗都可控。相比之下,如果每个PE都配一套独立的访存和控制逻辑,面积和功耗会爆炸。

3.2 数据流动的三种经典模式

脉动阵列的数据流设计是核心中的核心,不同的数据流模式决定了权重、激活值、部分和谁驻留、谁流动。常见的有三种:

权重驻留(Weight Stationary, WS):权重预加载到PE中保持不动,激活值从左侧流入、从右侧流出,部分和从上往下累加。这种模式适合权重复用率高的场景,比如卷积层中同一个卷积核在多个位置滑动。

输出驻留(Output Stationary, OS):部分和留在PE中累加,权重和激活值从不同方向流入。这种模式适合输出通道数较多的场景,可以减少部分和的搬运。

行驻留(Row Stationary, RS):这是Eyeriss提出的一种数据流,结合了WS和OS的优点,在行方向上做权重驻留,在列方向上做输出驻留,进一步优化了能耗。

实际芯片设计中,往往不是纯粹的一种模式,而是根据网络结构做混合设计。比如TPU的MXU采用的是权重驻留+输出驻留的混合方式,权重在阵列中保持,部分和在列方向累加。

3.3 数据复用率怎么算:一个具体的例子

假设我们有一个N×N的脉动阵列,要计算两个N×N矩阵的乘法。在权重驻留模式下,权重矩阵的每一行加载到阵列的每一行PE中,激活矩阵的每一列从左侧流入。

第一个周期,激活矩阵的第一列进入阵列,与权重矩阵的第一列相乘,得到部分和。第二个周期,第一列激活值向右移动一格,第二列激活值进入,同时第一列的部分和向下移动一格。以此类推,经过2N-1个周期后,所有计算完成。

在这个过程中,每个权重被复用了N次(因为激活矩阵有N列),每个激活值也被复用了N次(因为权重矩阵有N行)。总访存次数是2N²(加载权重和激活),而计算量是N³次乘加。计算访存比达到了N/2,当N=256时,这个比值是128。也就是说,每从内存取一个数据,可以做128次乘加。这个比例在SIMD架构中很难达到。

4. 动手设计一个简单的脉动阵列

4.1 用Python模拟4×4脉动阵列的行为

在写RTL之前,我习惯先用Python把数据流模拟一遍,确认逻辑正确。下面是一个4×4权重驻留脉动阵列的简化模拟,计算两个4×4矩阵的乘法。

import numpy as np def systolic_array(A, B, N=4): """ A: 激活矩阵 (N x N) B: 权重矩阵 (N x N) 返回: C = A @ B """ # 权重驻留: 每个PE保存B的一行 # PE[i][j] 保存 B[i][j] pe_weight = [[B[i][j] for j in range(N)] for i in range(N)] # 部分和寄存器, 初始为0 psum = [[0 for _ in range(N)] for _ in range(N)] # 激活值寄存器, 每个PE有一个 act_reg = [[0 for _ in range(N)] for _ in range(N)] # 总共需要 2N-1 个周期 total_cycles = 2 * N - 1 results = [[0 for _ in range(N)] for _ in range(N)] for cycle in range(total_cycles): # 从左侧输入激活值 for i in range(N): col_idx = cycle - i if 0 <= col_idx < N: act_reg[i][0] = A[i][col_idx] else: act_reg[i][0] = 0 # 每个PE做乘加 new_psum = [[0 for _ in range(N)] for _ in range(N)] new_act = [[0 for _ in range(N)] for _ in range(N)] for i in range(N): for j in range(N): # 乘加 new_psum[i][j] = psum[i][j] + act_reg[i][j] * pe_weight[i][j] # 激活值向右传递 if j < N - 1: new_act[i][j+1] = act_reg[i][j] # 部分和向下传递 for i in range(N-1, 0, -1): for j in range(N): new_psum[i][j] = new_psum[i-1][j] if i > 0 else new_psum[i][j] # 更新寄存器 psum = new_psum act_reg = new_act # 记录最下面一行的部分和作为输出 for j in range(N): if cycle >= N - 1: results[N-1][j] = psum[N-1][j] return np.array(results) # 测试 A = np.random.randint(0, 5, (4, 4)) B = np.random.randint(0, 5, (4, 4)) C_systolic = systolic_array(A, B) C_ref = A @ B print("脉动阵列结果:\n", C_systolic) print("参考结果:\n", C_ref) print("是否一致:", np.array_equal(C_systolic, C_ref))

这段代码虽然简化了很多细节(比如没有处理部分和的精确传递时序),但能帮你直观理解数据在阵列中的流动方式。实际RTL设计中,每个PE的寄存器更新、数据传递的时序都需要精确控制。

4.2 关键参数怎么定:阵列规模、数据位宽、时钟频率

设计脉动阵列时,有几个参数需要权衡:

阵列规模(N×N):N越大,数据复用率越高,但面积和功耗也越大。TPU v1选了256×256,是因为当时28nm工艺下,这个规模的面积和功耗在可接受范围内,同时能覆盖大多数矩阵乘法的维度。如果N太小,比如16×16,复用率不够,访存瓶颈依然存在;如果N太大,比如512×512,面积和功耗会急剧上升,而且很多矩阵乘法的维度可能填不满阵列,利用率下降。

数据位宽:AI推理通常用INT8或FP16,训练用FP32或BF16。位宽越窄,PE的面积和功耗越小,同样面积下可以放更多PE。TPU v1用的是INT8,后来TPU v2支持了FP32和BF16。实际选择要看目标场景:如果只做推理,INT8足够;如果要兼顾训练,需要支持浮点。

时钟频率:脉动阵列的关键路径通常比较短(一个乘加加几个寄存器),所以可以跑比较高的频率。但频率越高,功耗越大。TPU v1的时钟频率是700MHz,不算高,但配合256×256的阵列,峰值算力已经很高了。实际设计中,频率和阵列规模需要联合优化,找到能效比最高的点。

4.3 从Python到RTL:一个PE的Verilog实现

下面是一个简单的PE的Verilog代码,实现了权重驻留、激活值右传、部分和下传的功能。

module pe #( parameter DATA_WIDTH = 8, parameter ACC_WIDTH = 32 )( input wire clk, input wire rst_n, input wire load_weight, input wire [DATA_WIDTH-1:0] weight_in, input wire [DATA_WIDTH-1:0] act_in, input wire [ACC_WIDTH-1:0] psum_in, output reg [DATA_WIDTH-1:0] act_out, output reg [ACC_WIDTH-1:0] psum_out ); reg [DATA_WIDTH-1:0] weight_reg; reg [DATA_WIDTH-1:0] act_reg; reg [ACC_WIDTH-1:0] psum_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin weight_reg <= 0; act_reg <= 0; psum_reg <= 0; act_out <= 0; psum_out <= 0; end else begin if (load_weight) begin weight_reg <= weight_in; end act_reg <= act_in; psum_reg <= psum_in + act_reg * weight_reg; act_out <= act_reg; psum_out <= psum_reg; end end endmodule

这个PE每个周期做一次乘加,激活值延迟一拍输出,部分和延迟一拍输出。实际设计中,可能需要流水线更深来满足时序,也可能需要处理权重加载和计算的重叠。

5. 脉动阵列在AI芯片中的实际应用

5.1 TPU的MXU:最经典的商用案例

Google TPU v1的MXU是一个256×256的脉动阵列,支持INT8乘加,峰值算力92 TOPS。它的数据流是权重驻留型,权重从片上SRAM加载到阵列中,激活值从左侧流入,部分和从下方流出。

MXU的设计有几个值得注意的点。第一,它用了双缓冲(double buffering)来隐藏权重加载的时间,当一组权重在计算时,下一组权重已经在后台加载。第二,它的部分和累加器位宽是32位,防止INT8乘加溢出。第三,它的控制逻辑非常简化,因为脉动阵列的数据流是固定的,不需要复杂的调度。

TPU v1的实测性能在推理任务上比同时期的GPU和CPU高出一个数量级,能效比更是碾压。这直接证明了脉动阵列在AI推理场景中的价值。

5.2 其他AI芯片的脉动阵列变体

除了TPU,很多AI芯片也采用了脉动阵列或类似的数据流架构。比如华为昇腾的达芬奇架构,核心是一个3D Cube单元,可以看作脉动阵列的变体,支持更灵活的数据流配置。寒武纪的MLU系列用了类似的思路,但在数据流和存储层次上做了自己的优化。

还有一些针对特定场景的芯片,比如边缘端的AI加速器,会用小规模的脉动阵列(比如32×32或64×64),配合低功耗设计,在手机、摄像头、IoT设备上做实时推理。

5.3 脉动阵列的局限性和适用边界

脉动阵列不是万能的。它的优势在于规则的大矩阵乘法,但如果计算模式不规则,比如稀疏矩阵、动态形状的模型,脉动阵列的利用率会大幅下降。因为阵列是固定的,如果矩阵维度填不满,很多PE就闲置了。

另外,脉动阵列对片上存储的要求很高。权重和激活值需要在阵列附近有足够的SRAM来缓冲,否则数据供应不上,阵列就会饿死。TPU v1配了28MB的片上SRAM,这在当时是很大的面积开销。

还有一个问题是灵活性。脉动阵列的数据流是硬件固定的,如果要支持不同的计算模式(比如卷积、反卷积、注意力),需要额外的控制逻辑或者多次映射。相比之下,GPU的SIMT架构更灵活,但能效比不如脉动阵列。

6. 实操中容易踩的坑和排查技巧

6.1 数据对齐和边界处理

在实际部署脉动阵列时,矩阵维度往往不是阵列规模的整数倍。比如阵列是256×256,但某个矩阵是100×100,这时候需要做padding。padding的方式会影响计算结果的正确性和阵列利用率。

我踩过的一个坑是:padding时用了0填充,但部分和的累加逻辑没有正确处理边界,导致结果偏大。后来发现是因为padding的0也参与了乘加,虽然乘积是0,但部分和的传递时序错了,导致某些PE在错误的周期累加了无效数据。解决办法是在边界周期屏蔽无效的部分和传递,或者用mask信号控制。

6.2 权重加载和计算的重叠

权重加载需要时间,如果加载和计算串行,阵列的利用率会下降。常见的做法是双缓冲:用两块权重缓存,一块在计算时,另一块在加载。但双缓冲的控制逻辑比较复杂,容易出现时序冲突。

我的经验是,权重加载的带宽要匹配阵列的消耗速度。如果阵列每个周期消耗256个权重,加载带宽至少要达到这个数,否则计算会停顿。实际设计中,往往用DMA把权重从DRAM搬到片上SRAM,再从SRAM加载到PE,这个链路的带宽要仔细计算。

6.3 部分和溢出的排查

INT8乘加的结果是16位,但累加多次后可能溢出。比如256次累加,最大结果是256×255×255≈16.6M,需要25位才能表示。如果累加器只有16位,就会溢出。

排查溢出的方法是:在仿真时监控累加器的最高位,如果出现翻转,说明溢出了。解决办法是增加累加器位宽,或者在累加过程中做饱和处理。TPU v1用了32位累加器,就是为了防止溢出。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
计算结果偏大padding边界处理错误检查边界周期的部分和传递加mask信号屏蔽无效累加
阵列利用率低矩阵维度不匹配统计PE活跃周期占比调整阵列规模或做矩阵分块
时序不收敛关键路径太长静态时序分析插入流水线寄存器
功耗过高时钟频率太高或阵列太大功耗仿真降低频率或缩小阵列
数据供应不上片上SRAM带宽不足带宽仿真增加SRAM或优化数据流

7. 一些个人体会和后续可以深挖的方向

脉动阵列这个东西,入门容易精通难。基本原理几个小时就能看懂,但真正要做好一个可用的设计,需要反复迭代数据流、存储层次、控制逻辑。我在实际项目中最大的体会是:不要追求理论上的最优,而要追求工程上的平衡。阵列规模、位宽、频率、存储容量,每一个参数都会影响最终的性能和功耗,没有银弹。

如果后续要继续深入,我建议从几个方向入手。一是研究不同的数据流模式,比如Eyeriss的行驻留,看看在特定网络结构下能不能比权重驻留更优。二是探索稀疏脉动阵列,针对剪枝后的模型做优化,跳过零值计算。三是结合近存计算(Near-Memory Computing)的思路,把部分计算搬到存储旁边,进一步减少数据搬运。

最后分享一个小技巧:在写RTL之前,一定要用Python或C++把数据流模拟清楚,确认每个周期的数据位置和计算结果。直接写RTL调试的代价太高,仿真一次可能要几分钟,而Python模拟几秒钟就能跑完。这个习惯帮我省了很多时间。

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

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

立即咨询