编译器优化
编译器优化
- 编译器优化的目标和原则
- 优化目标:
- 优化原则:
- 不同的角度看待编译器优化
- 从编译器阶段角度
- GCC/LLVM编译器架构划分
- 前端优化(语法层优化):
- 中端(IR优化)
- 后端(目标架构相关优化)
- 同时结合中端和后端
- 从其他角度看待编译器优化
- 静态优化 vs 动态优化
- 局部优化 vs 全局优化
- 语言无关 vs 有关
- 基于假设优化
- 编译器的分析支持手段
- 具体C/C++优化技术介绍
本文是一篇编译器优化的总览,不会给太多细节,细节会在之后的文章中补全。
编译器优化的目标和原则
优化目标:
- 性能优化(最常见):提升执行速度。
- 体积优化:减少可执行文件大小。
- 功耗优化:尤其在嵌入式系统或移动设备中。
可以简单的认为少做事就是减少功耗。
- 编译时间优化:提升开发效率。
- 调试信息保留:某些优化要避免影响可调试性。
优化原则:
-
保持语义等价性:不能因为优化改变了代码本身的逻辑
-
权衡收益与代价:
优化前评估优化可能导致的增加编译时间、代码变大、调试困难等代价,与明显提升运行效率等收益,两者对比之后是否值得做这项优化。
-
面向热路径优化(Profile-guided Optimization, PGO):
80/20原则,程序 80% 的运行时间都花在 20% 的代码上。因此编译器会用各种方法是不是热路径,从而进行精确优化。
不同的角度看待编译器优化
从编译器阶段角度
GCC/LLVM编译器架构划分
典型 C/C++ 编译器(如 GCC、Clang/LLVM)的结构分为:
- 前端(Frontend):词法分析、语法分析,生成 AST。
- 中端(Middle-end):语义分析、生成中间表示(IR)。
- 后端(Backend):将 IR 转换为目标机器码。
前端优化(语法层优化):
- 常量折叠
- 死代码删除
- 内联展开
- 返回值优化
中端(IR优化)
IR是一种语言无关的中间表示,在该阶段已经去除了不同高级语言之间的差异,IR优化对于不同高级语言而言做法都是同样的。
数据流分析支持的优化
- 公共子表达式消除(CSE)
- 复制传播(Copy Propagation)
- 死代码消除(DCE)
- 循环不变代码外提(LICM)
- 强度削弱(Strength Reduction):如将 i*8 转为 i<<3。
控制流优化 - 基本块合并、重排(Block Merging / Reordering)
- 空转移删除(Trivial Jump Removal)
- 条件传播与跳转优化
SSA-based 优化 - 引入 Static Single Assignment form 之后:
- 高效的数据依赖分析
- 实现 GVN(全局数值冗余消除)
- 精准的范围分析和别名分析
后端(目标架构相关优化)
后端是将IR中间表示转化为机器代码,因此在这部分做的优化是跟目标机器相关的优化。
- 寄存器分配(Register Allocation)
- 指令调度(Instruction Scheduling)
- 指令选择优化(Instruction Selection)
- 延迟槽填充(针对 RISC 架构)
- 函数和数据布局优化(Function Reordering)
同时结合中端和后端
循环优化
- 循环展开(Unrolling)
- 循环合并 / 分裂
- 循环交换(Loop Interchange)
- 循环向量化(Loop Vectorization):自动使用 SIMD 指令。
- 预取指令插入(Software Prefetch)
从其他角度看待编译器优化
静态优化 vs 动态优化
- 静态优化:编译时完成,不依赖运行信息。
- 动态优化:运行时(JIT)完成,如 Java HotSpot、LLVM ORC JIT。
目前C/C++基本都是静态优化,因为C/C++的代码是直接编译成机器码进行执行的,编译完成时候执行流程已经确定了,无法在运行时根据执行情况动态调整机器码。
说是基本是因为目前有伪动态优化技术,可以通过 PGO(Profile-Guided Optimization)来模拟一部分动态信息驱动的静态优化,说白了就是编译成机器码后运行一段时间收集运行信息,根据运行信息再次进行编译(实际上还是静态优化)。
局部优化 vs 全局优化
- 局部优化:局限于单个基本块。?
- 全局优化:跨基本块、跨函数甚至模块。?
语言无关 vs 有关
- 大部分 IR 层优化是语言无关的,如 LLVM 的 mem2reg。
- 但前端可以做语言相关优化,如 C++ 的内联构造函数消除(RVO)。
基于假设优化
基于“程序不会这么干”的语义假设来允许更激进的优化。
这种优化思想的本质是:
如果我们相信程序员遵守某些规范,那我们就可以做得更大胆。
这些优化往往在编译器中带有“不确定性”和“大胆性”:
- 如果假设成立 → 提升性能
- 如果假设被程序员违反 → 未定义行为 or fallback(deoptimization)
优化名 | 假设 | 效果 |
---|---|---|
严格别名优化(Strict Aliasing) | 不同类型指针不指向同一内存 | 缓存变量值、不重复读取内存 |
函数内联后去虚(Devirtualization) | 虚函数的实际类型唯一 | 可内联虚函数调用 |
条件分支预测优化 | 条件总是偏向某一方向(来自 PGO 或启发) | 指令重排、填延迟槽 |
const 推导(Const Propagation) | 常量不在运行时改变 | 变量替换为立即数,消除运算 |
内联展开 + 类型推导 | 模板调用总是特定类型 | 对模板函数展开、特化 |
编译器的分析支持手段
决定是否优化的关键依据
-
数据流分析(Data Flow Analysis)
确定变量的定义、使用范围、常量性等
决定如:死代码删除、常量传播、CSE 是否可行
-
控制流图(CFG)分析
判断哪些分支是冗余的
控制流重排、条件传播优化都依赖 CFG
-
静态单赋值形式(SSA)
所有变量只赋值一次,方便追踪数据来源
有助于做 alias analysis、GVN、Loop Optimization
-
别名分析(Alias Analysis)
判断两个指针是否可能指向相同内存
如果不别名 → 可做更 aggressive 的内联、预取、载入消除
-
循环分析(Loop Analysis)
判断循环是否可展开、可向量化(SIMD)
如 Loop Unrolling、Loop Fusion、LICM 都依赖此分析
具体C/C++优化技术介绍
挑选几个比较有代表性的
- 常量折叠
-
原理
如果表达式在编译期就可以知道值是多少,就直接在编译期计算表达式的值。
-
示例:
const int a = 3 * 4 + 5; // 3 * 4 + 5会折叠为 17
constexpr int b = func(2); // 如果 func 是 constexpr 函数
- 作用:
编译期直接计算表达式值,减少运行期计算开销(因为在编译期已经计算了)。
-
建议:
- 多使用 constexpr、const 和 consteval(C++20)
- 避免在常量表达式中调用运行时函数
- 避免写太复杂、编译器无法推导的表达式
- 常量传播
-
原理
如果能够确定某个变量是常量,就会用它的值替换掉变量本身,从而简化表达式、生成更高效的代码。常量折叠和常量传播不太一样,两者都是编译期确定值
- 常量折叠是针对表达式的优化技术,在编译期直接计算出表达式的值,计算值替换表达式,避免在编译期进行计算。
- 常量传播时针对常量的优化技术,发现常量的值在编译期时可知的,那么就把这个值赋给使用这个常量的其他变量/常量,也就是把值传播出去。
-
示例:
const int x = 5; int y = x + 3; // 编译器会直接把这行代码优化成 int y = 8;因为x是const变量,且值是编译器可知的
-
作用:
作用 描述 消除冗余计算 把计算提前执行(在编译期),减少运行时负担 配合死代码删除(DCE) 传播后分支/循环可静态确定,从而整段代码被删除 触发更多高级优化 传播后变量确定 → 可进行循环展开、指令合并等 提高代码可预测性和局部性 消除不必要的变量间接访问,有利于 CPU 优化 -
建议:
不要尝试修改一个const变量的值
const int x = 10;
int* ptr = (int*)&x;
*ptr = 20;
printf("x = %d\n", x); // 输出结果可能是10,也可是20,取决于译器的优化策略,如果常量传播,则x的值会编译期直接替换成10。
printf("*ptr = %d\n", *ptr); // 输出结果为20
- 死代码删除
-
原理
识别并移除永远不会影响程序结果的代码,比如不可能执行的分支、不会被使用的变量赋值等。也就是移除无效代码段。
通常依赖于:
- 控制流分析(Control Flow Analysis)
- 数据流分析(Data Flow Analysis)
来判断哪些语句是“无效的”。
-
示例:
int main() {int x = 42; // 死代码:x 没被使用int y = 0;if (false) {y = 5; // 死代码:永远不会执行}return 0;
}
- 作用:
作用 | 描述 |
---|---|
减小代码体积 | 去除无用指令,减少目标代码大小 |
提升运行效率 | 减少不必要的变量、指令、内存访问 |
为后续优化铺路 | 删除死变量后,可进一步触发其他优化(如寄存器分配更好) |
清理“调试遗留代码” | 编译器可以自动消除开发时临时遗留逻辑 |
- 建议:
使用 const, constexpr 明确表达不变,编译器更容易判断代码是否无效。
- 内联展开
- 原理
编译器将函数调用的地方,直接展开为函数体代码,避免函数调用的开销(如栈帧建立、跳转等)。
- 示例:
inline int add(int a, int b) {return a + b;
}int main() {int x = add(1, 2); // 会被替换为 int x = 1 + 2;
}
-
作用:
- 减少函数调用开销(如栈帧建立、函数跳转)
- 使调用点附近的优化成为可能(如常量折叠、死代码删除)。
例如上述例子
add(1, 2)
可能会被直接替换为1 + 2
,进而可以进行常量折叠在编译期直接计算出3。 - 增强编译器对整体逻辑的掌握,利于进一步优化。
如果只是函数调用,编译器只看到了需要调用某一个函数,但是函数内部的信息是不明确的额,而内联则是将函数内部处理逻辑直接在调用点处展示出来,对编译器来说自然是知道的信息越多约能进行更细致的优化。
-
建议:
- 返回值优化
-
原理
Return Value Optimization(RVO).
Name Return Value Optimization(NRVO).
对于返回值的具名和不具名进行区分,则有RVO和NRVO这两种区别。编译器在函数返回局部对象时,避免构造临时对象和拷贝/移动操作,直接在调用方分配的内存中构造对象
-
示例:
struct Big {Big() { std::cout << "ctor\n"; }Big(const Big&) { std::cout << "copy\n"; }
};Big make() {Big b;return b; // 若触发 NRVO,这里不会打印 copy
}
-
作用:
- 避免不必要的拷贝或移动构造,提升性能。
打一个不是很准确的比分,RVO就像是下边这样
int add(int a, int b){int res = a + b; return res;}int main() {int res = add( 1 + 2); return 0;}// 做RVO等同于把res传到add中void add(int& res, int a, int b) { res = a + b; }int main(){ int res = 0; add(res, 1, 2); return 0;}
-
建议:
- 优先返回未命名临时对象:return Obj();(RVO)
因为未命名的临时对象,几乎一定会出发RVO
- 返回命名对象时,结构尽量简单、单一返回点(NRVO)
- 避免在 return 中涉及多个对象选择、复杂分支(会阻止 NRVO)。因为这样编译器会不知道要选择哪一个对象进行NRVO。
从编译器的角度,要实现NRVO必须在编译期间就知道要返回的是哪一个对象,这样对象所在的内存才能确定,自然是越简单越好。
- 优先返回未命名临时对象:return Obj();(RVO)
- 尾调用优化
-
原理
当一个函数在 返回之前最后一步就是调用另一个函数,编译器可以将当前函数的栈帧替换为被调用函数的栈帧,避免栈增长,提升性能并防止栈溢出。
尾递归就是尾调用中最后一个函数是调用自己,形成递归。
尾递归优化,编译器实际上可能把递归函数转换为循环实现。
-
示例:
int factorial(int n, int acc = 1) {if (n == 0) return acc;return factorial(n - 1, acc * n); // 尾递归
}
-
作用:
- 消除递归带来的栈空间开销
- 支持“无限”递归深度,适用于数学函数、状态机等
-
建议:
- 递归函数写成尾递归形式(返回时直接 return 调用,不做额外计算)
-
详细内容
C++ 中的尾调用优化TCO:原理、实战与汇编分析
- 公共子表达式消除(CSE, Common Subexpression Elimination)
-
原理
将多次出现的等价表达式只计算一次,存为临时变量,避免重复计算。
-
示例:
int a = x * y + 5; int b = x * y + 10; CSE int t = x * y; int a = t + 5; int b = t + 10;
-
作用:
- 减少指令次数,降低运算资源消耗
- 提升浮点运算、图形处理等计算密集型场景的效率
-
建议:
- 编写表达式时避免无意义的重复计算
- 避免副作用表达式(如带状态函数)以便编译器识别为纯表达式
- 注意浮点不精确场景下的不可合并
因为浮点计算不是严格的“数学等价”,会有一定的误差,因此编译器可能不会合并,除非加上一些编译参数,例如
-ffast-math
- 严格别名
-
原理
编译器假设不同类型的指针不会指向同一内存地址,从而允许更激进的优化(如寄存器缓存、不刷新内存等)。 -
示例:
float f = 1.0f; int* p = (int*)&f; // 违反 strict aliasing
-
作用:
- 提高优化空间:可认为 floa t* 与 int* 指向不同数据
- 使得缓存、重排序、寄存器保留更安全
-
建议:
- 避免使用 reinterpret_cast 或 (int)&x 等违反别名规则的写法
- 使用 memcpy 或标准库安全类型转换
- 若必须使用 alias,使用 attribute((may_alias)) 或 -fno-strict-aliasing 关闭该优化(降低优化强度)
- 自动 SIMD(Auto-Vectorization / 自动向量化)
- 原理
编译器自动将数据并行循环转换为SIMD 指令(如 SSE/AVX),一次处理多个数据,提高性能。
就是编译器发现在循环中第i个数据的值跟第j个的值无关,,就自动改成并行处理。
-
示例:
for (int i = 0; i < n; ++i)a[i] = b[i] + c[i]; // → 编译器自动转成 SIMD 加法
-
作用:
- 显著提升数值密集任务性能(图像处理、物理模拟等)
- 减少循环次数、加速运算
-
建议:
- 编写简单、无依赖的循环结构
- 避免循环中有条件分支或跨依赖
- 使用 -O2 -ftree-vectorize 或 -march=native 使编译器自动尝试 SIMD
- 可用 #pragma GCC ivdep / #pragma omp simd 强制提示并行性
- 数据预取(Prefetching)
- 原理
通过编译器/CPU 确定未来要用的数据地址,提前加载到 cache,减少因 cache miss 导致的等待。
-
示例:
for (int i = 0; i < N; ++i) {__builtin_prefetch(&a[i + 16]); // 手动预取未来访问的数据,提示编译器把a[i + 16]加到缓存中。a[i] = a[i] + 1; }
__builtin_prefetch 是GCC 和 Clang 提供的内建函数(builtin function),用于手动插入“预取指令”(prefetch instruction)来优化缓存访问。
-
作用:
- 减少内存访问延迟
- 提高缓存命中率
-
建议:
- 大批量线性访问时可受益
- 编译器通常能自动判断并插入预取指令
- 手动预取适合访问跨度大或复杂数据结构