5. 循环优化:循环展开、循环交换、循环合并、减少循环内分支

循环,说白了就是程序里最耗时的「大户」。我做过不少性能调优的项目,打开性能分析器一看,热点函数里十有八九都是循环在吃 CPU。你想想看,一条指令执行一次可能只要几纳秒,但循环个几百万次,那时间就相当可观了。

今天咱们就聊聊循环优化的四个核心手段:循环展开、循环交换、循环合并、减少循环内分支。这些技巧我在实际项目中反复用过,效果立竿见影。

核心观点:循环优化的本质是减少循环开销、提高指令级并行、改善缓存局部性。别小看这些「小技巧」,组合起来往往能带来 2-5 倍的性能提升。

5.1 循环展开

循环展开,就是把循环体复制多份,减少循环控制指令的执行次数。每次循环都要做条件判断、计数器加减、跳转,这些开销虽然小,但架不住次数多啊。

举个例子,一个简单的累加循环:

// 原始循环
int sum = 0;
for (int i = 0; i < 1000; i++) {
    sum += arr[i];
}

// 展开 4 次
int sum = 0;
int i;
for (i = 0; i < 1000 - 3; i += 4) {
    sum += arr[i];
    sum += arr[i+1];
    sum += arr[i+2];
    sum += arr[i+3];
}
// 处理剩余元素
for (; i < 1000; i++) {
    sum += arr[i];
}

展开后,循环次数从 1000 次降到了 250 次。循环控制指令减少了 75%。

展开因子怎么选? 我个人习惯先看 CPU 的流水线深度和寄存器数量。一般展开 2-4 次比较稳妥。展开太多反而可能寄存器溢出,得不偿失。

小技巧:展开后记得处理剩余元素。我见过有人忘了这步,结果数组长度不是展开倍数的倍数时,数据就漏了。嗯,这种 bug 很难查。

5.2 循环交换

循环交换主要针对多维数组的遍历。C 语言里数组是行优先存储的,也就是说 arr[i][j]arr[i][j+1] 在内存里是挨着的。如果遍历顺序不对,缓存命中率会惨不忍睹。

我曾经优化过一个矩阵乘法,原始代码是这样的:

// 缓存不友好的写法
for (int j = 0; j < N; j++) {
    for (int i = 0; i < M; i++) {
        sum += arr[i][j];  // 按列访问,跳跃大
    }
}

// 缓存友好的写法
for (int i = 0; i < M; i++) {
    for (int j = 0; j < N; j++) {
        sum += arr[i][j];  // 按行访问,连续
    }
}

为什么差别这么大?因为 CPU 从内存读数据时,一次会加载一整块(缓存行,通常 64 字节)。按行访问时,下一个数据大概率已经在缓存里了。按列访问呢?每次都要重新加载,缓存命中率直线下降。

访问模式 缓存命中率 执行时间(相对)
行优先(连续) ~95% 1x
列优先(跳跃) ~30% 5-10x

我在项目中遇到过类似情况,一个图像处理算法跑得很慢,性能分析器显示大量缓存未命中。把内外层循环一交换,速度直接快了 6 倍。你想想看,就改了两行代码的事。

5.3 循环合并

循环合并,就是把两个或多个遍历相同范围的循环合并成一个。好处很明显:减少了循环控制开销,也提高了数据复用。

// 合并前
for (int i = 0; i < N; i++) {
    a[i] = b[i] * 2;
}
for (int i = 0; i < N; i++) {
    c[i] = d[i] + 1;
}

// 合并后
for (int i = 0; i < N; i++) {
    a[i] = b[i] * 2;
    c[i] = d[i] + 1;
}

合并后,循环次数从 2N 降到了 N。而且如果 b[i]d[i] 在内存里挨着,还能进一步改善缓存局部性。

注意:循环合并不是万能的。如果两个循环的依赖关系不同,或者合并后导致寄存器压力过大,反而会变慢。我曾经在一个 DSP 算法里强行合并,结果性能反而下降了 20%。后来拆开才恢复正常。

5.4 减少循环内分支

循环内的分支(if-else)是性能杀手。现代 CPU 有分支预测机制,但预测失败时流水线要清空重来,代价很大。我建议尽量把分支提到循环外面,或者用其他方式消除分支。

看个例子:

// 分支在循环内
for (int i = 0; i < N; i++) {
    if (condition) {
        result[i] = a[i] * 2;
    } else {
        result[i] = a[i] + 1;
    }
}

// 分支提到循环外
if (condition) {
    for (int i = 0; i < N; i++) {
        result[i] = a[i] * 2;
    }
} else {
    for (int i = 0; i < N; i++) {
        result[i] = a[i] + 1;
    }
}

第二种写法虽然代码长了点,但每个循环内部没有分支,CPU 可以全速执行。如果 condition 在循环过程中不变,那效果尤其明显。

还有一种技巧是用查表代替分支:

// 用查表消除分支
int lookup[] = {0, 1, 0, 1};  // 假设条件只有几种组合
for (int i = 0; i < N; i++) {
    result[i] = a[i] + lookup[flag[i]];
}

查表法把分支变成了内存访问,虽然也有开销,但比分支预测失败好得多。我曾经在视频编码器里用这招,把一个热点函数的速度提升了 40%。

避坑指南:我曾经在一个循环里写了个 if (x > 0) ... else if (x == 0) ... else ... 的三路分支,性能惨不忍睹。后来改成用 signbit() 和位运算,直接消除了分支。嗯,位运算有时候比分支快得多。

5.5 知识体系总览

下面这张图总结了循环优化的四个核心方向,以及它们各自解决的问题:

循环优化四大方向 循环展开 减少循环控制开销 提高指令级并行 循环交换 改善缓存局部性 行优先 vs 列优先 循环合并 减少循环次数 提高数据复用 减少分支 避免预测失败 查表/外提 实际应用建议 • 先做性能分析,找到热点循环 • 优先优化最内层循环 • 组合使用效果更佳(如展开 + 交换) • 每次优化后都要 benchmark 验证

5.6 实战要点总结

说了这么多,我总结几条实战中最重要的原则:

  • 先测量,再优化。 别凭感觉改代码。用 perf、VTune 之类的工具看看热点在哪。
  • 从内层循环开始。 最内层的循环执行次数最多,优化收益最大。
  • 组合使用。 比如先做循环交换改善缓存,再对最内层做展开,效果往往 1+1 > 2。
  • 注意可读性。 优化后的代码可以加注释说明为什么这么写。不然三个月后你自己都看不懂。

最后说一句:循环优化不是银弹。有些场景下,编译器已经帮你做了展开和交换。我建议先看看编译器的优化报告(比如 GCC 的 -fopt-info),再决定要不要手动干预。嗯,有时候编译器比你想的更聪明。

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