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 数据流调度策略
有了三种复用策略,怎么把它们组合起来?这就涉及到数据流调度了。
我个人习惯把数据流分为三种经典模式:
- 权重固定(Weight Stationary):权重留在片上不动,输入特征图和部分和流进来流出去。适合权重小、输入大的场景。
- 输入固定(Input Stationary):输入特征图留在片上,权重和部分和流动。适合输入小、权重大的场景。
- 输出固定(Output Stationary):部分和留在片上,输入和权重流动。适合输出通道数多的场景。
我建议的做法是:不要死板套用一种模式。不同层用不同策略。比如浅层用权重固定,深层用输出固定。我在一个项目里就是这么做的,带宽节省了40%。
避坑指南:我曾经在一个项目里,所有层都用同一种数据流。结果浅层跑得飞快,深层却卡得要死。后来才发现,深层权重太大,片上放不下,频繁换权重导致带宽爆炸。所以,一定要根据每层的特点动态选择数据流。
4.4 带宽瓶颈案例分析
最后分享一个我实际遇到的案例。
那是一个自动驾驶的目标检测项目,用的是YOLOv3。芯片的算力是4TOPS,理论帧率能到60fps。但实际跑起来,只有20fps。
我一查,问题出在DRAM带宽上。芯片的DRAM带宽是12.8GB/s,但实际有效带宽只有6GB/s左右。为什么?因为数据复用做得不好。
具体问题:
- 输入特征图没有做行缓冲,每次计算都要从DRAM读一整张图
- 权重没有做分片,大的权重层频繁换入换出
- 部分和没有做片上累加,中间结果反复读写
我的解决方案:
- 给每个卷积层配了行缓冲,输入特征图复用率提升了5倍
- 对大的权重层做了分片,分片大小跟片上SRAM匹配
- 增加了部分和缓冲区,中间结果读写减少了70%
优化后,有效带宽从6GB/s提升到了10GB/s,帧率从20fps提升到了45fps。虽然没有达到理论值,但已经非常接近了。
教训:带宽优化不是一蹴而就的。需要反复调参,找到最适合当前芯片和网络结构的配置。我建议你准备一个带宽分析脚本,每次改完配置就跑一遍,看看带宽瓶颈在哪里。
4.5 本章小结
数据复用和带宽优化,说白了就是让数据在片上多待一会儿。三种复用策略——输入特征图复用、权重复用、部分和复用——各有适用场景。数据流调度则是把这些策略组合起来,形成最优方案。
嗯,记住一句话:计算是免费的,数据搬运是昂贵的。做加速器设计,先把数据流想清楚,再谈计算单元。