《高性能科学与工程计算》——2.3 小方法,大改进

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章例子),那么数据重用优化的潜在性能提升是非常大的,甚至可以放弃向量化优化。

时间: 2024-07-29 10:36:35

《高性能科学与工程计算》——2.3 小方法,大改进的相关文章

《高性能科学与工程计算》——1.6 向量处理器

1.6 向量处理器 从Cray 1超级计算机开始,直到基于RISC的高度并行计算机出现之前,向量机一直占据着科学计算的主要领域.在写这本书时,只有两家公司还在制造和销售向量机.但因为对内存带宽和运行时间有高度需求,向量机还是有着一个充满商机的市场. 根据设计,对于合适的可向量化的代码,向量处理器相较于标准的微处理器可以达到一个较好的实际性能.这种设计遵循单指令多数据(SIMD)的范例,即一条简单的机器指令被自动地应用于很多类型相同的参数.许多现代的基于cache的微处理器以扩展SISD指令集的形

《高性能科学与工程计算》——1.2 基于高速缓存的通用微处理器体系结构

1.2 基于高速缓存的通用微处理器体系结构 微处理器可能是人类发明的最复杂的机器.然而,就像前面章节中描述的那样,它们都基于存储程序数字计算机的概念.对于科学家而言,理解CPU所有内部工作细节是不可能的,也是不必要的,尽管把握其高级特性对于了解潜在的性能瓶颈是有帮助的.图1-2展示了现代基于高速缓存的通用微处理器的简图.对于一个运行的程序,真正执行计算的部分是仅占芯片一小部分的浮点型(FP)和整型(INT)计算单元.其他逻辑控制单元用来向计算单元提供操作数.一般将CPU寄存器区分为浮点数和整数两

《高性能科学与工程计算》——1.3 存储层次

1.3 存储层次 数据以不同的方式存储在计算机系统中.前面章节提到,CPU有一组可以不延时访问的寄存器.另外,有一个或几个容量小但存取速度快的cache,用以保存最近被访问过的数据项副本.主存要慢得多,但是容量比cache大很多.最后,数据能够存放在磁盘上,在需要的时候再复制到主存中.这是一个复杂的层次结构,为了搞清楚它的性能瓶颈,理解数据在不同层次之间的传递原则是至关重要的.下面我们将集中描述从CPU到主存的各个层次(见图1-3).1.3.1 高速缓存 高速缓存是低容量.高速度的存储器,通常集

《高性能科学与工程计算》——2.5 C++优化

2.5 C++优化 目前,有大量关于如何编写高效C++代码的文献[C92,C93, C94, C95].我们的目标不是取代它们.所以我们特意忽略了引用计数.写时复制.智能指针等关键技术.本节以循环代码为例,根据我们的经验指出C++编程中经常存在的性能错误和误解. C++编程存在着一个根深蒂固的假象:编译器应该能够识别高级C++程序包含的所有抽象和代码混淆.首先,C++是一门支持复杂管理的高级编程语言,且自身特征明显(如运算符重载.面向对象.自动构建/销毁等).然而,这些特征绝大多数都不适合编写高

《高性能科学与工程计算》——2.4 编译器作用

2.4 编译器作用 通过利用编译器自动优化,高性能计算程序可以获得不同程度的性能改进.几乎每个现代编译器都可以在命令行上设置编译选项,以便对编译器优化目标程序进行细粒度控制.有些情况下可以简单地通过更换一个编译器来检查程序是否还存在性能提升空间.编译器需要进行复杂的工作以将高级代码编写成的源程序编译为机器代码,同时要顾及到处理器内部资源.本章和下一章讨论的一些优化方法可以在某些简单情况下被编译器实现,但是涉及复杂的情况时就无法用编译器自动完成优化工作.始终要注意的一点是编译器可能足够聪明但是又可

《高性能科学与工程计算》——2.2 优化常识

2.2 优化常识 简单的代码修改经常会带来性能的显著提升.下面的章节总结了避免性能缺陷的几个最重要的"优化常识".这些方法看似微不足道,但许多科学应用程序在应用这些方法后,性能都有了显著提升.2.2.1 少做工作 重新组织代码以减少代码工作量,在很多情况下可显著提升性能.最常见的例子是循环检测一组对象是否具有特定属性,任一对象具备该属性即可: 如果complex_func()函数没有其他作用,FLAG是唯一和循环外部通信的变量.这种情况下,FLAG值一经改变,就立即退出循环可明显减少计

《高性能科学与工程计算》——第3章 数据访存优化3.1 平衡分析与lightspeed评估

第3章 数据访存优化 在高性能计算中,访存是最重要的性能限制因素.如前所述,微处理器的理论峰值性能和访存带宽存在固有的"不平衡性".因为很多科学和工程应用程序由需要大量数据传输的基于循环的代码构成,所以相对较低的内存(甚至是硬盘)访存带宽,就会导致片上资源的低效利用和程序性能的降低.图3-1综合显示了现代并行计算机系统的数据通路构成,以及在不同层次上的带宽和延迟范围.执行计算任务的功能部件位于该层次结构的顶部.在这些不同层次的数据通路中,访存带宽最大有3-4个数量级的差异,访存延迟最高

《高性能科学与工程计算》——第2章 串行代码基本优化技术2.1 标量剖析

第2章 串行代码基本优化技术 在千核级并行计算机时代,有些观点认为编写高效串行代码在许多领域已经有些过时了.因为增加更多CPU以获得大规模并行能力要比投入大量精力优化串行代码简单得多.这似乎是一个合理的理论,5.3.8节的论述中也体现了对这种观点的支持.然而,本书认为程序在单处理器上的性能优化毫无疑问是最重要的,如果通过一些简单的优化方法就可以实现两倍加速比,那么用户会更倾向于使用较少的CPU.这样不仅可把宝贵的计算资源释放给其他用户或项目,而且还可以使投入大量资金购买的硬件获得更加有效的利用.

《高性能科学与工程计算》——1.5 多线程处理器

1.5 多线程处理器 所有现代的处理器都以高度流水线化来提高性能(如果可以使用流水线).前面提到,一些因素会影响流水线的高效利用:相关性.存储延迟.不确定的循环长度.指令混合以及分支判断错误等(参考2.32节)将导致流水线频繁等待,很大一部分执行资源处于空闲状态(见图1-19).不幸的是,这种情况是规则而不是意外.为了提高时钟频率而尽可能设计长流水线会增加算法的复杂性,结果导致没有获得成比例的性能提升,处理器也会有更多的功耗. 正是由于这个原因,到很多现代的处理器设计中加入了线程,也叫做超线程技