2.11编译器如何优化
由于编译器会极大地影响计算机的性能,理解编译技术是理解性能的关键所在。本节的目的是给出一个关于通过编译优化器来优化程序以提高性能的概述。接下来的一节介绍了编译器的内部剖析。
2.11.1高层优化
高层优化是在非常接近源码的层级上进行的变换。
最常用的高层变换可能是过程内联(procedure inlining),它是用函数体来替换函数调用,用调用实参取代过程参数。其他的高层优化包括通过循环变换来减少循环开销,改善存储器访问,更有效地使用硬件资源。例如,在像传统的for这样需要多次迭代的循环中,循环展开的优化通常是很有用的。循环摘开包括回去这个循环,多次复制循环体,减少转换后的循环的执行次数。循环展开能够减小循环开销并且为许多其他优化提供机会。其他的高层转换包括高级的循环变换,例如循环交换和循环很快来获得更好的内存行为。
2.11.2局部和全局优化
在完成局部和全局优化的过程中,会执行以下三类优化:
(1)局部优化作用于单个基本块。在全局优化前后分别运行局部优化以实现局部代码优化。
(2)全局优化,作用于多个基本块。
(3)全局寄存器分配,分配代码区域的变量给寄存器。寄存器分配是处理器性能提高的一个关键因素。
许多优化既有局部的也有全部的,包括公用子表达式消除、常数传播、复制传播、无用内存写消除和强度削弱。让我们看一下这些优化的简单例子。
公用子表达式消除(common subexpression elimination)查找多个相同表达式的实例并且用第一个实例的引用来代替第二个。例如每一个给数组元素加4的代码段:
x[i] = x[i] + 4;
x[i]的地址计算了两次,因为x的起始地址和i的值都没有发生变化,所以两次计算相同。因此,计算结果可以被重用。看一下这个片段的中间代码,它允许运行多个其他的优化。下面左边部分是没有优化的中间代码,右边部分是使用公用子表达式消除将第二个地址计算器用第一个计算代替的代码。注意到寄存器分配还没有发生,所以在这里编译器可以使用虚拟寄存器号例如R100。
让我们考虑一下其他的优化:
强度削弱(strength reduction): 优化将复杂的操作用几个简单的操作代替,在上述代码段中可以采用,只需将mult用左移来代替。
常数传播(coonstant propagation): 优化和类似的常数合并(constant folding)优化在代码中查找常数并传播它们,在可能的时候合并数值。
复制传播(copy propagation) :优化技术传播简单的复制值,免去重新从内存中取数据,并且可能同时允许其他的优化,例如公用子表达式消除。
无用内存写消除(dead store elimination): 查找不再使用的存操作并加以消除;和它类似的技术有无用代码消除(dead code elimination)。
程序员在使用指针来尝试提高访问变量的性能时,尤其是指向栈的指针,而这个指针和其他变量及数组元素的存在别名冲突时,这将阻止编译器做进一步优化。最终结果是相比被编译器优化的高层代码,底层指针代码的运行结果不会更好,甚至更差。
很多全局代码优化和局部代码优化有相同的目标,包括公用子表达式消除,常量传播。复制传播和无用内存写消除及无用代码消除。还有另外两种重要的全局优化:代码移动和归纳变量消除。这两种都是循环优化;也就是说,它们主要针对循环。代码移动查找循环中的不变部分:代码的特殊部分,在每次循环迭代中都会得到同样的值,因此,可以只在循环外计算一次。归纳变量消除是一系列变换的组合,包括减小索引数组的开销,本质上是用指针访问来替换数组索引
2.12编译器如何工作初探
略
2.13 以一个C程序的排序为例
略
2.14
略
2.15数组与指针
对于程序员新手来说,理解指针是一个很有挑战性的任务。对比使用数组和数组下标的汇编码和适用指针的汇编码,可以帮助从本质上来理解指针。本节展示了用于将内存中一个字序列清零的两个过程的C和MIPS汇编版:一个程序使用数组下标,另一个则使用指针。
2.15.1clear的数组实现
首先来看数组版本的clear1,我们主要关注循环体,而不是考虑过程链接相关的代码。假设两个参数array和size分别使用寄存器$a0和$a1保存,而i则保存在$t0中。
for循环的第一部分,变量i的如下初始化:
move $t0, $zero
为了把array[i]清为0,我们必须先得到它的地址。首先将i与4相乘,得到字节地址:
loop1 : sll $t1, $t0 , 2 # $t1 = $t0(i) *4
由于数组的起始地址保存在寄存器中,我们应该使用add指令将其与下标相加来得到array[i]的地址: add $t2, $a0, $t1 #$t2 = address of array[i] 此时,我们将这个地址置0: sw $zreo, 0($t2) # array[i] = 0 上面的指令是循环体的最后内容,接下来则是对i增1: addi $t0, $t0 , 1 最后,循环测试i是否小于size:slt $t3, $t0, $a1 # $t3 = (i < size)?
bne $t3, $zero, loop1 #if(i < size) go to loop1
到此,我们已经得到的所有过程的片段。
2.15.2 clear的指针实现
使用指针的第二个过程给两个参数array和size分别分配寄存器$a0和$a1,给p分配寄存器$t0.此第2个过程的代码开始时先给指针p赋值为数组第一个元素的地址:move $t0, $a0 接着的代码是for循环体,将0存入p: loop2 : sw $zero, 0($t0) #Memory[p] = 0 这条指令实现循环体,所以下面的代码就是迭代增加,将p改为指向下一个字:addi $t0, $t0, 4 在C语言中将指针自增(增1)意味着将指针顺序移动到下一个对象。由于p是指向整数的指针,而每个整数占用4个字节,所以编译器将p增4. 接着是循环判断。第一步是计算array中最末元素的地址。首先将size乘4得到它的字节地址:
add $t1, $a1, $a1 #$t1 = size * 2
add $t1, $a1, $a1 #$t1 = size * 4
然后将结果加到数组的开始地址上得到数组后面第一个字的地址:
add $t2, $t0, $t1 #$t2 = address of array[size]
循环判断仅仅判断p是否小于array的最后一个元素的地址:
slt $t3, $t0, $t1 #$t3 = (p < &array[size])
bne $t3, $zero, loop2 #if( p < &array[size]) go to loop2
所有代码片段都已完成。
2.15.3比较clear的两个版本
左边的版本在循环中必须有“乘”和加,因为i增加了并且每个地址必须由新的下标重新计算;右边的存储器指针版本直接增加指针p.指针方式将每次重复中执行的指令从7条减少到4条。这种手工优化相当于编译器的强度削弱(用移位代替乘法)和循环变量的消除。