循环优化:循环展开、循环合并、循环交换、循环剥离、循环向量化

循环,说白了就是程序里最耗时的「热区」。我做了这么多年性能优化,十有八九的瓶颈都卡在循环上。今天咱们就把循环优化的几个核心手段掰开揉碎讲清楚。

我个人习惯把循环优化分成两类:一类是改变循环的结构,另一类是让硬件跑得更快。前者靠脑子,后者靠编译器。但不管哪类,你得先理解循环到底慢在哪。

循环展开:用空间换时间

循环展开是最直观的优化手段。你想想看,每次循环都要做一次条件判断、一次跳转,这些开销虽然小,但架不住循环次数多啊。

// 原始循环
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次就够了。嗯,这里要注意,展开后还要处理剩余元素,别漏了。

我的经验:展开因子选多少,得看你的循环体有多大。循环体小(比如就一条加法),展开8次没问题。循环体大(比如几十条指令),展开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];

// 合并后,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,排查了两天。

知识体系总览

下面这张图把五种循环优化手段的关系梳理清楚了。我画的时候特意把「硬件特性」和「代码结构」分开,因为这两类优化的思考方式完全不同。

循环优化知识体系 循环优化 改变循环结构 利用硬件特性 循环展开 循环合并 循环交换 循环剥离 循环向量化 缓存友好访问 指令级并行 五种优化手段,两种思考维度

如何选择优化手段

说实话,没有银弹。我一般按这个顺序来思考:

  1. 先看数据访问模式——如果是多维数组,优先考虑循环交换
  2. 再看循环体大小——循环体小,优先展开;循环体大,考虑剥离
  3. 然后看数据依赖——没有依赖,编译器自动向量化;有依赖,看看能不能改写
  4. 最后看多个循环的关系——访问同一块数据?合并试试

我曾经在一个图像处理项目里,把上面五种手段全用上了。先交换了行列访问顺序,然后剥离了边界像素,主循环展开4次,最后编译器自动向量化。性能提升了大概5倍。嗯,那种感觉确实很爽。

记住:优化之前一定要先profiling。别凭感觉优化,你感觉的热点可能根本不是热点。我见过太多人花三天优化了一个只占1%时间的循环,结果主函数里一个malloc占了50%。

循环优化说到底就是两件事:减少开销,提高并行度。减少开销靠展开和剥离,提高并行度靠向量化和缓存友好。循环合并和交换则是调整数据流,让前两个目标更容易达成。

好了,这几种手段你可以在自己的项目里试试。先从最简单的循环展开开始,看看效果。别贪多,一次只改一个地方,对比性能变化。慢慢你就会找到感觉了。