编译器代码优化——阴暗面






4.92/5 (23投票s)
2003年6月26日
7分钟阅读

82111

634
编译器代码优化模型的工作原理以及如何混合它们以创建混合优化模型。
简要考虑
首先,我为我蹩脚的英语道歉,因为我的母语是葡萄牙语。
本文的源代码采用VC++6项目格式,但本文也适用于其他版本,例如.NET甚至VC++5。
我假设您对生成、使用和解释列表文件有先前的知识,以及一些汇编语言的基础知识。我在这里将避免解释这些细节,但是如果您不熟悉列表文件,您可以通过阅读这篇非常好的文章来获取这些信息。
引言
我们都知道许多编译器为我们提供了多种代码优化选项,包括Visual C++甚至Visual Basic编译器。我们也知道有两种更常用的优化方式
- 最小化大小
- 最大化速度
我们相对频繁地使用它们,但很少停下来思考它们是如何工作的,或者我们应该优先选择哪一个。首先,我们问:如果我们的源代码相同,编译器如何构建不同的代码对象?
为了回答这个问题,我们需要检查编译器实际上生成了什么。这可以通过列表文件完成。
内部优化
首先,让我们检查以下代码
// // Counting generated machine code size in a simple "Hello World" example // #include <stdio.h> char* hello = "Hello World!"; // our famous message :) int main(void) { long seip,eeip; // variables to store 'start' // and 'end' EIP addresses __asm { xor eax,eax // clear contents of eax call jump1 // store instruction pointer value on // the stack, then jump to label // (see below) jump1: mov eax,[esp] // #1 (3 bytes) mov seip,eax // #2 (3 bytes) } // Here, the code to be analyzed printf("%s\n",hello); __asm { xor eax,eax // #3 (2 bytes) call jump2 // #4 (5 bytes) // 'call' instruction is equivalent to: // push EIP // jmp jump2 jump2: xor eax,eax mov eax,[esp] sub eax,0Dh // we need subtract from the last // instruction address the size of all // performed instructions between call // instructions that are not part of // the code being analyzed. // (13 bytes: lines #1, #2, #3 and #4) mov eeip,eax } // print code size results and exit printf("Code size: %d bytes",eeip-seip); return 0; }
此代码的目的是简单:我们将通过我们代码前后EIP地址的差值,获取正在分析的源代码(在本例中,只是一个printf
)生成的机器代码的大小,以字节为单位。请注意,MASM,因此Visual C++中的内联汇编代码不允许直接访问EIP,因此此过程通过call
指令进行模拟(这等同于:push EIP + jmp LABEL
)。
使用大小优化编译代码(在示例项目中选择OptSize配置)并执行程序,我们将得到以下输出
$ Hello World!
$ Code size: 18 bytes
使用速度优化编译(在示例项目中选择OptSpeed配置)并执行程序,我们将得到以下输出
$ Hello World!
$ Code size: 19 bytes
现在,机器代码多了一个字节,而我们没有在源代码中添加任何内容!
让我们检查两种编译的列表文件,看看这种“魔术”是如何发生的。(使用选项“汇编、机器代码和源”——列表文件将具有.cod扩展名)
情况A:最小化大小
00012 ff 35 00 00 00
00 push DWORD PTR ?hello@@3PBDB
00018 68 00 00 00 00 push OFFSET FLAT:??_C@_03HHKO@?$CFs?6?$AA@
0001d e8 00 00 00 00 call _printf
00022 59 pop ecx
00023 59 pop ecx
情况B:最大化速度
0013 a1 00 00 00 00 mov eax, DWORD PTR ?hello@@3PBDB
00018 50 push eax
00019 68 00 00 00 00 push OFFSET FLAT:??_C@_03HHKO@?$CFs?6?$AA@
0001e e8 00 00 00 00 call printf
00023 83 c4 08 add esp, 8
在情况A中,我们的变量hello
通过其指针直接分配到堆栈中,消耗6字节的代码;而在情况B中,我们变量的地址首先复制到eax
中,然后通过push eax
分配,也消耗6字节,但有2条指令(mov + push
)。现在我们问:有什么区别?请看下表
情况A:最小化大小
指令 | 时钟* | 大小 |
push DWORD PTR ?hello@@3PBDB |
4 | 6 |
push OFFSET FLAT:??_C@_03HHKO@?$CFs?6?$AA@ |
1 | 5 |
call _printf |
3 | 5 |
pop ecx |
1 | 1 |
pop ecx |
1 | 1 |
总计 | 10 | 18 |
情况B:最大化速度
指令 | 时钟* | 大小 |
mov eax, DWORD PTR ?hello@@3PBDB |
1 | 5 |
push eax |
1 | 1 |
push OFFSET FLAT:??_C@_03HHKO@?$CFs?6?$AA@ |
1 | 5 |
call _printf |
3 | 5 |
add esp, 8 |
1 | 3 |
总计 | 7 | 19 |
* 在I486机器上运行程序所需的时钟周期
因此,对于相同的代码,在情况A中,我们有18字节的代码,消耗10个机器周期;在情况B中,我们有19字节的代码,但消耗7个机器周期。
请注意情况 A 中的第一条指令和情况 B 中的前两条指令:在情况 A 中,只使用了一条指令 (push
),但它涉及到使用内存地址操作数 (DWORD PTR
),这比使用寄存器作为操作数消耗更多的周期。在情况 B 中,通过将内存地址存储在寄存器 (eax
,在这种情况下) 中,然后将该寄存器压栈来完成。即使有两条指令而不是一条,操作也会执行得更快。这基本上是与优化相关的“魔术”:当目标是速度时,最大限度地优先考虑直接涉及寄存器的操作,并避免(如果可能)涉及内存操作数的操作。
但我们可以从这段代码中获得更多。请看A和B两种情况下堆栈帧的创建
情况A:最小化大小
指令 | 时钟* | 大小 |
push ebp |
1 | 1 |
mov ebp, esp |
1 | 2 |
push ecx |
1 | 1 |
push ecx |
1 | 1 |
总计 | 4 | 5 |
情况B:最大化速度
指令 | 时钟* | 大小 |
push ebp |
1 | 1 |
mov ebp, esp |
1 | 2 |
sub esp, 8 |
1 | 3 |
总计 | 3 | 6 |
* 在I486机器上运行程序所需的时钟周期
请注意,情况A中的最后两条push
指令与情况B中的sub
指令执行相同的功能,但速度稍慢,大小稍小。由于ecx
是32位寄存器(4字节),在两条push
指令执行完毕后,堆栈指针将有8字节的位移(我们有两个dword
局部变量),这与情况B中的sub esp,8
相同。这本身就解释了为什么我们的代码在情况A中有一个pop
序列,而在情况B中有一个add
指令来恢复堆栈(记住:我们正在使用C调用约定)。
混合模型:混合优化
扩展前面的分析,请注意,对于相同的操作,我们有6字节的代码大小,在情况A中消耗4个周期,在情况B中只消耗2个周期。为什么不将情况A中的此指令替换为情况B中的指令呢?这将导致一个混合优化模型,其中我们同时拥有小型和快速的代码。应用此替换(手动),我们得到
混合优化模型
指令 | 时钟* | 大小 |
mov eax, DWORD PTR ?hello@@3PBDB |
1 | 5 |
push eax |
1 | 1 |
push OFFSET FLAT:??_C@_03HHKO@?$CFs?6?$AA@ |
1 | 5 |
call _printf |
3 | 5 |
pop ecx |
1 | 1 |
pop ecx |
1 | 1 |
总计 | 8 | 18 |
* 在I486机器上运行程序所需的时钟周期
我们仍然拥有与情况A相同的18字节代码,但现在消耗8个周期,这几乎是情况B的值!您现在可以使用MASM来汇编这个新代码(示例中包含一个用于此操作的.bat文件)。
然而,请将此混合模型仅视为理解本研究的练习,因为在真实且广泛的项目中,这种手动过程变得不可行且容易出错。
优化模型:选择最佳解决方案
现在,“常驻”的问题是
- 如果我的代码只执行多3个或少3个周期,有什么区别?
- 选择最小尺寸模型不是更方便吗?
这是一个常见疑问,但可以通过考虑以下因素来回答
- 这只是一个小例子,我们只有一个简单的函数被执行。现在想象一个真实的程序,其中有无数次操作。您现在可以按比例想象,通过最大化速度可以节省多少机器周期(或者通过最小化大小可以节省多少字节代码)。
- 想象一下我们的例子在一个有100万次迭代的循环中。对于代码本身所需的每个周期,我们不仅需要一个周期,而是直到循环结束才需要100万个周期!在现代计算机上,这可能察觉不到,但是例如在旧的80386上运行程序时,这种差异是显而易见的。
- 在保护模式下运行时,许多并发任务正在执行,在关键和复杂的任务中,例如工程应用中广泛的数学运算,也许快速的代码更合适。
可以列举许多其他因素来证明使用一种或另一种模型的合理性,这表明选择正确的优化模型必须考虑这些因素。这是一项客观而又主观的任务,很多时候被遗忘或不被考虑,但它能决定我们项目的成功。