循环优化:循环展开、循环交换、循环融合、循环分块
循环,说白了就是程序里的「体力活」。你想想看,一个程序跑得慢,十有八九是循环在拖后腿。我这些年做性能调优,碰到的热点函数里,循环体占了七八成。今天咱们就把循环优化的四个核心招式掰开揉碎讲清楚。
一、循环展开:减少开销,暴露并行
循环展开是最直观的优化手段。它的核心思想很简单:减少循环控制的开销,同时给编译器更多指令级并行的空间。
先看一个最普通的循环:
// 原始循环
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 压力增大 |
| 循环融合 | 提高数据重用率 | 相邻循环操作同一数组 | 引入依赖,破坏向量化 |
| 循环分块 | 适配缓存层次结构 | 大数组、矩阵运算 | 循环嵌套变深,代码复杂 |
六、知识体系总览
下面这张图把四种技术的定位和关系梳理清楚了:
七、实战建议
说了这么多,到底怎么用?我个人的工作流是这样的:
- 先定位热点——用 perf 或者 VTune 找到最耗时的循环,别瞎优化。
- 看数据访问模式——是连续访问还是跳跃访问?数组有多大?
- 选技术——小循环优先展开,大数组考虑分块,多维数组检查交换,相邻循环试试融合。
- 验证效果——每次只改一处,对比性能。我习惯用 rdtsc 指令精确测量循环耗时。
重要提醒:编译器(尤其是 GCC 和 Clang)已经内置了很多循环优化。你手动展开的代码,编译器可能早就帮你做了。所以我的建议是:先让编译器用 -O2/-O3 优化,然后看性能分析报告,如果还有瓶颈,再手动介入。
循环优化这件事,说白了就是跟 CPU 的缓存和流水线打交道。你理解得越深,优化就越精准。别指望一招鲜吃遍天,多试、多测、多分析,慢慢就有感觉了。