循环优化是一个能显著提升程序性能的领域,理解它不仅能让你写出对编译器更友好的代码,也能在手动优化时有的放矢。
1. 为什么要有循环优化?解决了什么问题?
循环是程序中计算最密集的部分,通常被称为程序的“热点”。据统计,90%的执行时间往往花在不到10%的循环代码上。因此,对循环进行优化,收益是最大的。
循环优化主要解决了以下核心问题:
- 减少指令总数:消除循环中重复、冗余的计算,用更高效的指令替换低效的指令。
- 改善局部性:优化对内存的访问模式,使得CPU缓存能被更有效地利用,减少昂贵的内存访问延迟。
- 提高指令级并行度:为处理器的流水线、超标量、向量化等功能创造更好的条件,让CPU能同时执行更多指令。
- 减少控制开销:降低循环本身(如循环条件判断、分支预测失败)带来的开销。
2. 主要的循环优化技术及其实现机制
下面我们来详细探讨几种核心的循环优化技术。
2.1 循环无关代码外提
- 解决的问题: 避免在循环体内重复计算值不变(Loop-Invariant)的表达式。
- 实现机制:
- 分析:编译器通过数据流分析,识别出循环体内的哪些表达式其操作数在循环迭代过程中保持不变。这些操作数可能来自循环外的常量、变量,或者在循环前就被计算好的值。
- 变换:编译器将这些计算语句从循环体内“提升”到循环的前置块中,这样它们只被执行一次,而不是每次迭代都执行。
- 示例:
// 优化前 for (int i = 0; i < n; ++i) { arr[i] = x * y + i; // 假设 x 和 y 在循环内不变 } // 优化后(编译器自动生成的逻辑) int temp = x * y; // 循环无关计算被外提 for (int i = 0; i < n; ++i) { arr[i] = temp + i; }
2.2 归纳变量优化与强度削弱
- 解决的问题:
- 归纳变量:在循环中其值随迭代规律性变化的变量(通常是线性变化,如
i++)。 - 强度削弱:用代价更低的操作替换代价高的操作(如用加法替代乘法)。
- 归纳变量:在循环中其值随迭代规律性变化的变量(通常是线性变化,如
- 实现机制:
- 编译器识别出基于基本归纳变量(如
i)推导出的其他归纳变量(如j = 4 * i + 2)。 - 将推导公式中的乘法运算,转换为基于前一个值的加法运算。
- 编译器识别出基于基本归纳变量(如
- 示例:
// 优化前 for (int i = 0; i < n; ++i) { int j = 4 * i + 2; // 每次迭代都要做乘法和加法 arr[j] = ...; } // 优化后 int j = 2; // 初始化j的初始值 for (int i = 0; i < n; ++i) { arr[j] = ...; j += 4; // 强度削弱:用加法 j = j + 4 取代了 4*i+2 }
2.3 循环展开
- 解决的问题:
- 减少分支开销:减少循环条件判断和分支跳转的次数,降低分支预测失败的概率。
- 提高指令级并行:为编译器调度指令创造更大空间,可以填充处理器的流水线,更好地利用多个功能单元。
- 实现机制:
- 编译器选择一个展开因子。
- 将原始循环的多个迭代体复制到新的循环体中。
- 调整循环的终止条件,并处理可能剩余的迭代次数(如果不能被展开因子整除)。
- 示例(展开因子为2):
注意:过度展开会导致指令缓存不命中,需要权衡。// 优化前 for (int i = 0; i < n; ++i) { sum += arr[i]; } // 优化后 int i = 0; for (; i < n - 1; i += 2) { // 每次处理两个元素 sum += arr[i]; sum += arr[i+1]; } for (; i < n; ++i) { // 处理剩余的迭代(如果n是奇数) sum += arr[i]; }
2.4 循环融合
- 解决的问题: 将多个遍历相同数据集的循环合并成一个,极大化地改善时间局部性和空间局部性。
- 实现机制:
- 检查相邻的多个循环,确认它们的迭代空间相同且之间没有数据依赖。
- 将它们的循环体合并到同一个循环中。
- 示例:
// 优化前:遍历了数组三次,缓存不友好 for (int i = 0; i < n; ++i) { a[i] = i; } for (int i = 0; i < n; ++i) { b[i] = 2 * i; } for (int i = 0; i < n; ++i) { c[i] = a[i] + b[i]; } // 优化后:只遍历一次,数据都在缓存中,效率高 for (int i = 0; i < n; ++i) { a[i] = i; b[i] = 2 * i; c[i] = a[i] + b[i]; }
2.5 循环 fission/distribution(循环分布)
- 解决的问题: 与融合相反。当一个循环体过大、包含太多不相关的操作时,可能会挤占指令缓存,或者阻碍其他优化(如向量化)。将其拆分成多个小循环可能更优。
- 实现机制: 将一个大循环体按照功能或数据依赖关系拆分成多个独立的循环。
2.6 循环分块
- 解决的问题: 优化对大型数组的访问,特别是多维数组,以改善缓存利用率。
- 实现机制:
- 将大的迭代空间分割成更小的“块”。
- 确保对一块数据的操作完全在高速缓存中进行,然后再处理下一块,从而避免缓存抖动。
- 示例(矩阵乘法优化):
这样,// 朴素实现,缓存不友好 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]; // 分块后(假设块大小为B) for (int ii = 0; ii < N; ii += B) for (int jj = 0; jj < N; jj += B) for (int kk = 0; kk < N; kk += B) for (int i = ii; i < ii+B; ++i) // 处理一个小块 for (int j = jj; j < jj+B; ++j) for (int k = kk; k < kk+B; ++k) C[i][j] += A[i][k] * B[k][j];A和B的小块可以被加载到缓存中并充分复用。
2.7 自动向量化
- 解决的问题: 利用现代CPU的SIMD指令,用一条指令同时处理多个数据,是性能提升的“大杀器”。
- 实现机制:
- 编译器分析循环中的数据访问是否连续、对齐,操作是否独立无依赖。
- 如果条件满足,编译器会生成使用SSE、AVX等SIMD指令的代码,一次加载、计算、存储多个数据元素。
- 示例:
编写对编译器友好的代码(如使用简单循环、连续内存访问、避免复杂控制流)可以极大地帮助自动向量化。// 优化前 for (int i = 0; i < n; ++i) { c[i] = a[i] + b[i]; } // 向量化后(概念性代码,使用128位SSE寄存器,可处理4个float) for (int i = 0; i < n; i += 4) { __m128 vecA = _mm_load_ps(&a[i]); // 一次加载4个float __m128 vecB = _mm_load_ps(&b[i]); __m128 vecC = _mm_add_ps(vecA, vecB); // 一次相加4个float _mm_store_ps(&c[i], vecC); // 一次存储4个float }
3. 编译器实现这些优化的通用流程
- 中间表示: 编译器首先将源代码转换为一种称为IR的中间表示形式(如LLVM IR)。IR更简单、更规范,便于进行分析和变换。
- 分析阶段:
- 控制流分析: 构建控制流图,理解循环的嵌套结构。
- 数据流分析: 这是优化的核心。通过到达定值、活跃变量分析等技术,确定变量的定义和使用关系,判断哪些代码是无关的,哪些变量是归纳变量等。
- 依赖分析: 尤其对于循环,判断迭代之间是否存在数据依赖(如写后读、写后写),这是很多激进优化(如向量化、并行化)的前提。
- 变换阶段: 根据分析结果,在IR上应用上述的各种优化变换pass。这些pass通常有严格的顺序,因为一个优化可能会为另一个优化创造机会。
- 代码生成: 将优化后的IR转换回目标机器的汇编代码。
总结与建议
| 优化技术 | 核心目标 | 对程序员编码的启示 |
|---|---|---|
| 循环无关代码外提 | 消除冗余计算 | 主动将不变的计算提到循环外,帮助编译器确认其不变性。 |
| 强度削弱 | 用加法代替乘法 | 无特殊要求,编译器通常做得很好。 |
| 循环展开 | 减少分支,提高并行 | 可使用 #pragma unroll 提示编译器,但不要过度手动展开。 |
| 循环融合 | 改善缓存局部性 | 尽量将遍历相同数据集的连续循环合并。 |
| 循环分块 | 改善缓存利用率 | 处理大型矩阵时,考虑手动分块,但编译器有时也能自动完成。 |
| 自动向量化 | 利用SIMD指令 | 使用简单循环、连续内存访问、避免函数调用和数据依赖。 |
作为开发者,我们的目标是编写清晰、正确的代码,同时了解这些优化机制,以便:
- 写出对编译器友好的代码,让编译器更容易实施优化。
- 在性能最关键的部分,当编译器优化不足时,有能力进行手动优化。
- 使用编译器报告(如GCC的
-fopt-info,ICC/ICX的-qopt-report)来查看编译器是否成功进行了向量化、循环展开等关键优化,并据此调整代码。
最终,信任但不完全依赖编译器。理解其工作原理,与编译器协同工作,是写出高性能C++代码的关键。
C++底层机制推荐阅读
【C++基础知识】深入剖析C和C++在内存分配上的区别
【底层机制】【C++】vector 为什么等到满了才扩容而不是提前扩容?
【底层机制】malloc 在实现时为什么要对大小内存采取不同策略?
【底层机制】剖析 brk 和 sbrk的底层原理
【底层机制】为什么栈的内存分配比堆快?
【底层机制】右值引用是什么?为什么要引入右值引用?
【底层机制】auto 关键字的底层实现机制
【底层机制】std::unordered_map 扩容机制
【底层机制】稀疏文件--是什么、为什么、好在哪、实现机制
【底层机制】【编译器优化】RVO--返回值优化
【基础知识】仿函数与匿名函数对比
【底层机制】【C++】std::move 为什么引入?是什么?怎么实现的?怎么正确用?
【底层机制】emplace_back 为什么引入?是什么?怎么实现的?怎么正确用?
关注公众号,获取更多底层机制/ 算法通俗讲解干货!