4. 数据复用策略:让数据在芯片里多跑几趟

做卷积加速器,说白了就是在跟数据搬运较劲。我早年做第一个加速器项目时,花了大半年调架构,最后发现性能瓶颈根本不在计算单元,而是数据从DDR搬到片上SRAM的路上堵死了。嗯,从那以后,我养成了一个习惯——先想清楚数据怎么复用,再动手画架构图。

今天咱们聊聊四种核心复用策略:输入特征图复用、权重复用、部分和复用,以及循环分块技术。你想想看,卷积运算本质上就是一堆乘加操作,如果能减少数据从片外搬到片内的次数,那加速器的效率自然就上去了。

4.1 输入特征图复用(Input Feature Map Reuse)

什么叫输入特征图复用?简单说,就是同一个输入像素,被多个卷积窗口共享。举个例子,一个3x3的卷积核滑过特征图,相邻两个窗口之间会有2行2列的重叠区域。这些重叠的像素,我们没必要每次都从外面读进来。

核心思想:输入特征图在行方向或列方向上有大量重叠,利用片上缓存暂存这些重叠数据,避免重复读取。

我在项目中遇到过一种情况:输入特征图尺寸是224x224,卷积核是3x3,步长为1。如果不做复用,每个输出像素需要读9个输入像素,总共要读224x224x9 ≈ 45万个数据。但如果用行缓冲(Line Buffer)把中间结果存下来,实际只需要读224x224 + 2x224 ≈ 5万个新数据。你看,差了将近9倍。

具体实现上,我建议用双缓冲行缓冲结构:

// 伪代码:行缓冲实现输入特征图复用
// row_buffer[2][W] 存储两行数据
for (row = 0; row < H; row++) {
    // 从片外读入新的一行
    load_row_from_dram(row_buffer[row % 2], row);
    
    // 当行缓冲填满3行时,开始计算
    if (row >= 2) {
        for (col = 0; col < W; col++) {
            // 从行缓冲中取3x3窗口
            window = get_window(row_buffer, row, col);
            // 执行卷积计算
            output[row-1][col] = conv(window, weight);
        }
    }
}

我的经验:行缓冲的深度取决于卷积核大小。3x3用2行缓冲就够了,5x5需要4行。别贪多,片上SRAM很贵的。

4.2 权重复用(Weight Reuse)

权重复用就更好理解了。同一个卷积核,要在整个输入特征图上滑来滑去。如果每个位置都从片外读一次权重,那带宽直接爆炸。

我记得有一次做量化部署,模型有32个输出通道,每个通道的3x3权重都要复用。我当时的做法是把所有权重一次性加载到片上,然后让计算单元反复使用。你猜怎么着?带宽需求直接降了32倍。

权重复用的典型策略有两种:

  • 输出通道复用:一个卷积核处理完所有输入位置后,再换下一个卷积核
  • 输入通道复用:多个卷积核同时处理同一个输入位置,共享输入数据
复用策略 适用场景 片上存储需求 带宽节省
输出通道复用 输出通道数多,输入通道少 低(只需存一组权重) 高
输入通道复用 输入通道数多,输出通道少 高(需存多组权重) 中

注意:权重复用不是越多越好。如果片上放不下所有权重,频繁的换入换出反而会降低效率。我曾经在一个项目中硬塞了64组权重,结果片上SRAM爆了,最后不得不改成32组+双缓冲。

4.3 部分和复用(Partial Sum Reuse)

这部分稍微绕一点,但很重要。卷积计算中,每个输出像素是多个输入通道的累加结果。如果每个通道算完就写回片外,等所有通道算完再读出来累加,那带宽就白费了。

部分和复用的思路是:在片上维护一个累加器缓冲区,每个新通道的计算结果直接累加到缓冲区里。等所有通道都算完了,再把最终结果写回片外。

// 伪代码:部分和累加
// psum_buffer[H][W] 存储部分和
for (c_in = 0; c_in < C_in; c_in++) {
    // 加载第c_in个输入通道
    load_ifmap(input_channel[c_in]);
    
    for (row = 0; row < H_out; row++) {
        for (col = 0; col < W_out; col++) {
            // 计算当前通道的贡献
            partial = conv_single_channel(input_channel[c_in], 
                                          weight[c_in], 
                                          row, col);
            // 累加到部分和缓冲区
            psum_buffer[row][col] += partial;
        }
    }
}
// 所有通道累加完毕,写回片外
store_psum_to_dram(psum_buffer);

这样做的好处很明显:原来需要C_in次读+1次写,现在只需要C_in次读+1次写,但写操作从C_in次降到了1次。对于C_in=64的情况,写带宽节省了64倍。

关键点:部分和缓冲区的大小决定了能同时累加多少个输出像素。我一般建议做成和输出特征图一样大,但如果片上放不下,可以分块处理。

4.4 循环分块(Loop Tiling)

好了,前面三种复用策略讲的是数据怎么在片上重复利用。但有个现实问题:片上SRAM就那么大,放不下整个特征图怎么办?这时候就需要循环分块了。

循环分块,说白了就是把大的循环拆成小的块,让每个块的数据都能塞进片上缓存。我习惯用三维分块:在高度、宽度、通道三个维度上分别切块。

举个例子,假设输入特征图是224x224x64,片上SRAM只能放下64x64x16的数据。那我们就这么切:

// 循环分块伪代码
Tile_H = 64;  // 高度方向分块大小
Tile_W = 64;  // 宽度方向分块大小
Tile_C = 16;  // 通道方向分块大小

for (th = 0; th < H; th += Tile_H) {
    for (tw = 0; tw < W; tw += Tile_W) {
        for (tc = 0; tc < C; tc += Tile_C) {
            // 加载当前块的数据到片上
            load_tile(th, tw, tc, Tile_H, Tile_W, Tile_C);
            
            // 在当前块上执行卷积
            conv_tile(th, tw, tc);
            
            // 写回部分和
            store_tile(th, tw, tc);
        }
    }
}

分块大小的选择是个权衡:

  • 块越大:复用效率越高,但需要更多片上存储
  • 块越小:对片上存储要求低,但边界开销增加

我的建议:分块大小最好和行缓冲对齐。比如行缓冲是64列,那宽度方向就按64分块。这样数据在行缓冲里就能直接复用,不用额外搬移。

4.5 四种策略的协同设计

实际项目中,这四种策略不是孤立使用的。我一般会画一个数据流图,把输入、权重、部分和的流动路径标清楚,然后看哪里是瓶颈。

下面这张图展示了四种策略如何协同工作:

数据复用策略协同架构图 片外DRAM 输入/权重/输出 片上SRAM 行缓冲 + 权重缓存 MAC计算阵列 乘加运算 部分和累加器 循环分块加载 输入/权重复用 部分和复用 累加结果写回 最终结果写回 四种复用策略的协同: 1. 循环分块:将大特征图切成小块,确保每块能塞进片上SRAM 2. 输入特征图复用:利用行缓冲暂存相邻窗口的重叠数据 3. 权重复用:将卷积核一次性加载到片上,反复使用 4. 部分和复用:在片上累加多个通道的结果,减少写回次数

你看这张图,数据从DRAM经过循环分块加载到SRAM,然后在SRAM里做输入和权重复用,送到MAC阵列计算,计算结果在部分和累加器里复用累加,最后再写回SRAM和DRAM。整个流程环环相扣。

避坑指南:我曾经在一个项目里只优化了权重复用,忽略了输入复用,结果带宽瓶颈从权重变成了输入。后来不得不重新设计行缓冲结构。所以,四种策略要一起考虑,别只盯着一个。

好了,关于数据复用策略就聊这么多。记住一句话:数据搬移的次数,决定了加速器的天花板。把复用做好了,你的加速器就成功了一半。

可可精品资料库