循环分块(Loop Tiling):分块大小的数学原理
循环分块,说白了就是「把大循环切成小方块」。
我刚开始做嵌入式AI推理时,遇到一个很头疼的问题:模型跑起来,CPU利用率看着挺高,但帧率就是上不去。后来一查,发现大部分时间都花在了缓存缺失上。嗯,那时候我才真正意识到——计算不是瓶颈,数据搬运才是。
循环分块就是专门解决这个问题的。它的核心思想很简单:让数据在进入缓存后,被尽可能多地重复使用,再被踢出去。
分块大小的数学原理
先问一个问题:分块到底分多大?
我个人习惯用一个公式来估算:
分块大小 ≤ 缓存容量 × 缓存关联度 / 数据通路宽度
举个例子。假设你的L1缓存是32KB,4路组相联,缓存行64字节。那么一个分块能安全使用的容量大约是:
32KB × 4 / 64B = 2048 个缓存行 ≈ 128KB
等等,这里有个坑。你想想看,缓存里不可能只放你这一块数据。中断、上下文切换、其他任务的数据,都会挤占缓存空间。所以实际分块大小,我建议只取理论值的 50%~70%。
分块对时间局部性的改善
时间局部性,说白了就是「刚用过的数据,马上再用一次」。
没有分块时,矩阵乘法是这样的:
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
for (k = 0; k < N; k++)
C[i][j] += A[i][k] * B[k][j];
你看,内层循环每次都要重新加载B[k][j]。等k跑完一轮,B的整列数据早就被踢出缓存了。下次再用,又得从内存搬一次。
加上分块后:
for (ii = 0; ii < N; ii += TILE)
for (jj = 0; jj < N; jj += TILE)
for (kk = 0; kk < N; kk += TILE)
for (i = ii; i < ii+TILE; i++)
for (j = jj; j < jj+TILE; j++)
for (k = kk; k < kk+TILE; k++)
C[i][j] += A[i][k] * B[k][j];
现在,A和B的一个小块会留在缓存里,被反复使用。等这个小块算完了,再换下一块。这就是时间局部性的改善。
分块对空间局部性的改善
空间局部性,就是「用了一个数据,旁边的数据很快也会用到」。
在嵌入式平台上,内存访问是按缓存行(通常64字节)为单位加载的。如果你只用一个数据,那剩下的63字节就浪费了。
分块怎么改善空间局部性?
我举个例子。假设你有一个3x3的卷积核,在特征图上滑动。没有分块时,每次滑动都要重新加载相邻的像素数据。有了分块后,一个分块内的所有像素会被一次性加载到缓存中,相邻像素的访问命中率会大幅提升。
| 场景 | 缓存缺失率 | 说明 |
|---|---|---|
| 无分块 | 约 30%~50% | 每次滑动都重新加载 |
| 有分块(块大小=8) | 约 5%~10% | 相邻数据在缓存中命中 |
| 有分块(块大小=16) | 约 2%~5% | 空间局部性充分利用 |
多级缓存的分块策略
现在的嵌入式芯片,基本都有L1、L2两级缓存,有的还有L3。不同级别的缓存,大小和速度都不一样。
我的策略是:外层分块适配L2,内层分块适配L1。
具体来说:
// L2分块:适配L2缓存(比如256KB)
for (ii = 0; ii < N; ii += L2_TILE)
for (jj = 0; jj < N; jj += L2_TILE)
// L1分块:适配L1缓存(比如32KB)
for (i = ii; i < ii+L2_TILE; i += L1_TILE)
for (j = jj; j < jj+L2_TILE; j += L1_TILE)
// 实际计算
for (k = 0; k < N; k++)
C[i][j] += A[i][k] * B[k][j];
这样设计的好处是:
- L1分块:保证最内层计算时,数据在L1缓存中命中
- L2分块:保证外层循环时,数据在L2缓存中命中
- 两者配合:减少从主存加载数据的次数
最后说一个我自己的经验:分块大小不是固定的。不同芯片的缓存参数不一样,同一个芯片上跑不同模型,最优分块大小也可能不同。我建议在开发阶段,写一个自动调参的脚本,跑一遍不同分块大小的性能,选最优的那个。
嗯,循环分块就讲到这里。下一章我们聊聊数据对齐和预取,这两个技巧和分块配合使用,效果会更好。