第四章:脉动阵列(Systolic Array)——经典矩阵乘法加速器设计

各位好,今天我们来聊聊脉动阵列。说实话,这是我在AI芯片设计中最喜欢的一个架构,没有之一。为什么?因为它把「数据复用」和「计算并行」这两个核心问题解决得特别漂亮。我记得2018年做第一代NPU时,整个加速核的核心就是一块二维脉动阵列,那会儿为了调优它的数据流,我们团队熬了整整三个月。

4.1 脉动阵列的核心思想

脉动阵列这个名字,其实挺形象的。你想想看,数据就像血液一样,在计算单元之间「脉动」式地流动。每个计算单元只做一件事:乘加运算。然后数据从左到右、从上到下,一拍一拍地传递。

说白了,这是一种空间上的流水线。跟时间流水线不同,脉动阵列把计算单元铺在二维平面上,数据在单元间流动,每个单元处理完就传给邻居。这样做的好处是什么?

  • 数据复用率极高:同一个数据可以被多个计算单元使用
  • 局部通信为主:只有相邻单元之间才有连线,没有全局总线
  • 规整性好:所有单元结构相同,非常适合VLSI实现

关键洞察:脉动阵列的本质,是用「空间换时间」。把原本需要多次访存的操作,通过数据在阵列中的流动一次性完成。我见过太多团队在这个点上栽跟头——他们以为脉动阵列只是把乘法器排成矩阵,结果带宽根本喂不饱。

4.2 一维脉动阵列:从向量点积开始

我们先从最简单的一维情况说起。一维脉动阵列,本质上就是一组处理单元排成一行,用来做向量点积或者矩阵-向量乘法。

假设我们要计算:C = A × B,其中A是1×N的向量,B是N×1的向量。传统做法是循环累乘,但脉动阵列怎么做?

// 一维脉动阵列的伪代码
// 每个PE执行:c += a * b

// 初始化
for i = 0 to N-1:
    PE[i].c = 0

// 数据流动
for cycle = 0 to 2*N-2:
    // 每个周期,数据向右流动一格
    for i = 0 to N-1:
        if (cycle - i) >= 0 and (cycle - i) < N:
            a = A[cycle - i]
            b = B[i]
            PE[i].c += a * b

嗯,这里要注意。上面的代码看起来简单,但实际硬件实现时,数据到达的时序非常关键。我曾经在项目中遇到过一个bug:因为时钟树不平衡,导致数据到达不同PE的时间差了半个周期,结果算出来的结果全是错的。

实战技巧:一维脉动阵列的吞吐率是每个周期一个结果,但延迟是2N-1个周期。如果你的矩阵维度很大,这个延迟可能会成为瓶颈。我建议在系统层面做乒乓缓冲,用双缓冲来隐藏延迟。

4.3 二维脉动阵列:矩阵乘法的终极形态

二维脉动阵列,就是把一维的PE排成矩阵。每个PE负责一个输出元素的部分和。这是Google TPU的核心架构,也是目前最主流的矩阵加速方案。

我们来看一个经典的二维脉动阵列结构:

// 二维脉动阵列(MxN)的矩阵乘法
// C = A × B,A是M×K,B是K×N

// 每个PE(i,j)存储C[i][j]的部分和
// 数据流动方向:
//   - A矩阵的数据从左向右流动
//   - B矩阵的数据从上向下流动

for cycle = 0 to M+N+K-3:
    for i = 0 to M-1:
        for j = 0 to N-1:
            if (cycle - i - j) >= 0 and (cycle - i - j) < K:
                a = A[i][cycle - i - j]
                b = B[cycle - i - j][j]
                PE(i,j).c += a * b

这个数据流模式,业内叫「 systolic array with stationary weights」。什么意思?就是权重数据(B矩阵)预先加载到PE里,然后输入数据(A矩阵)在阵列中流动。每个PE内部有个寄存器存权重,这样权重就不用反复从外面读了。

性能分析:一个M×N的二维脉动阵列,每个周期可以完成M×N次乘加运算。但前提是数据能喂得进来。以Google TPU v1为例,它有256×256=65536个MAC单元,但需要同时从HBM读取256个输入和256个权重,这个带宽需求是巨大的。

4.4 三种主流数据流模式

在实际设计中,数据怎么在阵列里流动,有几种不同的流派。我整理了一个表格,方便大家对比:

数据流模式 权重存放 输入流动 输出流动 典型应用
Weight Stationary 固定在PE内 从左向右 从上向下 Google TPU
Input Stationary 从上向下 固定在PE内 从左向右 某些CNN加速器
Output Stationary 从左上到右下 从左上到右下 累加在PE内 脉动阵列原型

我个人习惯用Weight Stationary模式。为什么?因为在实际的神经网络推理中,权重是固定的,输入数据是变化的。把权重存在PE里,输入数据流过去,这样数据复用率最高。但要注意,如果网络层切换频繁,重新加载权重的开销会很大。

4.5 微架构设计要点

好了,理论讲完了,我们来点实际的。设计一个脉动阵列的微架构,有几个关键点必须考虑:

  1. PE内部结构:每个PE包含一个乘法器、一个加法器、一个累加寄存器、以及数据缓冲寄存器。乘法器和加法器的位宽要匹配,我建议至少32位浮点或16位定点。
  2. 数据通路:每个PE有四个方向的数据端口(东、西、南、北),但实际只需要两个方向(比如东和南)。数据在PE间的传递要用寄存器打拍,保证时序收敛。
  3. 控制逻辑:需要生成每个PE的使能信号和数据选择信号。这个控制逻辑的复杂度跟阵列大小成正比,我见过有人用状态机硬写,结果代码量爆炸。
  4. 边界处理:阵列边缘的PE需要跟外部存储器交互。这里要用到DMA控制器,把数据从DDR搬到阵列的输入缓冲。

避坑指南:我曾经在设计一个64×64的脉动阵列时,忽略了PE间的布线延迟。结果综合后时序根本跑不到目标频率。后来不得不把阵列拆成4个32×32的子阵列,中间加流水线寄存器。所以,大阵列一定要考虑物理设计的可行性。

4.6 脉动阵列的SVG结构图

下面这张图展示了一个4×4的二维脉动阵列,以及数据流动的方向。A矩阵的数据从左向右流,B矩阵的数据从上向下流,每个PE内部完成乘加运算。

二维脉动阵列结构图 (4×4) PE00 C00 PE01 C01 PE02 C02 PE03 C03 PE10 C10 PE11 C11 PE12 C12 PE13 C13 PE20 C20 PE21 C21 PE22 C22 PE23 C23 PE30 C30 PE31 C31 PE32 C32 PE33 C33 A0 A1 A2 A3 B0 B1 B2 B3 图例 A矩阵数据流(从左向右) B矩阵数据流(从上向下) PE单元 累加结果C

4.7 实际设计中的权衡

好了,最后我想聊聊实际设计中需要做的权衡。这些东西书上不会写,但做项目时一定会遇到。

阵列大小 vs. 芯片面积:阵列越大,算力越强,但面积也越大。而且面积不是线性增长的——布线资源、时钟树、电源网络都会变得复杂。我建议根据目标应用场景的矩阵维度来选择阵列大小。比如做图像处理的,常用3×3或5×5的卷积核,那阵列可以小一些;做全连接层的,矩阵维度可能上千,那阵列就要大。

数据精度 vs. 计算效率:浮点运算精度高,但硬件开销大。定点运算效率高,但要注意数值溢出。我个人习惯在训练时用浮点,推理时用定点。而且现在很多芯片支持混合精度,比如用16位做计算,32位做累加,这样既保证了精度,又节省了面积。

数据复用 vs. 灵活性:脉动阵列的数据复用率很高,但灵活性差。如果网络结构变化很大(比如从卷积变成LSTM),数据流模式就要重新设计。我见过有些团队在阵列外围加了一个可编程的互联网络,这样可以在不同数据流模式之间切换,代价是增加了面积和功耗。

我的建议:如果你是第一次设计脉动阵列,先从一维开始,验证数据流和控制逻辑的正确性。然后再扩展到二维。不要一上来就搞256×256的大阵列,那会把你逼疯的。我当年就是从8×8开始,一步步验证,最后才做到64×64。

脉动阵列的设计,说白了就是「数据流」和「计算单元」的舞蹈。数据怎么流、流多快、在哪儿停,这些决定了你的加速器性能。希望今天的分享能帮大家理清思路。下次见面,我们聊聊如何给脉动阵列配一个高效的存储系统——没有好的存储,再强的计算阵列也是白搭。


专注资料整理