循环优化:循环展开、循环交换、循环融合、循环分块

循环,说白了就是程序里的「体力活」。你想想看,一个程序跑得慢,十有八九是循环在拖后腿。我这些年做性能调优,碰到的热点函数里,循环体占了七八成。今天咱们就把循环优化的四个核心招式掰开揉碎讲清楚。

一、循环展开:减少开销,暴露并行

循环展开是最直观的优化手段。它的核心思想很简单:减少循环控制的开销,同时给编译器更多指令级并行的空间。

先看一个最普通的循环:

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

每次迭代都要做 i 的比较、自增、跳转。如果展开成 4 次一组:

// 循环展开 4 次
for (int i = 0; i < 1000; i += 4) {
    a[i]   = b[i]   + c[i];
    a[i+1] = b[i+1] + c[i+1];
    a[i+2] = b[i+2] + c[i+2];
    a[i+3] = b[i+3] + c[i+3];
}

循环次数从 1000 降到了 250,控制开销直接砍掉 75%。而且现代 CPU 可以同时发射多条无关的加法指令,性能提升很明显。

关键点:展开因子不是越大越好。我见过有人展开 32 次,结果寄存器溢出,性能反而下降。一般 2~8 次比较稳妥,具体要看你的 CPU 架构和循环体复杂度。

我的习惯:先看汇编输出。如果循环体里出现了大量 load/store 到栈上,说明寄存器不够用了,这时候就该减小展开因子。

二、循环交换:让内存访问更友好

循环交换优化的是数据局部性。说白了,就是让 CPU 尽量访问连续的内存地址,充分利用缓存行。

二维数组在内存中是按行存储的。看这个例子:

// 按列访问——缓存不友好
for (int j = 0; j < N; j++) {
    for (int i = 0; i < M; i++) {
        sum += a[i][j];
    }
}

内层循环 i 变化时,a[i][j] 跳着访问不同行,每次都要重新加载缓存行。换成按行访问:

// 按行访问——缓存友好
for (int i = 0; i < M; i++) {
    for (int j = 0; j < N; j++) {
        sum += a[i][j];
    }
}

内层循环 j 变化时,a[i][j] 是连续地址,一个缓存行能装下 8 个 float,命中率极高。

我曾经踩过的坑:有一次优化矩阵乘法,我盲目交换了循环,结果性能反而差了。后来发现是因为外层循环的步长太大,导致 TLB 频繁失效。嗯,这里要注意:循环交换不是万能的,你得先搞清楚数据访问模式。

三、循环融合:合并相邻循环

循环融合,就是把两个相邻的循环合并成一个。好处很明显:减少一次完整的数组遍历,提高数据重用率。

// 两个独立循环
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] 刚算完就被用上了,还在寄存器里,不用再读一遍内存。而且循环控制开销也减半了。

但融合不是总能做的。如果两个循环的依赖关系不同,或者数组大小不一样,强行融合反而会出问题。

判断标准:两个循环的迭代空间必须一致,且融合后不能引入新的数据依赖。我一般先画个依赖图,看一眼就清楚了。

四、循环分块:分而治之,吃满缓存

循环分块,也叫循环分片,是处理大数组时的杀手锏。它的思路是把大循环切成小块,让每一块的数据都能塞进 CPU 缓存。

看矩阵乘法这个经典场景:

// 原始三重循环
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        for (int k = 0; k < N; k++) {
            c[i][j] += a[i][k] * b[k][j];
        }
    }
}

当 N 很大时,b[k][j] 的访问模式会导致严重的缓存缺失。分块之后:

// 分块后的三重循环
int block = 64;  // 块大小
for (int ii = 0; ii < N; ii += block) {
    for (int jj = 0; jj < N; jj += block) {
        for (int kk = 0; kk < N; kk += block) {
            for (int i = ii; i < ii + block; i++) {
                for (int j = jj; j < jj + block; j++) {
                    for (int k = kk; k < kk + block; k++) {
                        c[i][j] += a[i][k] * b[k][j];
                    }
                }
            }
        }
    }
}

虽然循环层数变多了,但每一块的数据都能完整地留在 L1/L2 缓存里,整体性能反而大幅提升。

块大小怎么选?我一般用 L1 数据缓存大小的 1/4 到 1/2 作为初始值。比如 L1 是 32KB,float 占 4 字节,那一个块大概能装 4096 个元素,块大小取 64 左右比较合适。然后跑个微基准测试微调一下。

五、四种技术的对比与选择

这四种技术各有适用场景。我整理了一张表,方便你对照:

技术 核心目标 适用场景 风险
循环展开 减少控制开销,暴露 ILP 循环体小、迭代次数多 寄存器溢出,代码膨胀
循环交换 改善空间局部性 多维数组访问,步长不匹配 TLB 压力增大
循环融合 提高数据重用率 相邻循环操作同一数组 引入依赖,破坏向量化
循环分块 适配缓存层次结构 大数组、矩阵运算 循环嵌套变深,代码复杂

六、知识体系总览

下面这张图把四种技术的定位和关系梳理清楚了:

循环优化四种技术全景图 循环优化 循环展开 减少控制开销 循环交换 改善局部性 循环融合 提高数据重用 循环分块 适配缓存层次 四种技术可组合使用,具体选择取决于数据规模、访问模式和硬件特性

七、实战建议

说了这么多,到底怎么用?我个人的工作流是这样的:

  1. 先定位热点——用 perf 或者 VTune 找到最耗时的循环,别瞎优化。
  2. 看数据访问模式——是连续访问还是跳跃访问?数组有多大?
  3. 选技术——小循环优先展开,大数组考虑分块,多维数组检查交换,相邻循环试试融合。
  4. 验证效果——每次只改一处,对比性能。我习惯用 rdtsc 指令精确测量循环耗时。

重要提醒:编译器(尤其是 GCC 和 Clang)已经内置了很多循环优化。你手动展开的代码,编译器可能早就帮你做了。所以我的建议是:先让编译器用 -O2/-O3 优化,然后看性能分析报告,如果还有瓶颈,再手动介入。

循环优化这件事,说白了就是跟 CPU 的缓存和流水线打交道。你理解得越深,优化就越精准。别指望一招鲜吃遍天,多试、多测、多分析,慢慢就有感觉了。


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