数据流与脉动阵列:Row-Stationary数据流原理、脉动阵列结构、数据复用策略详解

好,咱们进入第二章。这一章可以说是Eyeriss架构的灵魂所在。如果你搞懂了数据流和脉动阵列,那后面整个部署方案你就拿下了大半。我个人习惯,每次带新人做AI芯片项目,第一件事就是让他们把Row-Stationary(行固定)数据流给我讲明白。为什么?因为这里面藏着降低功耗、提升效率的核心秘密。

2.1 从“计算”到“数据搬运”的思维转变

做AI加速器,说白了就是在跟数据较劲。你想想看,一个卷积层,权重有几万到几百万个,输入特征图也是几万到几百万个。计算本身很快,但把这些数据从内存搬到计算单元,再搬回去,这个过程消耗的时间和能量,往往比计算本身多得多。

我在项目中遇到过这样一个情况:一个看起来算力很高的加速器,实际跑起来性能只有理论峰值的30%。查来查去,问题就出在数据搬运上。计算单元一直在空转,等数据。所以,一个好的数据流策略,核心目标就是:让数据在计算单元里多待一会儿,尽量复用,减少对外部存储的访问。

2.2 Row-Stationary(行固定)数据流原理

Eyeriss提出的Row-Stationary数据流,就是冲着这个目标去的。它不像传统方法那样,把整个卷积核或者整张特征图作为基本处理单元,而是把卷积核的一行作为最小的、可以独立调度的计算任务。

核心思想: 将卷积核的一行(Row)固定在一个处理单元(PE)上,让该行权重在PE内部被充分复用,同时让输入特征图的行数据在PE阵列中“流动”起来。

具体怎么做的呢?我们来看一个简单的例子。假设卷积核大小是3x3,输入特征图是5x5。

  1. 任务划分: 把3x3的卷积核拆成3行。每一行(3个权重)分配给一个PE。这样,我们有3个PE,每个PE负责一个卷积核行的计算。
  2. 数据流动: 输入特征图的第一行数据,会依次流过PE0、PE1、PE2。每个PE拿到数据后,跟自己固定的那一行权重做乘加运算。
  3. 部分和累加: 每个PE计算出来的结果,是一个“部分和”。这些部分和会在PE之间传递和累加,最终形成完整的输出像素。

嗯,这里要注意,Row-Stationary的精髓在于“固定”和“流动”的配合。权重是固定的,输入数据是流动的。这样,权重在PE内部可以被反复使用,而输入数据在流过PE阵列时,也能被多个PE共享。

我的经验: 在实际部署时,Row-Stationary对卷积核大小的适应性非常好。尤其是对于3x3、5x5这种小卷积核,它的数据复用效率极高。我曾经在一个项目中,通过将数据流从传统的Weight-Stationary切换到Row-Stationary,让芯片的能效比提升了近2倍。

2.3 脉动阵列结构:数据流的物理载体

Row-Stationary数据流需要一个高效的硬件结构来承载。这就是脉动阵列(Systolic Array)。你可以把它想象成一个由PE组成的二维网格,数据像血液一样在网格中有节奏地“脉动”。

Eyeriss的脉动阵列有几个关键特点:

  • 近邻通信: PE只跟上下左右的邻居通信,没有全局总线。这大大降低了布线复杂度和功耗。
  • 数据复用: 数据在PE之间传递时,可以被多个PE重复使用。比如,一个输入像素沿着行方向传递,可以被这一行的所有PE用到。
  • 流水线并行: 不同的PE可以同时处理不同的数据,实现高度的并行计算。

下面这张图,是我自己画的Eyeriss脉动阵列结构图,你可以直观地看到数据是怎么流动的。

Eyeriss 脉动阵列结构图 (Row-Stationary数据流) 输入特征图 (行数据流) 行0 行1 行2 PE0,0 权重行0 PE0,1 权重行0 PE0,2 权重行0 PE1,0 权重行1 PE1,1 权重行1 PE1,2 权重行1 PE2,0 权重行2 PE2,1 权重行2 PE2,2 权重行2 部分和累加 (列方向) 输出像素 PE (权重行0) PE (权重行1) PE (权重行2) 输入特征图行数据 输出部分和/像素

从图上你可以看到:

  • 输入特征图的行数据(蓝色方块)从左侧流入阵列。
  • 每一列PE(比如PE0,0、PE1,0、PE2,0)共享同一个输入数据。
  • 每一行PE(比如PE0,0、PE0,1、PE0,2)固定了同一个卷积核行(权重行0)。
  • 部分和(橙色方块)在列方向上累加,最终在阵列底部输出完整的计算结果。

2.4 数据复用策略详解:三种复用模式

Row-Stationary数据流之所以高效,是因为它天然支持三种数据复用模式。我当年在部署一个ResNet-50模型时,就是靠吃透这三种复用,才把DDR带宽占用降下来的。

复用模式 复用对象 复用方式 典型场景
权重复用 (Weight Stationary) 卷积核权重 权重固定在PE内,输入数据流过PE时,权重被反复使用 同一卷积核扫描不同输入位置
输入复用 (Input Stationary) 输入特征图 输入数据在PE阵列的行方向广播,被多个PE共享 多个卷积核同时处理同一输入区域
部分和复用 (Partial Sum Accumulation) 中间计算结果 部分和在PE阵列的列方向累加,避免重复读取 卷积核行间累加

我曾经踩过的坑: 在设计数据流调度器时,我一开始没有充分考虑这三种复用的平衡。结果导致权重复用率很高,但输入复用率很低,整体性能反而上不去。后来我意识到,三种复用模式是相互影响的。你需要根据具体的网络层参数(输入尺寸、卷积核大小、通道数)来动态调整PE阵列的映射方式,才能达到最优效果。

举个例子,对于卷积核较大的层(比如7x7),权重复用占主导,你应该让更多的PE来固定不同的权重行。而对于输入特征图较大的层(比如224x224),输入复用更关键,你应该让输入数据在更多的PE之间广播。

2.5 实战中的调度与映射

理论说完了,咱们聊聊实战。在Eyeriss架构上部署一个卷积层,你需要做两件事:空间映射和时间调度。

  • 空间映射: 决定哪个PE计算哪个卷积核行,以及输入数据如何分配到PE阵列上。
  • 时间调度: 决定数据流入PE阵列的顺序和节奏。

我个人的习惯是,先用一个简单的脚本把空间映射算出来。比如,对于一个3x3的卷积核,输入通道数为C,输出通道数为M,PE阵列为R行xC列。

# 伪代码示例:Row-Stationary空间映射
# 假设 PE 阵列大小为 4x4
PE_ROWS = 4
PE_COLS = 4

# 卷积核大小 3x3,输入通道 C,输出通道 M
KERNEL_SIZE = 3
INPUT_CHANNELS = 64
OUTPUT_CHANNELS = 128

# 空间映射策略:
# 1. 将输出通道 M 映射到 PE 阵列的行方向 (PE_ROWS)
# 2. 将卷积核的行 (KERNEL_SIZE) 映射到 PE 阵列的列方向 (PE_COLS)
# 3. 输入通道 C 在时间上循环

for m in range(0, OUTPUT_CHANNELS, PE_ROWS):
    for k in range(0, KERNEL_SIZE, PE_COLS):
        # 当前PE阵列负责计算的输出通道和卷积核行
        current_output_channels = m to m+PE_ROWS
        current_kernel_rows = k to k+PE_COLS
        
        # 加载对应的权重到PE阵列
        load_weights(current_output_channels, current_kernel_rows)
        
        # 流式输入数据,进行计算
        for c in range(INPUT_CHANNELS):
            for h in range(INPUT_HEIGHT):
                for w in range(INPUT_WIDTH):
                    # 输入数据流入PE阵列
                    systolic_array_compute(input_data[h][w][c])
        
        # 收集部分和,得到最终输出
        collect_partial_sums()

这段代码虽然简单,但它体现了Row-Stationary调度的核心逻辑:先固定权重,再流动数据,最后累加部分和。

一个小技巧: 在实际硬件实现时,PE阵列的大小往往不是刚好能整除卷积核行数或输出通道数。这时候就需要做“边界处理”。我通常的做法是,在PE阵列的边界处插入“空转”周期,或者用额外的PE来处理剩余的数据。虽然会损失一点效率,但能保证逻辑的简洁性。