一段代码想要最终被计算机执行,首先需要被翻译成机器可识别和执行的指令。在这个过程中,有两个重要的角色,编译器 和 指令集。
代码编译的过程往往包含几个步骤:
1 |
|
在这个过程中, (1)、(2)依赖于上层的编程语言设计 (3)会将分析结果编译成中间代码。在这个阶段,编译器会尝试对中间代码进行优化,通过减少无效或冗余的代码、计算强度优化等手段,以助于减少最终生成的指令数,或使用更高效的指令 (4)基于中间代码生成机器可执行的目标代码,这个过程和操作系统、指令集、内存等相关。其中,不同的指令集也会带来不同的效率。
编译器优化
以 gcc 为例,我们来简单了解一下编译器优化。
gcc 编译器会在编译速度、生成代码大小和生成代码执行速度三个方面尝试进行优化。在默认情况下,gcc 编译器不开启编译优化,因为编译器的目标是减少编译时间、保证编译结果能够按照期望进行测试。
1 |
|
编译优化
我们以一个简单的例子,来简单了解一下编译优化,看看编译器可能会做哪些事情。
1 |
|
这是一段 C 语言代码,代码做了变量声明和打印,先来看看不开启编译优化的情况下,编译出来的汇编语言结果:
1 |
|
通过 gcc -S -fverbose-asm,我们可以获得添加了变量备注的汇编结果。从中可以粗略看到各个变量的创建,通过 addl
进行了 index + 6
的操作,并最终通过 call
调用 printf
方法。
如果我们开启编译优化,则得到的结果会有很大不同:
1 |
|
最直观的感受是指令少了更多,更加精简了,细看之下,会发现有一些改变:
(1)消除了冗余的声明
里面一些声明了,但是没有使用到的变量,最终被移除了。
比如sum
、loop
、index
(2)编译期间能够进行的计算提前做
在代码中,index
变量是参与运算,但是在最终的指令中也被移除了,而这个过程被提前进行了计算,index = 6
,那么 index + 1
可以提前预知判断为7
,则在编译阶段直接进行了替换。在指令上,实际是将:
1 |
|
变为了:
1 |
|
精简了编译出的指令结果。
(3)指令优化
在编译优化前的指令里,函数执行的最后可以看到这样一段指令:
1 |
|
它的目的只将累加器归0,而在开启编译后话后,变成了这样:
1 |
|
为什么会有这样的变化呢?我们可以简单对比一下这两个命令。
我们使用GNU 汇编器as
,分别对这两段指令进行编译,并通过 objdump -D
来看看两个命令编译后的结果:
1 |
|
可以发现,mov
指令占用大小是 xor
的 2.5 倍,从大小上来看,xor
更胜一筹。可以看出,编译优化会对指令进行优化,选择成本代价更低的指令,生成代码的大小也是编译优化中考虑的一部分。
编译优化项
从上面的例子可以看到,编译器会从不同的角度进行指令优化,在 gcc 的编译优化选项中,我们选取其中的一部分来看看:
1 |
|
以-finline-functions
为例,该优化项会尝试将非inline
函数进行内联,对下面的代码进行优化:
1 |
|
代码读取main函数参数的两个参数,转化为整数猴进行想加,来看看它优化前的中间代码:
1 |
|
从main
函数中可以看到,先调用了call add
进行加法,再调用call printf
输出。
当我们开启编译优化,并开启函数内联优化,再来看看编译结果:
1 |
|
通过gcc -O -finline-functions
开启函数内联优化进行编译,结果发生了变化:
(1)add
函数的指令得到精简和优化,原因是参数-O
实际上是开启了-O1
级优化,该级别优化不包含-finline-functions
,所以手动开启了函数内联优化
(2)main
函数中已经没有了call add
,而是将add
函数进行内联处理,将leal
指令放在了main
函数中
编译优化等级
在上面的优化中,我们有说到一个概念,编译优化等级。gcc 将编译优化项进行了分类,划分了出多个优化等级用于不同的场景。
优化等级 | 说明 |
---|---|
-O0 | 默认优化等级,即不开启编译优化,只尝试减少编译时间 |
-O/-O1 | 尝试减少代码大小,缩短代码执行时间,不会执行需要消耗大量编译时间的优化。对于大函数的编译优化会占用更多的时间和内存。 |
-O2 | 与-O1类似,会提升编译时间和代码性能,几乎开启除了时空权衡优化外的所有优化项 |
-O3 | 在-O2级别的基础上,开启了更多的优化项,最高优化等级,以编译时间、代码大小、内存为代价获取更高的性能。在部分情况下可能会降低性能。 |
-Os | 优化代码大小,会开启-O2中不会增加代码大小的优化项 |
-Ofast | 开启-O3所有优化项,该优化等级不会严格遵循语言标准 |
对代码性能的影响
编译优化对最终的代码性能有什么影响呢,来看一个例子:
1 |
|
依旧是一个循环加的例子,并记录加法运算时间。在不开启编译优化的情况下,执行时间如下:
开启-O3
级优化后,执行时间如下:
优化后,执行时间只有原来的10%左右,效果明显。 (但是这个例子算是一个特殊的例子,计算过程可以用上向量化计算指令,实现并行计算,才能达到这么明显的效果,其他场景下不一定)
优化有风险
编译器优化这么厉害,当然也有“副作用”,来看看下面的例子:
1 |
|
函数中的for
里,依赖于i
来判断是否结束。正常判断下,i
循环加溢出后变成负数,就会终止循环,结果如下:
但是当我们开启-O2
优化后,会进入死循环:
我们来看看优化前后的汇编指令:
1 |
|
在编译优化后,变成了死循环。这是一个特殊的现象,在这个过程中,有符号证书越界(Signed Integer Overflow)在 C99 标准中是一个未定义行为,该行为对应的实现可能是隐式转换或者异常等。在 gcc 中,编译器会 overflow 不会发生,将这段代码编译为死循环。
(可能还会有类似的情况,具体可以参考CERT - Dangerous Optimizations and the Loss of Causality)
选择性编译优化
既然危险系数这么高,就放弃不用吗,当然不行,我们可以选择对部分代码进行编译优化:
1 |
|
该语句会对其之后的代码开启对应级别的编译变化,语句之前的代码不影响,举个栗子:
1 |
|
在gcc
编译时不开启编译优化,得到编译后的汇编指令:
1 |
|
此时在main
函数阶段是正常编译的,在#pragma GCC optimize
之后的f
函数将会开启编译优化,编译成死循环