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,然后在SRAM里做输入和权重复用,送到MAC阵列计算,计算结果在部分和累加器里复用累加,最后再写回SRAM和DRAM。整个流程环环相扣。
避坑指南:我曾经在一个项目里只优化了权重复用,忽略了输入复用,结果带宽瓶颈从权重变成了输入。后来不得不重新设计行缓冲结构。所以,四种策略要一起考虑,别只盯着一个。
好了,关于数据复用策略就聊这么多。记住一句话:数据搬移的次数,决定了加速器的天花板。把复用做好了,你的加速器就成功了一半。
可可精品资料库