编译器中的优化
检查编译器做了什么
研究编译器产生的代码,看它优化代码的程度,是非常有用的。有时,编译器会相当奇妙地使代码更高效,有时它蠢得难以置信。看编译器输出通常可以透露出什么可以通过修改源代码来改进,如下面例子所示。
检查编译器产生代码最好的方式是使用汇编语言输出的编译器选项。在大多数编译器上,你可以通过从命令行调用编译器来完成并使用相关优化选项,以及汇编输出的选项-S
或/Fa
。如果编译器没有汇编输出选项,使用目标文件反汇编器。
注意,Intel编译器有在汇编输出中注释源代码的选项(/Fas
或-fsource-asm
)。这个选项使汇编输出可读性更好,但不幸的是,它阻止了某些优化。如果你希望看到完全优化的结果,不要使用源代码注释选项。
在调试器的反汇编窗口看编译器产生代码也是可能的。不过,你在调试器中看到的代码不是优化的,因为调试选项阻止了优化。调试器不能在完全优化代码中设置断点,因为它没有行号信息。使用中断3的内联汇编指令,在代码中插入一个固定的断点,通常是可能的。代码是__asm int 3;
或者__asm(“int 3”);
或者__debugbreak();
。如果你在调试器中运行优化代码(发布版本),它将在中断3断点处暂停,并显示汇编,可能没有函数名及变量名的信息。记住删除中断3断点。
下面的例子展示了编译器汇编输出看起来像什么,及你如何可以使用它来改进代码。
// Example 8.26a
void Func(int a[], int & r) {
int i;
for (i = 0; i < 100; i++) {
a[i] = r + i/2;
}
}
对例子8.26a,Intel编译器产生下面的汇编代码(32位模式):
; Example 8.26a compiled to assembly:
ALIGN 4 ; align by 4
PUBLIC ?Func@@YAXQAHAAH@Z ; mangled function name
?Func@@YAXQAHAAH@Z PROC NEAR ; start of Func
; parameter 1: 8 + esp ; a
; parameter 2: 12 + esp ; r
$B1$1: ; unused label
push ebx ; save ebx on stack
mov ecx, DWORD PTR [esp+8] ; ecx = a
xor eax, eax ; eax = i = 0
mov edx, DWORD PTR [esp+12] ; edx = r
$B1$2: ; top of loop
mov ebx, eax ; compute i/2 in ebx
shr ebx, 31 ; shift down sign bit of i
add ebx, eax ; i + sign(i)
sar ebx, 1 ; shift right = divide by 2
add ebx, DWORD PTR [edx] ; add what r points to
mov DWORD PTR[ecx+eax*4], ebx ; store result in array
add eax, 1 ; i++
cmp eax, 100 ; check if i < 100
jl $B1$2 ; repeat loop if true
$B1$3: ; unused label
pop ebx ; restore ebx from stack
ret ; return
ALIGN 4 ; align
?Func@@YAXQAHAAH@Z ENDP ; mark end of procedure
编译器产生大多数注释被替换了。名字?Func@@YAXQAHAAH@Z
是Func
的名字,带有关于函数类型与其参数的信息。这称为名字重整。名字重整的细节在手册5《不同C++编译器他操作系统的调用惯例》中解释。参数a
与r
分别在栈上地址esp+8
与esp+12
处传递,载入ecx
与edx
。(在64位模式中,参数在寄存器中传递,而不是在栈上)。现在ecx
包含数组a
第一个元素的地址,edx
包含r
指向变量的地址。在汇编代码中,引用与指针相同。寄存器ebx
在使用前被压入栈,在函数返回前从栈弹出。这是因为寄存器使用惯例,函数不允许改变ebx
的值。仅寄存器eax
,ecx
与edx
可以自由改变。循环计数器i
作为寄存器变量保存在eax
中。循环初始化i=0;
被翻译为指令xor eax, eax
。这是寄存器置零的一个常用方式,比mov eax, 0
更高效。循环主体在标签$B1S2:
处开始。这只是编译器为该标签选择的一个随意名字。它把ebx
用作计算i/2+r
的临时寄存器。指令mov ebx, eax
/ shr ebx, 31
将i
的符号位拷贝到ebx
的最低有效位。下两条指令add ebx, eax
/ sar ebx, 1
把ebx
加i
,并右移一位把i
除2
。指令add ebx, DWORD PTR [edx]
向ebx
加上地址在edx
的变量,而不是edx
。中括号表示把edx
中的值用作内存指针。这是r
指向的变量。现在,ebx
包含i/2+r
。下一条指令mov DWORD PRT [ecx+eax*4], ebx
将这个结果保存在a[i]
中。注意数组地址的计算是如何做到高效。ecx
包含该数组的起始地址。eax
保存索引i
。这个索引必须乘上每个数组元素的大小(字节),计算元素i
的地址。int
的大小是4
。因此数组元素a[i]
的地址是ecx+eax*4
。然后,结果ebx
保存在地址[ecx+eax*4]
处。这都在一条指令里完成。CPU支持这种指令,用于快速访问数组元素。指令add eax, 1
是循环递增i++
。cmp eax, 100
/ jl $B1$2
是循环条件i < 100
。它将eax
与100
比较,如果i < 100
,跳回标签$B1$2
。pop ebx
恢复一开始保存的ebx
值。ret
从函数返回。
汇编列表揭示了可以进一步优化的3件事情。我们注意到的第一件是,为了i/2
,对i的符号位做了一些有趣的处理。编译器没有注意到i
不会是负数。把i
声明为unsigned int
,或者在除2
之前类型转换到unsigned int
,可以通知编译器。
我们注意到的第二件事情是,r
指向的值从内存重新载入了一百次。这是因为我们忘记告诉编译器假设没有指针别名。添加编译器选项“假设没有指针别名”(如果有效),可能改进代码。
可以改进的第三件事情是,可以通过归纳变量计算r+i/2
,因为它是循环索引的一个阶梯函数。整数除法阻止了编译器使用归纳变量,除非以步进2进行循环展开。
结论是,我们可以通过展开循环,并显示使用一个归纳变量,帮助编译器优化例子8.26b。
欢迎交流