第四章:脉动阵列(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 微架构设计要点
好了,理论讲完了,我们来点实际的。设计一个脉动阵列的微架构,有几个关键点必须考虑:
- PE内部结构:每个PE包含一个乘法器、一个加法器、一个累加寄存器、以及数据缓冲寄存器。乘法器和加法器的位宽要匹配,我建议至少32位浮点或16位定点。
- 数据通路:每个PE有四个方向的数据端口(东、西、南、北),但实际只需要两个方向(比如东和南)。数据在PE间的传递要用寄存器打拍,保证时序收敛。
- 控制逻辑:需要生成每个PE的使能信号和数据选择信号。这个控制逻辑的复杂度跟阵列大小成正比,我见过有人用状态机硬写,结果代码量爆炸。
- 边界处理:阵列边缘的PE需要跟外部存储器交互。这里要用到DMA控制器,把数据从DDR搬到阵列的输入缓冲。
避坑指南:我曾经在设计一个64×64的脉动阵列时,忽略了PE间的布线延迟。结果综合后时序根本跑不到目标频率。后来不得不把阵列拆成4个32×32的子阵列,中间加流水线寄存器。所以,大阵列一定要考虑物理设计的可行性。
4.6 脉动阵列的SVG结构图
下面这张图展示了一个4×4的二维脉动阵列,以及数据流动的方向。A矩阵的数据从左向右流,B矩阵的数据从上向下流,每个PE内部完成乘加运算。
4.7 实际设计中的权衡
好了,最后我想聊聊实际设计中需要做的权衡。这些东西书上不会写,但做项目时一定会遇到。
阵列大小 vs. 芯片面积:阵列越大,算力越强,但面积也越大。而且面积不是线性增长的——布线资源、时钟树、电源网络都会变得复杂。我建议根据目标应用场景的矩阵维度来选择阵列大小。比如做图像处理的,常用3×3或5×5的卷积核,那阵列可以小一些;做全连接层的,矩阵维度可能上千,那阵列就要大。
数据精度 vs. 计算效率:浮点运算精度高,但硬件开销大。定点运算效率高,但要注意数值溢出。我个人习惯在训练时用浮点,推理时用定点。而且现在很多芯片支持混合精度,比如用16位做计算,32位做累加,这样既保证了精度,又节省了面积。
数据复用 vs. 灵活性:脉动阵列的数据复用率很高,但灵活性差。如果网络结构变化很大(比如从卷积变成LSTM),数据流模式就要重新设计。我见过有些团队在阵列外围加了一个可编程的互联网络,这样可以在不同数据流模式之间切换,代价是增加了面积和功耗。
我的建议:如果你是第一次设计脉动阵列,先从一维开始,验证数据流和控制逻辑的正确性。然后再扩展到二维。不要一上来就搞256×256的大阵列,那会把你逼疯的。我当年就是从8×8开始,一步步验证,最后才做到64×64。
脉动阵列的设计,说白了就是「数据流」和「计算单元」的舞蹈。数据怎么流、流多快、在哪儿停,这些决定了你的加速器性能。希望今天的分享能帮大家理清思路。下次见面,我们聊聊如何给脉动阵列配一个高效的存储系统——没有好的存储,再强的计算阵列也是白搭。