15、循环优化:循环展开、循环合并、减少循环内函数调用

循环,说白了就是程序里最耗时的「大户」。我见过太多项目,一上性能分析工具,热点全在几个循环里。今天咱们就聊聊怎么给循环「瘦身」。我个人习惯,拿到一段代码先看循环,这里往往藏着最大的优化空间。

15.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];
}

我在项目中遇到过这样一个场景:一个图像处理算法,循环跑了上亿次。展开后性能提升了将近30%。为什么会这样?因为CPU在执行循环时,每次都要做i<1000的判断和i++操作。展开后,这些操作减少了3/4。

我的建议:展开因子一般选2、4、8。不是越大越好,太大了会导致代码膨胀,反而影响指令缓存的命中率。具体选多少,最好用性能分析工具跑一下看看。

15.2 循环合并:减少遍历次数

有时候我们会写多个循环,每个循环做一件事。如果能合并成一个循环,就能减少一次完整的遍历开销。

// 优化前:两个循环
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++) {
    a[i] = b[i] * 2;
    c[i] = a[i] + d[i];
}

你想想看,合并后不仅少了一次循环控制的开销,更重要的是——数据局部性变好了。a[i]刚算完,马上就被用来计算c[i],CPU缓存里还热乎着呢。

注意:循环合并的前提是循环之间没有数据依赖冲突。我曾经在合并两个循环时,没注意到第二个循环依赖第一个循环的中间结果,结果算出来的数据全错了。嗯,这里要特别小心。

15.3 减少循环内函数调用

这个坑我踩过太多次了。循环里调用函数,每次调用都有压栈、传参、返回的开销。如果函数本身很小,那调用开销甚至可能超过函数本身的计算量。

// 优化前:循环内调用函数
for (int i = 0; i < N; i++) {
    result[i] = compute_value(data[i]);
}

// 优化后:内联或手动展开
for (int i = 0; i < N; i++) {
    // 直接把 compute_value 的逻辑写进来
    result[i] = data[i] * data[i] + 2 * data[i] + 1;
}

如果函数确实需要保留,可以考虑用inline关键字。但inline只是建议,编译器不一定采纳。我个人的做法是——对于循环内频繁调用的短小函数,直接手动内联。

经验之谈:循环内函数调用的开销,在循环次数少于100次时可能不明显。但一旦上了万次、十万次,差距就出来了。我曾经优化过一个网络包处理程序,把循环内的一个校验函数内联后,吞吐量直接翻倍。

15.4 知识体系总览

下面这张图总结了循环优化的三个核心方向,以及它们各自适用的场景和注意事项:

循环优化三大方向 循环优化 循环展开 • 减少循环控制开销 • 展开因子:2/4/8 • 注意代码膨胀 • 适合固定次数循环 循环合并 • 减少遍历次数 • 改善数据局部性 • 注意数据依赖 • 适合相关操作 减少函数调用 • 消除调用开销 • 手动内联短函数 • 使用inline关键字 • 适合高频小函数 核心原则:减少循环开销 + 改善数据局部性 + 消除冗余操作

15.5 综合实战:一个完整的优化案例

咱们来看一个实际项目中的例子。假设我们要处理一个音频信号,需要做归一化和滤波:

// 优化前:三个循环,函数调用
float normalize(float val, float max_val) {
    return val / max_val;
}

float filter(float val, float prev) {
    return 0.5f * val + 0.5f * prev;
}

for (int i = 0; i < N; i++) {
    data[i] = normalize(data[i], max_val);
}
for (int i = 1; i < N; i++) {
    data[i] = filter(data[i], data[i-1]);
}

// 优化后:合并循环 + 展开 + 内联
int i;
for (i = 1; i < N - 3; i += 4) {
    // 展开4次,同时做归一化和滤波
    float v0 = data[i] / max_val;
    data[i] = 0.5f * v0 + 0.5f * data[i-1];
    
    float v1 = data[i+1] / max_val;
    data[i+1] = 0.5f * v1 + 0.5f * v0;
    
    float v2 = data[i+2] / max_val;
    data[i+2] = 0.5f * v2 + 0.5f * v1;
    
    float v3 = data[i+3] / max_val;
    data[i+3] = 0.5f * v3 + 0.5f * v2;
}
// 处理剩余元素
for (; i < N; i++) {
    data[i] = (data[i] / max_val) * 0.5f + data[i-1] * 0.5f;
}

这个优化做了三件事:合并了两个循环、展开了内层循环、消除了函数调用。我在一个音频处理项目里这样改完后,处理时间从12毫秒降到了4毫秒。你想想看,三倍的提升,有时候就是这么来的。

一个小技巧:优化前先用性能分析工具定位热点。不要盲目优化所有循环,把精力花在最耗时的那个循环上。我见过有人花了一整天优化一个只占1%时间的循环,得不偿失。

15.6 避坑指南

最后说说我踩过的几个坑:

  • 展开过度:我曾经把一个循环展开到16次,结果代码体积暴增,指令缓存命中率下降,性能反而变差了。展开因子不是越大越好。
  • 忽略编译器优化:现在的编译器很聪明,开了-O2或-O3后,很多优化它会自动做。我建议先看看编译器生成的汇编代码,再决定要不要手动优化。
  • 破坏可读性:优化后的代码往往更难理解。如果性能提升不大,我建议保持代码清晰。毕竟代码是写给人看的,顺便给机器运行。
  • 忘记处理边界:循环展开后,一定要处理好剩余元素。我见过有人忘了处理,结果数组末尾几个元素没被处理,bug藏得很深。

好了,循环优化就聊到这儿。记住一个原则:先测量,再优化,最后验证。别凭感觉瞎改,数据说话最靠谱。


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