2.4 编译器作用
通过利用编译器自动优化,高性能计算程序可以获得不同程度的性能改进。几乎每个现代编译器都可以在命令行上设置编译选项,以便对编译器优化目标程序进行细粒度控制。有些情况下可以简单地通过更换一个编译器来检查程序是否还存在性能提升空间。编译器需要进行复杂的工作以将高级代码编写成的源程序编译为机器代码,同时要顾及到处理器内部资源。本章和下一章讨论的一些优化方法可以在某些简单情况下被编译器实现,但是涉及复杂的情况时就无法用编译器自动完成优化工作。始终要注意的一点是编译器可能足够聪明但是又可能非常蠢笨。在讨论编译器能力时,一条常用评价是“编译器应该能够识别”,这经常是一个错误的假设。
参考文献[C91]概述了几种当前常用C/C++编译器的优化能力,并介绍了一些手工优化的技巧和指导。
2.4.1 通用优化选项
每种编译器都提供了一组标准优化选项(-O0,-O1,…),每个级别的优化都包括哪些优化方法并没有固定标准,需要参考相关手册。但是,所有编译器在-O0选项级别禁止大多数优化,因此这是调试和分析程序的正确方法,在更高级别的优化层次上,编译器进行检测和消除冗余变量、重排算术表达式等优化,因此调试器不能在代码和数据间提供一致的视图。
需要注意的是,某些问题只在高级优化层次上才出现。这可能是编译器的错误或缺陷,也可能是典型的错误,例如数组访问越界(读写索引超过了数组的界限),在-O0层次和-O3层次,数据被按照不同的方式组织,因此可能只在某个优化层次出错。这种错误很难被定位,有些情况下由于与优化器的冲突,甚至最常用的printf函数也不能帮助定位该类错误。
2.4.2 内联
内联通过插入被调用函数或子程序的全部代码来减少程序运行时的调用开销。例如每个被调用函数都会使用寄存器或者栈(具体要以来参数数量和调用方式决定)中资源来存储和传递函数参数,内联确实移除了将参数压入栈中的必要,并且使编译器可以在它认为需要的时候使用寄存器(而不是根据某些调用约定),从而减轻了寄存器压力,寄存器压力是指CPU没有足够的寄存器存储复杂计算或者循环体内的所有操作数(更多介绍见第2.4.5节)。最后,内联使得编译器可见的代码段增大并可以利用更多的在非内联情况下不可用的优化手段。程序员不应该依赖编译器优化内联代码,在性能关键段(例如循环体内),编译器看不见“真正的”代码反而无法优化。
函数调用是否会影响性能依赖于调用次数,通常情况下,内联频繁调用的小程序将会获得最大的性能加速。在C++代码中,内联是提高性能的基本方法,因为对简单数据类型的重载操作将会变为较小的函数,并且当内联函数返回一个对象时,临时复制可以被省略(更多C++优化细节见2.5节)。
编译器通常有不同的编译选项来控制内联的自动优化程度,例如,在什么程度上(即代码行数量)一个子程序可以作为一个被内联的候选对象等。注意C99和C++ inline关键字只是对编译器的一个提示,应该检查编译器日志(如果可用,见2.4.6节)判断函数是否真正被内联。
相反,在多处内联一个函数可能会增大目标代码而导致过量使用L1指令高速缓存,例如如果循环体内的指令不能存储在L1指令高速缓存中,将会与数据传输一起竞争更高层次的高速缓存或者主内存,因此读取指令延迟就会增大。所以在设置内联标识时需要考虑正反两个方面的效用。
2.4.3 别名
通过程序语言规则和对源代码的理解,编译器需要提出确切的假设来限制自身产生优化机器代码的能力。典型的例子是在C(以及C++)语言中利用指针(或引用)形式参数:
假设被指针a和b指向的内存区域不重叠,即[a,a+n-1]与[b,b+n-1]不重叠,那么该循环体中的读取和存储操作就可以按照任意顺序重排,编译器会应用它认为合适的任何软件流水方式或者展开循环并将读取和存储打包在一个程序块中,就像下面伪码(忽略其余循环):
此时循环可以简单地进行SIMD–向量化优化(见2.3.3节)。
然而,C和C++标准允许指针的别名,所以在优化时不能假设两个指针指向的区域不重叠,例如,如果a==b,该循环变成了1.2.3节的“真依赖”的Fortran实例,读取和存储的执行顺序必须与程序中声明的一致:
编译器在缺少更多信息的情况下必须按照这种方式生成代码,SIMD向量化优化也必须被排除,处理器硬件在某些限制条件下允许读取和存储的重排[V104,V105],但是必须保证程序语义。
在Fortran标准中禁止参数别名,这也是Fortran程序比相同的C程序快的主要原因之一。所有的C/C++编译器都有控制别名程度的命令行选项(例如Intel编译器中的-fno-fnalias选项和GCC中的-fargument-noalias,表明任何函数的两个指针实参都不指向同一区域)。如果编译器被告知没有参数别名,那么原则上就可以进行类似Fortran代码中的优化。但是应该注意,如果对优化过后的程序使用参数别名将会产生错误结果。
2.4.4 计算准确性
2.3.1节已经提到,当要求满足结合律时,编译器有时禁止重排算术表达式,除非极高层次的优化开关被打开。原因在于浮点运算不满足结合律[135]:如果a、b和c是有限精度的浮点数,则(a+b)+c一般不等于a+(b+c)。如果必须保证优化前代码的正确性,就不能使用结合律规则,因此应由程序员确定是否可以手工重组算术表达式。现代编译器都有相关编译选项用来控制算术表达式的重组,即使是在高层次的优化开关被打开的情况下。
同时要注意非正规数,即比用非零最高有效位表示的最小值还小的浮点数,会极大地影响计算性能,如果可能并且轻微的正确性损失是可接受的,那么这些数应该在硬件计算中被设为零。
2.4.5 寄存器优化
这是编译器优化(考虑使用寄存器)中最关键也是最复杂的任务之一,编译器试图将寄存器分配给使用最频繁的操作数并将这些操作数尽可能长的保留在寄存器中(如果这样做安全)。例如,如果一个变量的地址被访问,该变量的值很可能被改变(通过地址操作由程序其他部分改变),这种情况下编译器要决定是否将该变量写回内存中。
内联(见2.4.2节)可以减轻寄存器优化负担,因为编译器可以将本应该在函数调用前写入内存并随后再读出的变量保存在寄存器中。相反,优化拥有大量变量和算术表达式的循环体(可能出现在内联优化后)对于编译器来说非常困难,因为编译器要保持的操作数数目过大而不能在一次迭代中同时存储所有操作数。前面提到过,处理器中整数寄存器和浮点数寄存器的数量通常是有限的。目前典型的数目为8~128,如果寄存器数量不够,将会带来寄存器溢出,即将寄存器变量写回内存以供后续使用。如果程序的性能瓶颈是算术操作,那么寄存器溢出将会极大地降低性能。这种情况下可以划分一个大循环为多个循环来减轻寄存器使用压力。
一些处理器具备硬件支持可以处理寄存器溢出,例如Intel Itanium2处理器具有硬件性能计数器可以直接检测存储器溢出。
2.4.6 利用编译日志
前面几节指出编译器在编写高效程序中的关键作用。可以简单地操作以阻止编译器获得重要信息从而限制优化的级别和种类。为了更好地利用编译器的智能,需要编译器允许产生注释源代码表或者编译日志来表示本次编译过程都使用了哪些优化方法。代码清单2-1展示了一个MIPS R14000处理器上(已过时)编译注释的例子,即代码清单1-1中的标准三元组向量程序。该处理器是一个四路超标量处理器,在一个时钟周期内可以同时执行一个读取或存储操作,两个整数操作,一个浮点加和一个浮点乘操作(后两个操作通过一条融合的乘–加指令“madd”实现)。假设所有的数据都存储在最高层的高速缓存中,编译器可以计算程序一次循环迭代所需的最小计算周期数(第3行)。4~9行展示了处理器峰值,即每类指令的最大吞吐量。
代码清单2-1 流水化三元组软件的编译日表。其中“峰值”(peak)指该体系结构(MIPS R14000)上各种操作类型的最大执行速率
除此之外,寄存器利用和寄存器溢出(第11行和第12行),循环展开因子和软件流水因子(第2行,见1.2.3节和3.5节)、SIMD指令的使用(见2.3.3节)以及循环次数的编译器假定(第1行)都在表中展示,可以用来判断生成的机器代码的质量。然而,并不是所有编译器都有产生如此丰富的代码标注特性的能力,剩下的工作需要程序员完成。
也有人工检查汇编代码的编译选项。所有编译器提供命令行选项以输出汇编而不是可连接文件。然而,将该汇编文件与源代码进行对应并分析指令序列的效率需要很多经验[O55]。毕竟这是摒弃编写汇编语言代码的原因。