循环分块(Loop Tiling):分块大小的数学原理

循环分块,说白了就是「把大循环切成小方块」。

我刚开始做嵌入式AI推理时,遇到一个很头疼的问题:模型跑起来,CPU利用率看着挺高,但帧率就是上不去。后来一查,发现大部分时间都花在了缓存缺失上。嗯,那时候我才真正意识到——计算不是瓶颈,数据搬运才是

循环分块就是专门解决这个问题的。它的核心思想很简单:让数据在进入缓存后,被尽可能多地重复使用,再被踢出去

分块大小的数学原理

先问一个问题:分块到底分多大?

我个人习惯用一个公式来估算:

分块大小 ≤ 缓存容量 × 缓存关联度 / 数据通路宽度

举个例子。假设你的L1缓存是32KB,4路组相联,缓存行64字节。那么一个分块能安全使用的容量大约是:

32KB × 4 / 64B = 2048 个缓存行 ≈ 128KB

等等,这里有个坑。你想想看,缓存里不可能只放你这一块数据。中断、上下文切换、其他任务的数据,都会挤占缓存空间。所以实际分块大小,我建议只取理论值的 50%~70%

我曾经踩过的坑: 有一次我把分块大小设成了刚好等于缓存容量。结果跑起来性能反而下降了。为什么?因为缓存被占满后,每次访问都会触发缓存替换,相当于「自己跟自己打架」。后来我改成60%,性能直接提升了40%。

分块对时间局部性的改善

时间局部性,说白了就是「刚用过的数据,马上再用一次」。

没有分块时,矩阵乘法是这样的:

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的一个小块会留在缓存里,被反复使用。等这个小块算完了,再换下一块。这就是时间局部性的改善。

关键点: 分块让数据在缓存中的「驻留时间」变长了。原来一个数据可能只被用1次就被踢走,现在可以被用几十次甚至上百次。

分块对空间局部性的改善

空间局部性,就是「用了一个数据,旁边的数据很快也会用到」。

在嵌入式平台上,内存访问是按缓存行(通常64字节)为单位加载的。如果你只用一个数据,那剩下的63字节就浪费了。

分块怎么改善空间局部性?

我举个例子。假设你有一个3x3的卷积核,在特征图上滑动。没有分块时,每次滑动都要重新加载相邻的像素数据。有了分块后,一个分块内的所有像素会被一次性加载到缓存中,相邻像素的访问命中率会大幅提升。

场景 缓存缺失率 说明
无分块 约 30%~50% 每次滑动都重新加载
有分块(块大小=8) 约 5%~10% 相邻数据在缓存中命中
有分块(块大小=16) 约 2%~5% 空间局部性充分利用
小技巧: 分块大小最好是缓存行大小的整数倍。比如缓存行64字节,分块大小取64、128、256。这样能避免跨缓存行访问带来的额外开销。

多级缓存的分块策略

现在的嵌入式芯片,基本都有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缓存中命中
  • 两者配合:减少从主存加载数据的次数
注意: 多级分块不是分得越多越好。分块层级太多,循环嵌套过深,反而会增加循环控制的开销。我一般控制在2~3层。

最后说一个我自己的经验:分块大小不是固定的。不同芯片的缓存参数不一样,同一个芯片上跑不同模型,最优分块大小也可能不同。我建议在开发阶段,写一个自动调参的脚本,跑一遍不同分块大小的性能,选最优的那个。

嗯,循环分块就讲到这里。下一章我们聊聊数据对齐和预取,这两个技巧和分块配合使用,效果会更好。