循环优化:循环展开、循环合并、循环交换、循环剥离、循环向量化
循环,说白了就是程序里最耗时的「热区」。我做了这么多年性能优化,十有八九的瓶颈都卡在循环上。今天咱们就把循环优化的几个核心手段掰开揉碎讲清楚。
我个人习惯把循环优化分成两类:一类是改变循环的结构,另一类是让硬件跑得更快。前者靠脑子,后者靠编译器。但不管哪类,你得先理解循环到底慢在哪。
循环展开:用空间换时间
循环展开是最直观的优化手段。你想想看,每次循环都要做一次条件判断、一次跳转,这些开销虽然小,但架不住循环次数多啊。
// 原始循环
for (int i = 0; i < 1000; ++i) {
a[i] = b[i] + c[i];
}
// 展开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];
}
我在项目中遇到过一个问题:循环展开后反而变慢了。为什么?因为指令缓存被撑爆了。展开因子不是越大越好,一般4到8次就够了。嗯,这里要注意,展开后还要处理剩余元素,别漏了。
循环合并:减少缓存缺失
循环合并就是把两个遍历同一数据的循环合并成一个。这样做的好处是数据局部性更好,缓存命中率更高。
// 分开写,数据要过两遍缓存
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];
// 合并后,a[i] 刚算完就用上了
for (int i = 0; i < N; ++i) {
a[i] = b[i] * 2;
c[i] = a[i] + d[i];
}
我曾经犯过一个错:把两个毫无关联的循环强行合并,结果代码可读性变差,性能也没提升。合并的前提是数据有依赖关系,或者至少访问的是同一块内存区域。
循环交换:调整访问顺序
循环交换主要针对多维数组。C++里数组是按行存储的,所以按行访问比按列访问快得多。这个道理大家都懂,但写代码时经常忘。
// 按列访问,缓存不友好
for (int j = 0; j < N; ++j)
for (int i = 0; i < M; ++i)
a[i][j] = 0;
// 交换后,按行访问,缓存友好
for (int i = 0; i < M; ++i)
for (int j = 0; j < N; ++j)
a[i][j] = 0;
我记得有一次调一个矩阵乘法,性能死活上不去。后来发现是内层循环在按列遍历,交换后性能直接翻倍。你想想看,缓存行一般64字节,按行访问一次能拿16个int,按列访问一次只拿1个,差距能不大吗?
循环剥离:处理边界情况
循环剥离,也叫循环分段。它的核心思想是:把循环的前几个或后几个元素单独处理,让主循环体变得规整,方便编译器做向量化。
// 原始循环,长度可能不是4的倍数
for (int i = 0; i < N; ++i) {
a[i] = b[i] + c[i];
}
// 剥离后,主循环对齐到4的倍数
int i = 0;
// 处理前几个元素,让主循环从对齐地址开始
while (i < N && (reinterpret_cast<uintptr_t>(&a[i]) & 15)) {
a[i] = b[i] + c[i];
++i;
}
// 主循环,可以安全地向量化
for (; i + 3 < N; 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];
}
// 处理剩余元素
for (; i < N; ++i) {
a[i] = b[i] + c[i];
}
说白了,循环剥离就是「把脏活累活留给前后,中间干干净净」。编译器看到规整的循环,向量化起来特别顺手。
循环向量化:让硬件并行
向量化是编译器帮你做的,但前提是你的代码得「可向量化」。说白了就是:循环体内不能有数据依赖,不能有函数调用,不能有分支。
// 可向量化:每个元素独立计算
for (int i = 0; i < N; ++i) {
a[i] = b[i] * c[i] + d[i];
}
// 不可向量化:有数据依赖
for (int i = 1; i < N; ++i) {
a[i] = a[i-1] + b[i];
}
我建议你养成一个习惯:写循环时先想想能不能向量化。如果不行,看看能不能改写。比如上面的依赖循环,有时候可以用前缀和算法改成可向量化的形式。
#pragma GCC ivdep 告诉编译器「相信我,这里没有依赖」,但前提是你真的确定没有。我曾经滥用这个,结果出了诡异的bug,排查了两天。
知识体系总览
下面这张图把五种循环优化手段的关系梳理清楚了。我画的时候特意把「硬件特性」和「代码结构」分开,因为这两类优化的思考方式完全不同。
如何选择优化手段
说实话,没有银弹。我一般按这个顺序来思考:
- 先看数据访问模式——如果是多维数组,优先考虑循环交换
- 再看循环体大小——循环体小,优先展开;循环体大,考虑剥离
- 然后看数据依赖——没有依赖,编译器自动向量化;有依赖,看看能不能改写
- 最后看多个循环的关系——访问同一块数据?合并试试
我曾经在一个图像处理项目里,把上面五种手段全用上了。先交换了行列访问顺序,然后剥离了边界像素,主循环展开4次,最后编译器自动向量化。性能提升了大概5倍。嗯,那种感觉确实很爽。
循环优化说到底就是两件事:减少开销,提高并行度。减少开销靠展开和剥离,提高并行度靠向量化和缓存友好。循环合并和交换则是调整数据流,让前两个目标更容易达成。
好了,这几种手段你可以在自己的项目里试试。先从最简单的循环展开开始,看看效果。别贪多,一次只改一个地方,对比性能变化。慢慢你就会找到感觉了。