优化C++软件
Posted wuhui_gdnt
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了优化C++软件相关的知识,希望对你有一定的参考价值。
8.5. 编译器优化选项
所有C++编译器有各种你可以打开、关闭的优化选项。学习正在使用编译器可用的选项,并打开所有相关选项,是重要的。
许多优化选项与调试不兼容。调试器可以一次执行一行代码,并显示所有变量的值。显然,在部分代码被重排、内联或优化掉时,这是不可能的。通常制作可执行程序的两个版本:带有完整调试支持,在程序开发期间使用的调试版本,以及打开所有相关优化选项的发布版本。大多数IDE(集成开发环境)有制作目标文件与可执行文件的调试版本与发布版本的设施。确保区分这两个版本,在可执行文件的优化版本里关闭调试与分析支持。
大多数编译器提供优化大小与优化速度的选择。在代码是快速的且希望可执行文件尽可能小,或者代码缓存是关键的时,关乎优化大小。在CPU使用与内存使用是关键时间消耗者时,关乎优化速度。选择可用的最强的优化选项。
某些编译器提供分析指引(profile-guided)优化。这以下面的方式工作。首先,你使用分析支持编译程序。然后,你使用分析器进行一次测试运行,确定程序流以及每个函数与分支执行的次数。然后,编译器可以使用这个信息优化代码,以最优的次序放置不同的函数。
某些编译器支持全程序优化。这通过两步编译进行。首先,所有的源文件被编译为一个中间文件格式,而不是普通的目标文件格式。然后,在完成编译的第二步里中间文件被链接起来。寄存器分配与函数内联在第二步完成。中间文件格式不是标准化。甚至在同一个编译器的不同版本间也不兼容。因此,以这个格式发布函数是不可能的。
其他编译器提供了将多个.cpp文件编译为单个目标文件的可能性。在启用过程间优化时,这使编译器能进行跨模块优化。进行全程序优化一个更原始、但高效的方式是,通过#include指示把所有的源文件合并为一个,并把所有函数声明为static或inline。这将使编译器能进行全程序的过程间优化。
在CPU开发历史中,CPU的每个新世代都会增加可用的指令集。较新的指令集使编译器能制作更高效的代码,但这使得代码与旧CPU不兼容。Pentium Pro指令集使浮点比较更高效。所有现代CPU都支持这个指令集。SSE2指令集特别有趣,因为它在某些情形里,使浮点代码更高效,并且使向量指令的使用成为可能(参考第107页)。不过使用SSE2指令集不总是最优的。在某些情形里,SSE2指令集使浮点代码更慢,特别在代码混用了单精度与双精度时(参考第143页)。当今大多数CPU与操作系统支持SSE2指令集。
在不需要与旧CPU兼容时,你可以选择较新的指令集。甚至更好,你可以制作代码关键部分的多个版本以支持不同的CPU。这个方法在第125页解释。
在没有异常处理时,代码变得更高效。建议关闭异常处理的支持,除非代码依赖结构化的异常处理,且你希望代码能够从异常恢复。参考第48页。
建议关闭运行时类型识别(RTTI)的支持。参考第41页。
建议启用快速浮点计算或者关闭严格浮点计算,除非要求严格。参考第68与67页讨论。
如果可能,打开“函数级内联”选项。这个选项的解释参考第69页。
如果你确定代码没有指针别名,使用“假定没有指针别名”选项。参考第66页解释。(Microsoft编译器仅在专业与企业版支持这个选项)。
不要打开对“FDIV bug”的修正。FDIV bug是最旧的Pentium CPU里的一个小错误,在某些罕见浮点除法情形里导致极小的不准确,修正FDIV bug会导致浮点除法变慢。
许多编译器有用于“标准栈框”或“框指针”的选项。标准栈框用于调试与异常处理,忽略标准栈框使函数调用更快,释放一个寄存器用于其他目的。这是一个优势,因为寄存器是一种稀缺资源。不要使用栈框,除非你的程序依赖异常处理。
8.6. 优化指示
某些编译器有许多关键字与指示用于在代码特定位置给出特定的优化指令。许多这些指示是编译器特定的。你不能预期用于Windows编译器的指示能在Linux编译器上工作,或相反。但大多数Microsoft指示在用于Windows的Intel编译器与Gnu编译器上起作用,而大多数Gnu指示在用于Linux的PathScale与Intel编译器上起作用。
在所有C++编译器上起作用的关键字
关键字register可以添加到一个变量声明上,告诉编译器你希望这是一个寄存器变量。关键字register仅是一个暗示,编译器可以不理会,但在编译器不能预测哪些变量用得最多的情形里,它是有帮助的。
Register的反面是volatile。关键字volatile确保一个变量不会被保存在寄存器里,即使临时。这目的在于在多个线程间共享变量,但它也可以用于关闭用于测试目的变量的所有优化。
关键字const告知变量永远不会改变。这在许多情形里允许编译器优化掉变量。例子:
// Example 8.24. Integer constant
const int ArraySize = 1000;
int List[ArraySize];
...
for (int i = 0; i < ArraySize; i++) List[i]++;
这里,编译器可以使用值1000替换所有出现的ArraySize。在例子8.24中的循环可以更高效的方式实现,如果循环计数器的值是常量,且在编译时刻已知。整数常量不分配内存,除非它的地址被获取(&ArraySize)。
Const指针或const引用不能改变所指向对象。Const成员函数不能修改数据成员。建议只要合适,使用const关键字向编译器给出关于变量、指针或成员函数的额外信息,因为这会提高优化的可能性。例如,编译器可以安全地假设类数据成员的值在调用const成员函数期间不会改变。
关键字static有几个含义,依赖于上下文。关键字static,在应用到非成员函数时,表示不会从其他模块访问该函数。这使得内联更高效,并启用过程间优化。参考第69页。
关键字static,在应用到全局变量时,表示不会从其他模块访问它。这会启用过程间优化。
关键字static,在应用到函数内局部变量时,表示在该函数返回时这个变量将被保留,并在该函数下一次被调用前维持不变。这可能是效率低的,因为某些编译器将插入额外的代码,防止从多个线程同时访问该变量。即使变量是常量,这也适用。
然而,将局部变量声明为static与const,确保它仅在函数第一次被调用时被初始化也是有原因的。例子:
// Example 8.25
void Func ()
static const double log2 = log(2.0);
...
这里,仅在Func第一次执行时,才计算log(2.0)。没有static,每次执行Func时,将重新计算这个对数。这有函数必须检查它是否已经被调用过的坏处。这比再次计算这个对数要快,但使log2成为一个全局const变量或者以计算值替换它,会更快。
关键字static,在应用到类成员函数时,表示它不能访问任何非静态数据成员或成员函数。静态成员函数的调用比非静态成员函数快,因为它不需要this指针。建议只要合适,将成员函数声明为static。
编译器特定关键字
快速函数调用。__fastcall或者__attribute__((fastcall))。在32位模式中,fastcall修饰符会使函数调用更快。前两个整形参数在寄存器中传递(在CodeGear编译器上3个参数)。Fastcall函数在编译器间不兼容。在参数在寄存器中传递的64位模式中,不需要fastcall。
纯函数。__attribute__((const))(仅Linux)。声明一个函数为纯函数。这允许公共子表达式线程与循环不变代码移动。参考第67页。
假设没有指针别名。__declspec(noalias)或__restrict或#pragma optimize(“a”, on)。说明指针别名不会发生。参考第66页的解释。注意这些指示不总是能工作。
数据对齐。__desclspec(align(16))或__attribute__((aligned(16))。说明数组或结构体的对齐。对向量操作有用,参考第107页。
8.7. 检查编译器做什么
研究编译器产生的代码,看它优化代码的程度,是非常有用的。有时,编译器会相当奇妙地使代码更高效,有时它蠢得难以置信。看编译器输出通常可以透露出什么可以通过修改源代码来改进,如下面例子所示。
检查编译器产生代码最好的方式是使用汇编语言输出的编译器选项。在大多数编译器上,你可以通过从命令行使用相关优化选项,以及汇编输出的选项-S或/Fa,调用编译器来完成。在某些系统上,IDE也有可用的汇编输出选项。如果编译器没有汇编输出选项,使用目标文件反汇编器。
注意,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, 32将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,我们可以告诉它这件事(参考第29页)。
我们注意到的第二件事情是,r指向的值从内存重新载入了一百次。这是因为我们忘记告诉编译器假设没有指针别名(参考第66页)。添加编译器选项“假设没有指针别名”(如果有效),可能会改进代码。
可以改进的第三件事情是,可以通过归纳变量计算r+i/2,因为它是循环索引的一个阶梯函数。整数除法阻止了编译器制作归纳变量,除非循环展开2次(参考第58页)。
结论是,我们可以通过展开循环两次,并制作一个明确的归纳变量,帮助编译器优化例子8.26b。(这消除了前两个建议改进的需要)。
// Example 8.26b
void Func(int a[], int & r)
int i;
int Induction = r;
for (i = 0; i < 100; i += 2)
a[i] = Induction;
a[i+1] = Induction;
Induction++;
编译器从例子8.26b产生下面的汇编代码:
; Example 8.26b 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: 4 + esp ; a
; parameter 2: 8 + esp ; r
$B1$1: ; unused label
mov eax, DWORD PTR [esp+4] ; eax = address of a
mov edx, DWORD PTR [esp+8] ; edx = address in r
mov ecx, DWORD PTR [edx] ; ecx = Induction
lea edx, DWORD PTR [eax+400] ; edx = point to end of a
$B2$2: ; top of loop
mov DWORD PTR [eax], ecx ; a[i] = Induction;
mov DWORD PTR [eax+4], ecx ; a[i+1] = Induction;
add ecx, 1 ; Induction++;
add eax, 8 ; point to a[i+2]
cmp edx, eax ; compare with end of array
ja $B2$2 ; jump to top of loop
$B2$3: ; unused label
ret ; return from Func
ALIGN 4
; mark_end;
?Func2@@YAXQAHAAH@Z ENDP
这个解决方案显然更好。现在,循环主体仅包含6条指令,而不是9条,尽管它一次进行两次迭代。编译器使用第二个包含当前数组元素地址的归纳变量(eax)替换i。不是在循环控制中将i与100比较,它把数组指针eax与预先计算、保存在edx中的数组末尾地址比较。
另外,这个解决方案少用一个寄存器,因此它无需ebx的压栈、出栈。
以上是关于优化C++软件的主要内容,如果未能解决你的问题,请参考以下文章