2.3 小方法,大改进
2.3.1 消除常用子表达式
消除常用子表达式经常被认为是编译器的任务。其基本思想是,在构造复杂表达式之前,预先计算其中被多次调用的子表达式,并将结果存储在临时变量中。在循环代码优化中,这个方法称为循环无关代码移出:
该优化方法可节省大量计算时间,特别是当子表达式中包含“强”操作(如sin())时。尽管子表达式消除可能会受其他代码的影响,编译器原则上能够检测到并进行有关优化工作。然而,如果这个操作还需要其他关联规则,那么编译器常常不会进行优化(2.4.4节详细讨论了编译器优化和算术表达式重排序优化)。在实际应用中,手工完成这项工作是非常好的策略。
2.3.2 避免分支
“紧”循环(比如,循环体内部操作很少)常用的优化技术是软件流水(见1.2.3)、循环展开和其他优化技术(见本节后面内容)。由于某些原因编译器自动优化失败或者优化不够充分,则会明显影响程序性能。如当循环体内部包含条件分支时,则很容易发生:
上面的矩阵向量乘实例,使用if表达式完成了对上三角矩阵(sign = 1)、下三角矩阵(sign = -1)以及对角线元素(sign = 0)的分别处理。一旦处理器遇到对应的条件分支,许多分支预测逻辑单元在计算结果可用之前就会采用基于统计的方法对该计算结果进行预测。一旦预测被证明是错误的(也称为分支预测失误或分支迷失),流水线将重新回到该分支位置,这意味着时钟周期的浪费。此外,分支预测失败后,编译器也就不能继续进行循环展开或者SIMD向量化(见下一节内容)等后续优化。幸运的是,该循环嵌套可通过改进消除所有if表达式:
通过使用两个不同的内循环,条件分支被移出。要说明的是,这个循环嵌套还有更多的优化潜力。具体请参考第3章对数据访存操作优化的讨论。
2.3.3 使用SIMD指令集
尽管向量处理器也使用SIMD指令,微处理器对SIMD指令的使用也称为“向量化”,使用SIMD指令集更类似于现代向量化系统的多轨机制。一般而言,如果一条单一指令可执行更多的操作,那么一个上下文中“可向量化”循环的性能可以更高。例如,尽可能使用“小”数据类型。从DP切换到SP可能会导致两倍的性能提升(具备SIMD能力的x86型CPU[V104, V105]),而且还可以将更多的数据加载到cache中。
当然,选择SIMD指令,并不总能带来性能提升。如果应用程序性能严重受限于受访存带宽,不采用SIMD技术可弥补这一差距。使用SIMD指令,只会大大加快寄存器到寄存器操作的性能,但是会大大延长寄存器从内存子系统中获取新数据的时间。
图1-8描述的一条单精度加法指令可用在数组相加的循环中:
在上例中,循环的每次迭代都是相互独立的,循环体内部没有条件分支,数据访存也为连续访存操作。然而,使用SIMD指令需要对循环代码(如上例中应用的)重新组织:多次迭代(与SIMD寄存器大小相等)间不允许有分支,能够像单一的“块”一样执行。即使没有SIMD,这也是一个众所周知的优化方法——循环展开(详细讨论见3.5节)。因为循环的迭代次数一般不是寄存器大小的整数倍,所以余下的循环迭代还是会标量执行。忽略软件流水(参见1.2.3节)的伪代码如下:
https://yqfile.alicdn.com/428b7bd8ad3bed6d60ab45cbfdeb5df25f20987a.png
" >
R1、R2、R3都是128位的SIMD寄存器。理想情况下,上述操作由编译器自动实现。实际优化中,可使用编译指导语句,提示可向量化的代码(这些代码的向量化必须是安全并且有益的)。
在这个例子中,SIMD 读取和存储指令需要特别关注。操作对齐数据和非对齐数据的一些SIMD指令集是不同的。以x86(Intel/AMD)架构为例,该架构拥有对齐和非对齐的“打包”SSE 读取和存储指令[V107,O54]。如果将对齐的读取和存储指令应用于非对齐内存地址(不是16的倍数),则会抛出异常。如果编译器对应用到向量化循环中的数组的对齐情况一无所知,又不能对其影响和控制,尽管会带来性能损失,也一定要使用非对齐的读取和存储指令(或者使用一系列的标量化指令)。如果程序员不能肯定数据是对齐的,则强制编译器假设最优对齐是非常危险的。在某些架构上,对齐操作至关重要,需要尽一切努力保证读取和存储指令对齐到合适的地址边界。
迭代间存在真依赖的循环(见1.2.3节)是不能被SIMD以此种方式向量化的(然而也有转机,见习题2.2)。
这里,编译器将会进行标量操作,也就意味着只使用SIMD寄存器的最低位(x86架构)。
值得注意的是,循环向量化没有固定的指导原则。一个(可能是最弱的)可能的定义是循环内所有算术运算的执行要充分利用SIMD寄存器(完全利用SIMD寄存器的宽度)。即便如此,仍然可以使用标量读取和存储指令,编译器也会认为这样的循环是向量化的。支持SSE的x86处理器,其寄存器的高64位和低64位可以独立使用。因此,上述循环的向量化加法可看作为双精度加法:
上例中,如果操作数驻留在cache中,该版本并不能提供最佳性能。尽管算术运算(第9行)都是SIMD并行操作,而读取和存储却都是标量运算。由于缺乏完整的编译器报告,因此确定这样一个缺陷的唯一方法是手动检查生成的汇编代码。即使添加命令行选项或者源代码指导语句,编译器还是不能有效完成循环的向量化。那么,在使用汇编语言之前,一个“不得已而为之”的方法是使用编译器内部函数(compiler intrinsics)。内部函数和汇编指令非常相似,两者可被编译器进行1:1转换。由于编译器为内部函数提供了可映射到SIMD操作数的特殊数据类型,所以使用内部函数可使用户从追踪每个寄存器使用情况的繁重工作中解脱出来。内部函数不仅对向量化非常有用,而且在高级语言设计不能很好地映射到CPU某些特性的情况下,也非常有用。然而不幸的是,即使在同一个硬件架构上,编译器间的内部函数也互不兼容[V112]。
最后,必须强调的是,相对于真正的向量化处理器,RISC系统并不总能从向量化中受益。如果一个访存受限程序可使用寄存器或cache进行大量数据重用(见第3章例子),那么数据重用优化的潜在性能提升是非常大的,甚至可以放弃向量化优化。