数据流与脉动阵列:Row-Stationary数据流原理、脉动阵列结构、数据复用策略详解
好,咱们进入第二章。这一章可以说是Eyeriss架构的灵魂所在。如果你搞懂了数据流和脉动阵列,那后面整个部署方案你就拿下了大半。我个人习惯,每次带新人做AI芯片项目,第一件事就是让他们把Row-Stationary(行固定)数据流给我讲明白。为什么?因为这里面藏着降低功耗、提升效率的核心秘密。
2.1 从“计算”到“数据搬运”的思维转变
做AI加速器,说白了就是在跟数据较劲。你想想看,一个卷积层,权重有几万到几百万个,输入特征图也是几万到几百万个。计算本身很快,但把这些数据从内存搬到计算单元,再搬回去,这个过程消耗的时间和能量,往往比计算本身多得多。
我在项目中遇到过这样一个情况:一个看起来算力很高的加速器,实际跑起来性能只有理论峰值的30%。查来查去,问题就出在数据搬运上。计算单元一直在空转,等数据。所以,一个好的数据流策略,核心目标就是:让数据在计算单元里多待一会儿,尽量复用,减少对外部存储的访问。
2.2 Row-Stationary(行固定)数据流原理
Eyeriss提出的Row-Stationary数据流,就是冲着这个目标去的。它不像传统方法那样,把整个卷积核或者整张特征图作为基本处理单元,而是把卷积核的一行作为最小的、可以独立调度的计算任务。
核心思想: 将卷积核的一行(Row)固定在一个处理单元(PE)上,让该行权重在PE内部被充分复用,同时让输入特征图的行数据在PE阵列中“流动”起来。
具体怎么做的呢?我们来看一个简单的例子。假设卷积核大小是3x3,输入特征图是5x5。
- 任务划分: 把3x3的卷积核拆成3行。每一行(3个权重)分配给一个PE。这样,我们有3个PE,每个PE负责一个卷积核行的计算。
- 数据流动: 输入特征图的第一行数据,会依次流过PE0、PE1、PE2。每个PE拿到数据后,跟自己固定的那一行权重做乘加运算。
- 部分和累加: 每个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脉动阵列结构图,你可以直观地看到数据是怎么流动的。
从图上你可以看到:
- 输入特征图的行数据(蓝色方块)从左侧流入阵列。
- 每一列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来处理剩余的数据。虽然会损失一点效率,但能保证逻辑的简洁性。