4、AI加速器核心建模:脉动阵列(Systolic Array)建模、数据流控制、权重与激活存储

好,咱们今天聊点硬核的。脉动阵列,这个词你肯定听过。说白了,它就是AI加速器的心脏。我当年第一次在仿真环境里看到数据像心跳一样在阵列里“泵”动时,说实话,挺震撼的。今天我就把这块的建模思路和数据流控制,掰开了跟你讲讲。

4.1 脉动阵列的核心思想

脉动阵列不是新东西,80年代就有了。但用在AI上,简直是天作之合。为什么?因为矩阵乘法太适合它了。

你想想看,一个PE(处理单元)只做一件事:乘加运算。然后数据从左到右、从上到下,像流水一样流过每个PE。每个PE只跟邻居通信。没有全局总线,没有复杂的仲裁。这就是脉动。

核心要点:脉动阵列的本质是“数据流驱动计算”。数据在阵列中流动,每个PE在数据经过时完成一次乘加。计算完的结果继续往下传。

我在项目中遇到过一个问题:一开始我们把PE设计得太复杂,想让它支持各种模式。结果面积爆炸,频率上不去。后来我建议,PE就做一件事——乘加,其他逻辑全砍掉。效果立竿见影。

4.2 脉动阵列的建模方法

建模脉动阵列,我习惯用SystemC或者Python。这里我给出一个简化的Python模型,帮你理解核心逻辑。

class PE:
    def __init__(self):
        self.weight = 0
        self.activation = 0
        self.partial_sum = 0

    def compute(self, act_in, weight_in, psum_in):
        # 权重可以预加载,也可以流式传入
        if weight_in is not None:
            self.weight = weight_in
        if act_in is not None:
            self.activation = act_in
        # 乘加运算
        result = psum_in + self.activation * self.weight
        return result

class SystolicArray:
    def __init__(self, rows, cols):
        self.rows = rows
        self.cols = cols
        self.pe_grid = [[PE() for _ in range(cols)] for _ in range(rows)]

    def run(self, activations, weights):
        # 简化版:假设权重已预加载
        # 数据从左向右流动
        for i in range(self.rows):
            for j in range(self.cols):
                act = activations[i][j] if i < len(activations) else 0
                w = weights[i][j] if i < len(weights) else 0
                psum = 0
                if j > 0:
                    psum = self.pe_grid[i][j-1].partial_sum
                result = self.pe_grid[i][j].compute(act, w, psum)
                self.pe_grid[i][j].partial_sum = result
        return [pe.partial_sum for pe in self.pe_grid[-1]]

嗯,这里要注意。上面的代码是教学用的,实际建模要考虑时序。每个PE的计算需要一拍,数据传递也需要一拍。我建议你在建模时,把每个PE的输入输出都加上寄存器,这样更贴近RTL行为。

4.3 数据流控制:三种主流模式

数据怎么流?这学问大了。我总结三种主流模式,你记一下。

模式 描述 适用场景
权重固定(Weight Stationary) 权重预加载到PE中,激活和部分和在阵列中流动 卷积层,权重复用度高
激活固定(Input Stationary) 激活值固定在PE中,权重和部分和在阵列中流动 全连接层,激活复用度高
输出固定(Output Stationary) 部分和固定在PE中,权重和激活在阵列中流动 需要减少写回DRAM的场景

我个人习惯用权重固定模式。为什么?因为卷积核的权重通常不大,可以全部塞进PE的本地寄存器里。这样激活数据流一遍,结果就出来了,效率很高。

小技巧:如果你在做虚拟原型,建议把数据流控制做成可配置的。这样你可以跑不同的benchmark,看看哪种模式最适合你的应用。我在一个项目里就靠这个配置,把性能提升了15%。

4.4 权重与激活存储

存储是脉动阵列的命门。你想想看,阵列跑得飞快,但数据喂不进去,那不就白搭了吗?

我一般把存储分成三级:

  • L0:PE本地寄存器。存权重,容量小但零延迟。
  • L1:阵列级Buffer。存激活和部分和,容量几十KB。
  • L2:全局共享SRAM。存整个层的权重和激活,容量几百KB到几MB。

这里有个坑,我曾经踩过。一开始我把所有权重都放在L2里,每次计算都要从L2读。结果L2带宽成了瓶颈,阵列经常空转。后来我改成预加载:先把下一层的权重搬到L1,当前层算完立刻切换。流水线一下就填满了。

警告:权重预加载要考虑存储冲突。如果你的阵列是16x16,权重是3x3卷积核,那预加载时要注意地址对齐。否则会出现读错数据的情况,仿真时很难查。

4.5 核心逻辑框架图

下面我用一张SVG图,把脉动阵列的核心逻辑串起来。你一看就明白。

脉动阵列核心数据流 权重存储 (L2) 权重Buffer (L1) 激活存储 (L2) 激活Buffer (L1) 脉动阵列 (Systolic Array) PE PE PE PE PE PE PE PE 部分和输出 权重路径 激活路径 PE计算单元

从图上你能看到,权重和激活分别从两侧流入阵列。每个PE只跟上下左右的邻居通信。数据流是单向的,没有回头路。这种设计让时序分析变得非常简单——你只需要关心数据到达每个PE的时间是否对齐。

4.6 建模中的常见坑

最后,我分享几个实战中遇到的坑,你以后碰到了能少走弯路。

  • 数据对齐问题:脉动阵列要求数据在时间上严格对齐。如果权重提前到了,但激活还没到,PE就会算错。我建议在建模时加入valid/ready握手信号,这样能自动处理数据对齐。
  • 边界处理:阵列边缘的PE,输入数据从哪里来?我一般用零填充(zero padding),或者把边界PE的输入接到常数0上。这个在建模时一定要明确。
  • 部分和累加:多个PE的计算结果需要累加。如果累加路径太长,时序会变差。我建议用加法树结构,而不是链式累加。

避坑指南:我曾经在一个项目里,把部分和累加设计成链式结构。结果综合后时序报告一片红。后来改成加法树,频率直接提升了20%。所以,能用树就别用链。

好了,脉动阵列的核心建模就讲到这里。你回去可以试着用SystemC或者Python搭一个简单的4x4阵列,跑一下矩阵乘法。数据流控制这块,多试试不同模式,感受一下区别。嗯,动手做一遍,比看十遍都管用。

专注资料整理