4、数据复用与带宽优化:输入特征图复用、权重复用、部分和复用的策略

做神经网络加速器,说白了就是在跟带宽较劲。

我入行那会儿,有个项目让我印象特别深。芯片回来后跑ResNet-50,理论算力明明够,但实际帧率就是上不去。一抓波形,发现DRAM带宽被吃满了,大部分时间都在等数据。嗯,从那以后,我养成了一个习惯——先看数据流,再看计算单元。

4.1 为什么带宽会成为瓶颈?

你想想看,一个卷积层,输入特征图可能是224x224x64,权重是3x3x64x128。一次卷积,数据量有多大?

我算给你看:

  • 输入特征图:224 x 224 x 64 = 3.2M 字节(假设FP16)
  • 权重:3 x 3 x 64 x 128 = 0.74M 字节
  • 输出特征图:224 x 224 x 128 = 6.4M 字节

一次卷积就要搬10M字节以上。如果帧率是30fps,那带宽需求就是300MB/s以上。这还只是一个层。整个网络几十层,带宽压力可想而知。

核心矛盾:计算单元越来越快,但DRAM带宽的增长速度跟不上。结果就是计算单元经常「饿着肚子」等数据。

4.2 三种数据复用策略

解决带宽问题,核心思路就一个——让数据在片上多待一会儿。说白了就是复用。

4.2.1 输入特征图复用

输入特征图复用,就是让同一个输入像素被多个权重共享。

举个例子,一个3x3的卷积核在特征图上滑动。同一个输入像素,会被卷积核的9个权重分别乘一次。如果能把输入特征图留在片上,就不用反复从DRAM里读了。

我在项目中遇到过一种情况:输入特征图很大(比如512x512),片上SRAM放不下。这时候怎么办?我建议做行缓冲(row buffer)。只缓存当前计算需要的几行数据,滑动窗口慢慢往前推。

// 行缓冲伪代码示例
// 假设卷积核大小为3x3
#define ROW_BUF_SIZE 3  // 缓存3行

void conv_with_row_buffer(input_fmap, weight, output) {
    int row_buf[ROW_BUF_SIZE][WIDTH];  // 片上行缓冲
    
    for (int r = 0; r < HEIGHT; r++) {
        // 从DRAM加载新的一行到行缓冲
        load_row_from_dram(&row_buf[r % 3], input_fmap[r]);
        
        // 当行缓冲填满3行后,开始计算
        if (r >= 2) {
            for (int c = 0; c < WIDTH; c++) {
                // 从行缓冲中取3x3窗口
                // 计算卷积
                output[r-1][c] = convolve_3x3(row_buf, c, weight);
            }
        }
    }
}

我的经验:行缓冲的大小要跟卷积核大小匹配。3x3卷积就缓存3行,5x5就缓存5行。多了浪费SRAM,少了又不够用。

4.2.2 权重复用

权重复用,就是让同一个权重被多个输入像素共享。

你想想看,一个3x3的卷积核,在特征图上滑动时,同一个权重会被用到很多次。比如输入是224x224,那每个权重会被用224x224=50176次。如果能把权重留在片上,那带宽节省就非常可观。

我建议的做法是:权重先全部加载到片上,然后在整个特征图计算完成之前,不要换权重。这要求片上SRAM能放下至少一个卷积层的所有权重。

网络层 权重大小(FP16) 片上SRAM需求 是否可行
Conv1 (3x3x3x64) 3.4KB 很小 ✅ 完全可行
Conv5 (3x3x512x512) 4.5MB 较大 ⚠️ 需要权衡
FC层 (4096x4096) 32MB 巨大 ❌ 无法全缓存

注意:当权重太大放不下时,就需要做权重分片(weight tiling)。把权重分成几块,分批次加载。我曾经在这个问题上踩过坑——分片粒度太细,导致加载次数太多,反而增加了带宽开销。

4.2.3 部分和复用

部分和复用,是三种复用里最容易忽略、但效果最明显的一个。

什么叫部分和?就是卷积计算过程中的中间结果。比如一个3x3卷积,要累加9个乘积。前8个乘积加起来,就是部分和。最后一个乘积加上去,才是最终结果。

如果能把部分和留在片上,等所有累加完成后再写回DRAM,那就能省掉大量的中间结果读写。

// 部分和累加示例
// 假设输出特征图大小为 HxW,输出通道数为 C_out

void conv_with_psum_accum(input_fmap, weight, output) {
    int psum[H][W][C_out];  // 片上部分和缓冲区
    
    // 初始化部分和为0
    memset(psum, 0, sizeof(psum));
    
    // 对每个输入通道进行卷积
    for (int c_in = 0; c_in < C_in; c_in++) {
        for (int r = 0; r < H; r++) {
            for (int c = 0; c < W; c++) {
                for (int f = 0; f < C_out; f++) {
                    // 累加到部分和
                    psum[r][c][f] += 
                        input_fmap[r][c][c_in] * weight[c_in][f];
                }
            }
        }
    }
    
    // 所有输入通道处理完后,一次性写回DRAM
    write_to_dram(output, psum, sizeof(psum));
}

关键点:部分和缓冲区的大小决定了能省多少带宽。缓冲区越大,能累加的通道数越多,带宽节省越明显。但SRAM面积有限,需要做权衡。

4.3 数据流调度策略

有了三种复用策略,怎么把它们组合起来?这就涉及到数据流调度了。

我个人习惯把数据流分为三种经典模式:

  1. 权重固定(Weight Stationary):权重留在片上不动,输入特征图和部分和流进来流出去。适合权重小、输入大的场景。
  2. 输入固定(Input Stationary):输入特征图留在片上,权重和部分和流动。适合输入小、权重大的场景。
  3. 输出固定(Output Stationary):部分和留在片上,输入和权重流动。适合输出通道数多的场景。

我建议的做法是:不要死板套用一种模式。不同层用不同策略。比如浅层用权重固定,深层用输出固定。我在一个项目里就是这么做的,带宽节省了40%。

避坑指南:我曾经在一个项目里,所有层都用同一种数据流。结果浅层跑得飞快,深层却卡得要死。后来才发现,深层权重太大,片上放不下,频繁换权重导致带宽爆炸。所以,一定要根据每层的特点动态选择数据流

4.4 带宽瓶颈案例分析

最后分享一个我实际遇到的案例。

那是一个自动驾驶的目标检测项目,用的是YOLOv3。芯片的算力是4TOPS,理论帧率能到60fps。但实际跑起来,只有20fps。

我一查,问题出在DRAM带宽上。芯片的DRAM带宽是12.8GB/s,但实际有效带宽只有6GB/s左右。为什么?因为数据复用做得不好

具体问题:

  • 输入特征图没有做行缓冲,每次计算都要从DRAM读一整张图
  • 权重没有做分片,大的权重层频繁换入换出
  • 部分和没有做片上累加,中间结果反复读写

我的解决方案:

  1. 给每个卷积层配了行缓冲,输入特征图复用率提升了5倍
  2. 对大的权重层做了分片,分片大小跟片上SRAM匹配
  3. 增加了部分和缓冲区,中间结果读写减少了70%

优化后,有效带宽从6GB/s提升到了10GB/s,帧率从20fps提升到了45fps。虽然没有达到理论值,但已经非常接近了。

教训:带宽优化不是一蹴而就的。需要反复调参,找到最适合当前芯片和网络结构的配置。我建议你准备一个带宽分析脚本,每次改完配置就跑一遍,看看带宽瓶颈在哪里。

4.5 本章小结

数据复用和带宽优化,说白了就是让数据在片上多待一会儿。三种复用策略——输入特征图复用、权重复用、部分和复用——各有适用场景。数据流调度则是把这些策略组合起来,形成最优方案。

嗯,记住一句话:计算是免费的,数据搬运是昂贵的。做加速器设计,先把数据流想清楚,再谈计算单元。

数据复用与带宽优化核心逻辑 DRAM带宽瓶颈 输入特征图复用 权重复用 部分和复用 实现方法 行缓冲(Row Buffer) 滑动窗口缓存 实现方法 权重全缓存 权重分片(Tiling) 实现方法 片上部分和累加 延迟写回DRAM 数据流调度策略 减少片外DRAM访问 → 提升有效带宽

专注资料整理