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%。

我的建议:展开倍数一般取2、4、8。展开太多会导致代码膨胀,反而影响指令缓存的命中率。具体展开多少,最好实测一下。

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上尤其明显。比如ARM Cortex-A系列,一次预测失败可能要损失15-20个周期。而在简单的MCU上(比如Cortex-M),分支预测器很弱甚至没有,这时候减少分支的意义就不大。所以,先搞清楚你的目标平台

知识体系总览

下面这张图,把本章的核心技巧和它们之间的关系梳理了一下。你可以把它当作一个快速参考。

循环优化技巧 循环展开 循环合并 循环交换 循环剥离 减少分支预测失败 核心收益 减少循环控制开销 · 提高缓存命中率 · 降低分支预测失败率 最终目标:让程序跑得更快,更可预测

小结

循环优化,说白了就是跟CPU和内存「打交道」。你要理解硬件是怎么工作的,才能写出高效的代码。我个人习惯是:先分析热点,再动手优化。不要一上来就各种展开、合并,先搞清楚瓶颈到底在哪。

另外,别忘了编译器。很多循环优化,现代编译器已经帮你做了。比如GCC的 -O2-O3 就会自动做循环展开和循环交换。但编译器不是万能的,有些场景它判断不了,这时候就需要你手动介入。

最后一句:优化前先测一下,优化后再测一下。没有数据支撑的优化,都是耍流氓。

公众号:蓝海资料掘金营,微信deep3321