4. 循环优化技巧:循环展开、循环合并、循环交换、循环剥离,减少分支预测失败
循环,说白了就是程序里最耗时的「大户」。我做了十几年嵌入式优化,有个很深的体会:优化循环,往往比优化算法本身更见效。为什么?因为循环体通常会被执行成千上万次,你哪怕只省掉一条指令,放大后都是可观的收益。
这一章,我带你逐个拆解循环优化的几个核心技巧。嗯,咱们不搞花架子,直接上干货。
4.1 循环展开:用空间换时间
循环展开,就是把循环体里的代码复制多份,减少循环控制的开销。你想想看,每次循环都要做一次条件判断、一次跳转,这些指令虽然不重,但架不住次数多啊。
举个例子,一个简单的累加循环:
// 原始循环
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += arr[i];
}
// 展开2倍
int sum = 0;
for (int i = 0; i < 100; i += 2) {
sum += arr[i];
sum += arr[i+1];
}
展开后,循环次数从100次降到了50次。分支判断少了一半,跳转指令也少了一半。我在项目中遇到过,一个简单的循环展开,让某款MCU上的音频处理程序快了将近30%。
4.2 循环合并:减少缓存污染
循环合并,就是把两个或多个遍历相同数据范围的循环,合并成一个。这样做的好处很明显:数据只需要加载一次到缓存里。
我曾经接手过一个图像处理项目,代码里写了三个独立的循环,分别做亮度调整、对比度增强和边缘检测。每个循环都要把整张图片从内存里读一遍。你想想看,一张1080p的图片,读三遍和读一遍,差别有多大?
// 合并前:三个循环,三次内存访问
for (int i = 0; i < N; i++) {
a[i] = b[i] * 2;
}
for (int i = 0; i < N; i++) {
c[i] = a[i] + d[i];
}
for (int i = 0; i < N; i++) {
e[i] = c[i] / 3;
}
// 合并后:一个循环,一次内存访问
for (int i = 0; i < N; i++) {
a[i] = b[i] * 2;
c[i] = a[i] + d[i];
e[i] = c[i] / 3;
}
4.3 循环交换:优化内存访问模式
C语言里,二维数组是按行优先存储的。也就是说,arr[i][j] 和 arr[i][j+1] 在内存中是相邻的。但 arr[i][j] 和 arr[i+1][j] 之间可能隔了整整一行。
所以,按行访问比按列访问快得多。循环交换,就是把内层和外层的循环变量互换,让内存访问更连续。
// 坏写法:按列访问,缓存不友好
for (int j = 0; j < COLS; j++) {
for (int i = 0; i < ROWS; i++) {
arr[i][j] = 0;
}
}
// 好写法:按行访问,缓存友好
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
arr[i][j] = 0;
}
}
我在项目中实测过,一个1024x1024的矩阵清零操作,按行访问比按列访问快了将近5倍。为什么会这样?因为按列访问时,每次都要跳到内存中很远的地方,缓存几乎不起作用。
4.4 循环剥离:消除边界处理
循环剥离,就是把循环体里那些「特殊」的迭代单独拿出来处理。最常见的场景是:循环体内有一个条件判断,但这个判断只在第一次或最后一次迭代时才成立。
你想想看,如果循环体里有个 if (i == 0),那这个判断在每次迭代时都要执行,但只有第一次是有效的。这不是浪费吗?
// 剥离前:每次迭代都要判断
for (int i = 0; i < N; i++) {
if (i == 0) {
// 特殊初始化
arr[i] = 0;
} else {
arr[i] = arr[i-1] + 1;
}
}
// 剥离后:特殊处理单独拿出来
arr[0] = 0; // 剥离第一次迭代
for (int i = 1; i < N; i++) {
arr[i] = arr[i-1] + 1;
}
4.5 减少分支预测失败
现代CPU都有分支预测器,它会猜测条件跳转的走向。如果猜对了,流水线不受影响;如果猜错了,流水线要清空重来,损失十几个甚至几十个时钟周期。
所以,让分支预测更容易猜对,是循环优化的一个重要方向。
怎么做?几个实用技巧:
- 把大概率发生的情况放在前面。比如
if (likely_case)而不是if (unlikely_case)。 - 用查表代替分支。比如根据某个值做不同处理,可以建一个函数指针数组。
- 用算术运算代替条件判断。比如
abs(x)可以用位运算实现,避免分支。
// 分支密集型代码
int sum = 0;
for (int i = 0; i < N; i++) {
if (arr[i] > 0) {
sum += arr[i];
}
}
// 优化后:用条件移动指令(如果编译器支持)
int sum = 0;
for (int i = 0; i < N; i++) {
sum += (arr[i] > 0) ? arr[i] : 0;
// 现代编译器可能会生成 cmov 指令,避免分支
}
知识体系总览
下面这张图,把本章的核心技巧和它们之间的关系梳理了一下。你可以把它当作一个快速参考。
小结
循环优化,说白了就是跟CPU和内存「打交道」。你要理解硬件是怎么工作的,才能写出高效的代码。我个人习惯是:先分析热点,再动手优化。不要一上来就各种展开、合并,先搞清楚瓶颈到底在哪。
另外,别忘了编译器。很多循环优化,现代编译器已经帮你做了。比如GCC的 -O2 和 -O3 就会自动做循环展开和循环交换。但编译器不是万能的,有些场景它判断不了,这时候就需要你手动介入。
公众号:蓝海资料掘金营,微信deep3321