4、卷积加速器架构设计:脉动阵列(Systolic Array)原理、数据复用策略、加速器整体架构设计

好,咱们今天聊点硬核的。脉动阵列,这个词你肯定听过。说白了,它就是卷积加速器的心脏。我当年第一次在FPGA上跑通一个8x8的脉动阵列时,那种感觉——嗯,就像你亲手让一堆数据排着队、有节奏地跳了一支舞。

4.1 脉动阵列(Systolic Array)原理

脉动阵列这个名字,其实挺形象的。你想想看,心脏收缩一下,血液就往前推一点。数据也是这样,在每个时钟周期,数据从一个PE(处理单元)流到下一个PE。每个PE只做一件事:乘加运算。

为什么会用这种结构?因为卷积的本质就是乘加累加。你一个3x3的卷积核,在特征图上滑动,每次做9次乘法和8次加法。如果用传统的方式,数据搬运的功耗远大于计算本身。脉动阵列正好解决了这个问题——数据流起来,计算就在路上完成。

我在项目中遇到过一个问题:刚开始设计时,我让所有PE同时从内存读数据,结果布线资源直接爆炸。后来改成数据链式传递,资源占用降了40%。

核心要点:脉动阵列的每个PE只与相邻PE通信,没有全局连线。这是它能跑高频的关键。

4.2 数据复用策略

数据复用,说白了就是让一份数据被多个PE重复使用。我见过很多新手,上来就搞大阵列,结果带宽根本喂不饱。你想想看,一个16x16的阵列,256个PE,每个PE每个周期都要新数据——那得要多大的带宽?

常用的复用策略有三种:

  • 权重固定(Weight Stationary):权重存在PE里不动,输入数据流过去。适合权重不变、输入数据多的场景。
  • 输入固定(Input Stationary):输入数据存在PE里,权重流过去。适合输入数据复用率高的场景。
  • 输出固定(Output Stationary):部分和存在PE里,权重和输入都流过去。适合输出通道多的场景。

我个人习惯用权重固定。为什么?因为卷积核的权重是固定的,你加载一次,后面就不用动了。数据从左边流进来,从上边流下来,每个PE只做一次乘加,然后把部分和往下传。

避坑指南:我曾经在权重固定模式下,忘记考虑权重加载的时间。结果每次换卷积核都要等几十个周期。后来我加了双缓冲,权重加载和计算可以重叠。

4.3 加速器整体架构设计

好了,有了脉动阵列和数据复用策略,咱们来看看整个加速器长什么样。我一般把它分成四个部分:

  1. 输入缓冲区:存特征图数据。用双缓冲,一个在读,一个在写。
  2. 权重缓冲区:存卷积核权重。也是双缓冲,支持预加载。
  3. 脉动阵列:核心计算单元。我习惯用16x16或32x32。
  4. 输出缓冲区:存计算结果。支持累加和激活函数。

下面这张图,是我用SVG画的整体架构。你看一眼就明白了。

输入缓冲区 双缓冲 64KB 权重缓冲区 双缓冲 32KB 脉动阵列 16x16 PE 每个PE: 乘加器 + 寄存器 输出缓冲区 累加 + 激活 特征图数据 权重数据 部分和 控制逻辑(状态机 + 地址生成器) 存储 计算 控制

你看,数据从左边进来,权重从上边进来,计算结果从右边出去。控制逻辑在下面统一调度。这个架构我用了好几年,基本没大改过。

4.4 关键设计细节

嗯,这里要注意几个细节:

模块 关键参数 我的建议
输入缓冲区 深度、位宽 深度至少能存2行特征图,位宽等于数据位宽×PE列数
权重缓冲区 深度、预加载机制 用双缓冲,计算当前权重时预加载下一组
脉动阵列 阵列大小、PE位宽 16x16是黄金比例,资源与性能的平衡点
输出缓冲区 累加深度、激活函数 支持多周期累加,ReLU直接硬件实现

注意:阵列大小不是越大越好。我试过32x32,结果时序收敛不了,频率从500MHz掉到300MHz。后来改成两个16x16并行,频率回来了,吞吐量还更高。

4.5 数据流调度

数据怎么流?我一般这样安排:

// 伪代码:脉动阵列数据流调度
// 假设阵列大小 4x4,卷积核 3x3

// 第1拍:加载权重到所有PE
for i in 0..3:
    for j in 0..3:
        PE[i][j].weight = kernel[i][j]

// 第2拍开始:数据流入
for t in 0..N:
    // 左边输入新数据
    for i in 0..3:
        PE[i][0].input = data_in[i][t]
    
    // 数据向右传递
    for i in 0..3:
        for j in 1..3:
            PE[i][j].input = PE[i][j-1].output
    
    // 每个PE计算
    for i in 0..3:
        for j in 0..3:
            PE[i][j].psum += PE[i][j].input * PE[i][j].weight
    
    // 底部输出结果
    for j in 0..3:
        result[j] = PE[3][j].psum

你看,数据像流水一样,一拍一拍往前推。每个PE只做一件事,但256个PE同时做,吞吐量就上来了。

小技巧:我习惯在PE内部加一个流水线寄存器。虽然多了一拍延迟,但频率能提高30%以上。对于大阵列来说,这个取舍很划算。

好了,脉动阵列的核心思路就这些。说白了就是:让数据动起来,让计算停下来。你把这个想明白了,加速器架构就通了。

可可精品资料库