4、循环优化技术:循环展开、循环分块、循环重排
说到算子优化,循环优化是绕不开的核心话题。我刚开始做模型部署那会儿,总觉得写出来的算子能跑就行,结果一上板子,性能惨不忍睹。后来才明白,循环的写法,直接决定了计算效率的上限。
今天咱们聊聊三种最常用的循环优化手段:循环展开、循环分块、循环重排。这三板斧用好了,算子的性能能翻好几倍。
4.1 循环展开:减少控制开销,挖掘指令级并行
先说说循环展开。说白了,就是把循环体里的代码复制几份,减少循环次数。为什么要这么做?
你想想看,每次循环都要做一次条件判断、一次跳转。循环次数少的时候还好,要是循环个几百万次,这些控制开销就相当可观了。另外,展开后编译器更容易做指令调度,能更好地利用CPU的流水线。
核心收益:
- 减少循环控制指令(条件判断、跳转)的执行次数
- 增加指令级并行度,让CPU流水线更饱满
- 为寄存器分配提供更多机会
举个例子,一个简单的向量加法:
// 原始循环
for (int i = 0; i < N; i++) {
C[i] = A[i] + B[i];
}
// 展开4倍
for (int i = 0; i < N; i += 4) {
C[i] = A[i] + B[i];
C[i+1] = A[i+1] + B[i+1];
C[i+2] = A[i+2] + B[i+2];
C[i+3] = A[i+3] + B[i+3];
}
展开后,循环次数从N次降到了N/4次。每次循环里做4次加法,编译器可以同时发射多条指令。我在项目中遇到过,展开因子选4到8倍通常效果最好,再大反而会因为寄存器压力过大导致性能下降。
我的经验:展开因子不是越大越好。我建议你先从2倍开始试,逐步增加,同时观察编译器的寄存器分配情况。如果发现寄存器溢出(spilling)严重,就该减小展开因子了。
4.2 循环分块:提升缓存命中率
循环分块,也叫循环分片(Loop Tiling)。这个技术解决的是数据局部性问题。
为什么会这样?现代CPU的缓存是分级的,L1最快但最小,L3最大但慢。如果数据访问跨度太大,缓存就会频繁失效,数据得从内存里慢慢搬。循环分块的核心思想是:把大循环切成小块,让每一块的数据都能塞进缓存。
最经典的场景是矩阵乘法。直接写三层循环,内层访问B矩阵时是按列走的,缓存命中率极低。
// 原始矩阵乘法(缓存不友好)
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
float sum = 0.0f;
for (int k = 0; k < K; k++) {
sum += A[i][k] * B[k][j]; // B按列访问,缓存不友好
}
C[i][j] = sum;
}
}
// 分块后的矩阵乘法
#define BLOCK_SIZE 32
for (int i = 0; i < M; i += BLOCK_SIZE) {
for (int j = 0; j < N; j += BLOCK_SIZE) {
for (int k = 0; k < K; k += BLOCK_SIZE) {
// 处理一个BLOCK_SIZE x BLOCK_SIZE的小块
for (int ii = i; ii < i + BLOCK_SIZE; ii++) {
for (int jj = j; jj < j + BLOCK_SIZE; jj++) {
float sum = C[ii][jj];
for (int kk = k; kk < k + BLOCK_SIZE; kk++) {
sum += A[ii][kk] * B[kk][jj];
}
C[ii][jj] = sum;
}
}
}
}
}
分块后,A和B的子块都能放进L1缓存,数据复用率大幅提升。我曾经在优化一个卷积算子时,仅仅加了分块,性能就提升了3倍。
注意:分块大小要结合目标硬件的缓存大小来定。一般取L1缓存大小的1/4到1/2比较合适。比如L1数据缓存32KB,每个块大小取32x32(每个float 4字节,共4KB)就挺合理。
4.3 循环重排:调整访问模式
循环重排,就是改变循环嵌套的顺序。这个技术主要用来优化数据访问的连续性。
你想想看,CPU从内存读数据时,是按缓存行(通常64字节)为单位读取的。如果访问地址是连续的,一个缓存行里的数据都能用上。如果跳着访问,那就浪费了带宽。
举个例子,二维数组在内存中是按行存储的。如果按列访问,每次都要跨过一整行,缓存命中率极差。
// 不友好的访问模式(按列访问)
for (int j = 0; j < N; j++) {
for (int i = 0; i < M; i++) {
sum += matrix[i][j]; // 每次访问跨一整行
}
}
// 重排后(按行访问)
for (int i = 0; i < M; i++) {
for (int j = 0; j < N; j++) {
sum += matrix[i][j]; // 连续访问,缓存友好
}
}
就这么简单的一换,性能差距可能达到10倍以上。我记得有一次优化图像处理算子,原始代码是外层循环列、内层循环行,我改成外层行、内层列后,处理一张1080p的图片从15毫秒降到了3毫秒。
4.4 三种技术的组合使用
实际项目中,这三种技术往往是组合使用的。我个人的习惯是:
- 先做循环重排,确保数据访问是连续的
- 再做循环分块,让数据能塞进缓存
- 最后做循环展开,榨干指令级并行的潜力
这个顺序是有讲究的。重排和分块解决的是内存访问问题,这是性能瓶颈的大头。展开解决的是计算问题,是在前两步基础上的锦上添花。
组合示例(矩阵乘法优化):
- 重排:将ijk顺序改为ikj,让内层访问连续
- 分块:对i、j、k三个维度都分块,块大小32
- 展开:内层循环展开4倍,减少控制开销
这样组合下来,矩阵乘法的性能通常能达到理论峰值的80%以上。
4.5 避坑指南
最后分享几个我踩过的坑:
我曾经在ARM平台上做优化,把循环展开因子设成了16,结果性能反而下降了。后来发现是寄存器不够用,编译器不得不把变量 spill 到栈上。所以,展开前先看看目标CPU有多少个通用寄存器。
另一个坑:分块大小不是越小越好。块太小了,循环控制开销占比太大,反而得不偿失。我建议块大小至少是缓存行大小的4倍以上。
嗯,循环优化就聊到这儿。这三种技术看着简单,但真正用好需要结合硬件特性反复调参。下次咱们聊聊内存布局优化,那是另一个能带来巨大性能提升的方向。