1. 从矩阵乘法说起:为什么AI芯片需要脉动阵列
搞AI芯片的人,绕不开一个核心矛盾:算力需求增长的速度,远远快于数据搬运带宽的增长速度。这个矛盾在矩阵乘法上体现得最明显。深度学习的计算任务,无论是卷积还是全连接,拆到底层都是矩阵乘加运算。一个N×N的矩阵乘一个N×N的矩阵,需要N³次乘加,但数据量只有2N²。计算量是数据量的N倍,N越大,计算单元就越容易“饿着”——数据供不上,算力再强也是白搭。
传统CPU做矩阵乘法,靠的是指令流水线,每次从寄存器或缓存里取数、算、写回。问题在于,每个乘加操作都要访问一次存储,功耗和延迟都花在搬运上了。有数据统计,在先进工艺节点下,一次64位浮点乘加的能耗大约是一次64位数据搬运的几十分之一。也就是说,算本身不费劲,把数搬过来才费劲。
脉动阵列(Systolic Array)就是冲着这个痛点来的。它的核心思路非常朴素:让数据像心跳一样有节奏地流过一排排计算单元,每个计算单元只跟相邻的单元打交道,数据在流动的过程中被反复复用。这样一来,从主存读取数据的次数大幅减少,计算单元之间的连线也短,功耗和面积都能压下来。
我第一次接触脉动阵列是在读TPU论文的时候。Google在第一代TPU里用了一个256×256的脉动阵列做矩阵乘法,峰值算力92 TOPS,功耗只有40瓦。当时觉得这个设计太聪明了——它没有追求单个计算单元多快,而是把“数据复用”这件事做到了极致。后来自己动手在FPGA上搭了一个小规模的脉动阵列,才真正理解里面的门道。
这篇文章适合谁看?如果你是芯片架构的初学者,想搞明白AI加速器里最经典的矩阵乘法引擎是怎么工作的;或者你是软件工程师,想理解为什么某些算子在某些硬件上跑得快、在另一些上跑得慢;又或者你只是对“AI芯片到底特别在哪”这个问题感兴趣,那这篇内容应该能给你一些实在的答案。我会从脉动阵列的基本原理讲起,拆解它的数据流、计算单元设计、边界条件处理,再聊一聊实际实现中会踩的坑,最后说说它跟其他矩阵乘法架构的对比和适用边界。
2. 脉动阵列的数据流动逻辑:从“取数-计算-写回”到“数据在阵列中穿行”
2.1 一个计算单元只做一件事:乘加
脉动阵列的基本单元叫PE(Processing Element),每个PE的核心就是一个乘法器加一个累加器。它做的事情极其简单:从左边接收一个输入数据,从上边接收一个权重数据,两者相乘,结果累加到本地寄存器里。然后,把左边来的数据往右边传,把上边来的数据往下边传。
就这么简单。没有复杂的控制逻辑,没有多级缓存,没有乱序执行。每个PE每个时钟周期就干一次乘加,然后把数据往邻居那里推一把。这种极简设计带来的好处是,PE的面积可以做得非常小,功耗也极低。在一个256×256的阵列里,你就有65536个PE同时干活,每个周期完成65536次乘加。
2.2 权重 stationary 与数据流动的配合
脉动阵列最经典的用法是权重固定(Weight Stationary)。什么意思?就是权重数据预先加载到每个PE的本地寄存器里,然后在计算过程中不动。输入数据从阵列的左侧边界流入,水平向右传播;部分和从阵列的上边界流入,垂直向下传播。
假设我们要算C = A × B,其中A是M×K的矩阵,B是K×N的矩阵。在权重固定的脉动阵列里,B的每一列被加载到阵列的一列PE中,每个PE存一个权重值。A的每一行从左侧流入,每个周期向右推进一格。这样,A的第i行第k个元素在向右流动的过程中,会依次跟B的第k行第j个元素相乘,结果累加到对应位置。
这个过程的精妙之处在于:A的每个元素只从主存读一次,但在阵列中被复用了N次(N是输出矩阵的列数)。同样,B的每个元素也只加载一次,被复用了M次。数据复用率直接拉满。
2.3 部分和的垂直流动与累加
光有水平流动还不够。矩阵乘法的结果需要把K个乘积加起来。在脉动阵列里,这个累加是沿着垂直方向完成的。每个PE把自己的乘积结果加到从上方传下来的部分和上,然后把新的部分和继续往下传。最下面一排PE输出的就是最终结果。
这种垂直累加的设计,让部分和不需要写回主存再读出来,直接在阵列内部流动。对于大K值的矩阵乘法,这个优势非常明显——如果每次累加都要访问主存,光累加的访存开销就能把算力优势吃掉大半。
2.4 一个具体例子:4×4阵列算4×4矩阵乘法
拿一个4×4的脉动阵列算两个4×4矩阵的乘法来具体走一遍。假设A和B都是4×4,C = A×B。
第一步,把B的每一列加载到阵列的每一列PE中。PE(i,j)存的是B(i,j)。注意这里i是行索引,j是列索引。
第二步,A的第一行从左侧流入。时钟周期1,A(0,0)进入PE(0,0),跟B(0,0)相乘,结果存在PE(0,0)的累加器里。同时A(0,0)被传到PE(0,1)的左边输入。
时钟周期2,A(0,0)进入PE(0,1),跟B(0,1)相乘;A(0,1)进入PE(0,0),跟B(0,0)相乘。以此类推。
到时钟周期4,A(0,0)已经流到了PE(0,3),完成了它在第一行的全部乘加。同时,A(0,1)、A(0,2)、A(0,3)也分别在PE(0,2)、PE(0,1)、PE(0,0)里完成了各自的乘加。
但这只是第一行的部分结果。完整的C(0,j)需要把A(0,k)×B(k,j)对k从0到3全部加起来。这个累加是垂直方向完成的。A(1,k)×B(k,j)的结果会从PE(1,j)往下传到PE(0,j)的累加器里吗?不对,方向反了。实际上,在标准的权重固定脉动阵列里,部分和是从上往下流动的。PE(0,j)算出的A(0,k)×B(k,j)会往下传给PE(1,j),跟A(1,k)×B(k,j)的结果累加,再往下传,直到PE(3,j)输出最终的C(3,j)。
等等,这样C(0,j)的结果在哪里?这里有个细节:如果部分和从上往下流,那么最下面输出的其实是C(3,j),而C(0,j)在PE(0,j)里就被“路过”了。所以实际实现中,要么把结果从每个PE单独读出来,要么调整数据流动方向让结果从底部输出。TPU的做法是让结果从底部输出,但需要配合特定的数据排列方式。
这个细节说明一个问题:脉动阵列的数据流方向设计,直接决定了结果怎么收集、边界怎么处理。不是随便画个箭头就完事的。
3. 动手搭一个8×8脉动阵列:从RTL到功能验证
3.1 为什么选8×8这个规模
在FPGA上验证脉动阵列,规模不能太大。8×8的阵列有64个PE,每个PE一个乘法器加一个累加器,资源占用适中,时序也容易收敛。再大一点,比如16×16,布线延迟和时钟树就开始变得棘手,调试起来也麻烦。8×8足够验证数据流逻辑、边界处理、结果收集这些核心问题,又不至于让综合布线跑几个小时。
我用的开发板是Xilinx Artix-7系列,资源不算富裕,但跑8×8的脉动阵列绰绰有余。每个PE用一个DSP48 slice做乘加,64个PE用64个DSP,剩下的逻辑资源用来做数据缓存和控制状态机。
3.2 PE的RTL实现:极简但要注意位宽
PE的Verilog代码非常短,核心就是一个乘加和一个移位寄存器。但位宽设计是个容易踩坑的地方。假设输入数据是8位有符号数,权重也是8位有符号数,乘积就是16位。累加K次之后,结果位宽需要扩展。如果K=8,累加结果最多需要16+3=19位。如果K更大,位宽还要继续加。
我一开始偷懒,累加器只给了16位,结果跑大K值的时候溢出了,输出全是错的。后来改成24位,问题解决。位宽估算的公式是:输入位宽×2 + ceil(log2(K)) + 1(符号位)。这个公式不复杂,但忘了就会出问题。
另一个细节是复位策略。PE的累加器需要在每次新的矩阵乘法开始前清零。如果复位信号设计成全局同步复位,64个PE的复位线会形成很大的扇出,时序压力大。我后来改成每个PE用本地复位,由控制状态机产生一个复位脉冲,沿着阵列的行列方向传播,跟数据流同步。这样复位也是“脉动”的,时序好很多。
3.3 数据加载与排空:边界处理是重头戏
脉动阵列最麻烦的地方不在计算,在数据加载和结果排空。计算过程是规整的,但开始和结束的时候,阵列里是“空”的,需要把数据喂进去,等几个周期才能填满;算完之后,结果还在阵列里,需要等它流出来。
对于8×8的阵列,加载权重需要8个周期(每周期加载一列),输入数据需要8个周期填满第一行,然后还要等8个周期让部分和流到底部。整个流水线的填充延迟大约是24个周期。如果矩阵很小,比如4×4,填充延迟比计算时间还长,效率就很低。这也是脉动阵列适合大矩阵、不适合小矩阵的原因之一。
结果排空也有讲究。最后一个PE输出结果之后,还需要额外的周期把结果写入输出缓存。如果输出缓存深度不够,就会丢数据。我在实现的时候,输出缓存用了FIFO,深度设成阵列大小的两倍,确保不会因为反压导致数据丢失。
3.4 功能验证:用Python生成测试向量
验证脉动阵列的功能,最直接的方法是用Python生成随机矩阵,算出期望结果,然后跟RTL仿真结果对比。我写了一个简单的脚本,生成8×8的随机矩阵A和B,用numpy算A×B,然后把A和B按脉动阵列的格式排列成输入向量,跑仿真,把输出跟numpy结果对比。
这里有个坑:脉动阵列的输出顺序跟标准矩阵乘法的输出顺序不一样。因为结果是沿着对角线方向流出的,需要按照特定的顺序收集。我一开始没注意,直接把输出按行排列,结果跟numpy对不上。后来画了个时序图,标清楚每个周期哪个PE输出哪个元素,才把顺序理清楚。
验证通过之后,我又跑了几个边界情况:全零矩阵、全一矩阵、最大正值、最小负值。全零和全一都没问题,但最大正值和最小负值的时候出现了溢出。回头检查位宽,发现累加器的位宽还是不够。把位宽从24位加到32位之后,所有测试用例都通过了。
4. 脉动阵列的局限性与适用边界
4.1 稀疏矩阵:脉动阵列的“天敌”
脉动阵列的设计前提是稠密矩阵乘法。每个PE每个周期都在干活,数据流是满的。但如果矩阵是稀疏的,大量元素是零,脉动阵列还是傻乎乎地算零乘零,效率直线下降。对于稀疏度90%的矩阵,脉动阵列的有效算力只有峰值的10%。
当然,可以在数据流入之前做稀疏编码,跳过零元素。但这样一来,数据流就不规整了,PE之间的同步会变得复杂,脉动阵列“规整”的优势就没了。所以稀疏矩阵通常用别的架构,比如带稀疏索引的向量处理器,或者用压缩格式配合专用的稀疏乘法单元。
4.2 小矩阵:填充开销吃掉收益
前面提到过,脉动阵列有填充延迟。对于8×8的阵列,填充延迟大约24个周期。如果矩阵是4×4,计算只需要4个周期,填充却要24个周期,效率只有14%。矩阵越小,效率越低。
所以脉动阵列适合大矩阵。TPU的256×256阵列,填充延迟256个周期左右,但计算一个256×256的矩阵乘法需要256个周期,填充开销占比50%。如果矩阵是512×512,计算需要512个周期,填充开销占比33%。矩阵越大,填充开销占比越低。
对于小矩阵,通常的做法是批处理。把多个小矩阵拼成一个大矩阵,或者用多个小阵列并行处理。TPU就支持把多个小矩阵乘法合并成一个大的批处理矩阵乘法,提高阵列利用率。
4.3 非矩阵乘法算子:脉动阵列不擅长
脉动阵列是为矩阵乘法量身定做的。对于其他算子,比如激活函数、归一化、池化,脉动阵列就不太适用了。这些算子通常用向量处理器或者专用的功能单元来处理。
所以实际的AI芯片里,脉动阵列只是矩阵乘法引擎,外面还需要配合向量单元、标量单元、DMA引擎等。TPU的设计就是脉动阵列加向量单元加标量单元的组合。脉动阵列负责最重的矩阵乘法,其他算子交给别的单元。
4.4 跟其他矩阵乘法架构的对比
| 架构类型 | 数据复用 | 控制复杂度 | 适合场景 | 典型代表 |
|---|---|---|---|---|
| 脉动阵列 | 高 | 低 | 大稠密矩阵乘法 | TPU |
| 向量处理器 | 中 | 中 | 通用计算 | GPU |
| 张量核心 | 高 | 中 | 混合精度矩阵乘法 | NVIDIA Tensor Core |
| 存内计算 | 极高 | 高 | 低功耗推理 | 新型存内计算芯片 |
脉动阵列的优势在于控制简单、数据复用率高、功耗低。劣势在于灵活性差、稀疏矩阵效率低、小矩阵填充开销大。选择哪种架构,取决于目标应用场景。如果是数据中心的大批量推理,矩阵又大又稠密,脉动阵列很合适。如果是边缘设备,矩阵小、稀疏度高,可能向量处理器或者存内计算更合适。
5. 实际部署中的经验与踩坑记录
5.1 时钟频率与阵列规模的权衡
脉动阵列的时钟频率受限于最长的组合逻辑路径。在8×8的阵列里,最长的路径是从左上角PE到右下角PE的数据传播路径。如果每个PE的乘加逻辑延迟是2纳秒,8个PE串起来就是16纳秒,对应最高时钟频率62.5MHz。这还没算布线延迟。
实际综合下来,8×8阵列在Artix-7上能跑到100MHz左右。如果换成16×16,频率会降到50MHz以下。阵列规模翻倍,频率减半,总算力不变,但功耗和面积都增加了。所以阵列规模不是越大越好,需要根据工艺、频率、功耗综合权衡。
TPU用256×256的阵列,但它的PE逻辑经过深度优化,而且用了专门的时钟树和流水线设计,才能在较高频率下工作。普通FPGA上跑256×256的阵列,频率可能只有几十MHz,算力优势不明显。
5.2 数据位宽与精度的取舍
AI推理对精度的要求没有训练那么高。8位整数(INT8)推理在很多场景下已经够用,而且8位乘加的功耗和面积只有16位浮点的一个零头。脉动阵列用INT8,可以在同样的面积下塞进更多的PE,算力密度更高。
但INT8的累加器位宽要足够。前面算过,8位乘8位得16位,累加K次需要额外log2(K)位。如果K=256,需要额外8位,总共24位。如果K=1024,需要额外10位,总共26位。实际实现中,累加器通常给32位,留足余量。
如果要做混合精度,比如INT8输入、INT32累加、FP16输出,PE的设计会复杂一些,但灵活性更好。TPU支持INT8和FP16两种模式,PE里有两套乘法器,根据模式选择。
5.3 权重加载的带宽瓶颈
权重固定的脉动阵列,权重只在计算开始前加载一次。但如果矩阵很大,权重数据量也很大。256×256的阵列,权重矩阵是256×256,INT8的话就是64KB。加载64KB数据需要多少个周期?如果加载带宽是每周期256字节,需要256个周期。这256个周期里,阵列是空闲的。
为了减少权重加载开销,通常的做法是双缓冲。用两块权重缓存,一块在计算的时候,另一块在加载下一组权重。计算和加载重叠,隐藏加载延迟。TPU就用了双缓冲的权重缓存。
另一个做法是权重压缩。如果权重有很多零或者重复值,可以压缩存储,加载的时候解压。但解压逻辑会增加PE的复杂度,需要权衡。
5.4 散热与功耗密度
脉动阵列的功耗密度很高,因为大量PE挤在一起同时工作。8×8的阵列在FPGA上跑起来,芯片表面温度能到60度以上。如果是256×256的阵列,功耗密度更高,散热设计非常关键。
TPU用液冷或者大面积散热片来压温度。普通FPGA开发板通常只有被动散热,跑大阵列的时候需要加风扇。我在测试的时候,跑满负载几分钟后,FPGA温度就上了70度,后来加了个小风扇才稳住。
功耗优化方面,时钟门控是最有效的手段之一。当阵列的某一部分没有数据流动时,把对应的时钟关掉,能省不少功耗。但时钟门控的逻辑会增加设计复杂度,需要仔细验证。
6. 从脉动阵列延伸出去:AI芯片架构的演进方向
脉动阵列从1980年代就被提出来了,最早用在信号处理领域。后来AI兴起,它又被重新发掘出来,成了AI加速器的经典架构。但AI芯片的架构演进没有停在脉动阵列上。
一个明显的趋势是可重构架构。脉动阵列的数据流是固定的,灵活性差。可重构架构可以根据不同的算子动态调整数据流,兼顾效率和灵活性。比如有些架构支持在脉动模式、向量模式、标量模式之间切换,适应不同的计算任务。
另一个趋势是存内计算。脉动阵列虽然减少了数据搬运,但数据还是要在PE之间流动。存内计算直接把计算单元嵌入存储阵列里,数据不动,计算动。理论上能进一步降低功耗和延迟。但存内计算的工艺和设计挑战还很大,目前还在研究阶段。
还有一个方向是稀疏加速。针对稀疏矩阵,设计专门的稀疏脉动阵列,跳过零元素,提高有效算力。这需要硬件支持稀疏编码和解码,设计复杂度比稠密脉动阵列高不少。
我在实际项目里用脉动阵列做推理加速,最大的体会是:架构选择没有银弹,关键看应用场景。如果你的模型是稠密的大矩阵乘法为主,脉动阵列很合适。如果模型稀疏度高、算子种类多,可能需要更灵活的架构。理解脉动阵列的原理和边界,不是为了到处用它,而是为了在合适的地方用它,在不合适的地方知道为什么不用它。
最后分享一个调试小技巧:脉动阵列的仿真波形又长又密,看波形找问题很痛苦。我的做法是在PE里加一个可选的调试输出,把每个PE的输入、权重、乘积、累加结果都打上时间戳,输出到文件里。然后用Python脚本解析这个文件,跟numpy的逐元素计算结果对比,能快速定位到是哪个PE、哪个周期出了问题。这个方法比看波形效率高得多,尤其是在阵列规模大的时候。