Loading

CSAPP(五)——优化程序性能

编译器优化局限性

现代编译器都会在编译时对你的代码进行一些分析和优化,比如简化计算表达式,内联函数等等,并且编译器会对你提供优化等级的控制。在GCC中使用-Og-O1-O2-O3会让编译器使用越来越多的优化。书上说编译器不会对代码进行激进的优化,编译器对代码做的所有优化都必须产生和未优化前一样的结果,但据我了解像Java中的JIT即时编译器是会基于统计信息对代码做一些激进甚至会改变运行结果的优化的,当发现优化后的执行出错时,JVM会有一些机制消除已经执行的错误逻辑,并重新启用逻辑等价的代码(好像称作逃生门)。不过无论如何,我们只需要知道有这么回事儿就行。

内存别名使用

无法做激进优化,这让编译器在很多能帮上忙的地方也爱莫能助,因为它不能替你做决策尽管在你的代码路径中做了这个决策也不会出问题。比如:

twiddle1twiddle2都是将xp加上2倍的yp,而第一个版本的代码有六次内存操作(两次读xp,两次读yp,两次写xp),第二个版本的却只有三次。明显——尤其是在twiddle在循环中被调用时——twiddle2的性能比另外一个要好很多,但是编译器却不能做这种优化,因为存在一种可能,即xp==yp

如果xp==yp,那么twiddle1的两次分步操作会让最终xp的结果翻4倍,而twiddle2的非分步操作则只会让xp翻3倍。编译器必须考虑这种情况,这种情况被称为内存别名使用

练习题5.1

结果是0,从第二行开始就是了。

有副作用的函数

下面的两个函数好像都是一样的结果,看起来,一次函数调用一定要比四次函数调用更优。

但是,编译器没法保证四次调用f()返回相同的值,如果每次调用f(),值都会发生改变,那这种优化就是危险的。比如下面这个会修改全局变量的函数。

大多数编译器不会检测优化一个函数调用是否具有副作用,相反,编译器压根不会动这个函数。

内联函数替换:

上面的函数虽然没有办法被优化成另一个版本,但是可以使用另一种面向函数的优化技术,即内联函数。

f()中的代码直接放到引用它的位置,这样消除了所有的函数调用,还不会产生副作用。最主要的是,函数内联之后,编译器往往能对内联后的代码进行进一步的优化,所以一些优化手段(即使没有显著的性能提升)可能会被编译器作为另一些优化的基础。比如Java即时编译技术中的标量替换,它就会为稍后的栈上分配来打基础,如果没有标量替换这一步,栈上分配无法实行。

表示程序性能

我们采用每元素的周期数CPE来表示程序的性能,它能更好的反应一些不断循环的程序的性能。假如循环中需要处理10个元素,耗费100个时钟周期,那么\(CPE=100/10=10\),所以CPE是循环中的每个元素占用的平均时钟周期,这个值越小越好。

优化技巧:循环展开

下面是本书介绍的第一个优化技巧,循环展开。下面的两个函数的功能一样,都是求一个数组的前n项和,放到另一个长度为n的数组中。第一个每次计算一个元素,第二个每次计算两个元素。

书上给的基于最小二乘来拟合的两个函数随着n不断加大的运行效率变化,这两个函数的运行时间近似于\(368+9.0n\)\(368+6.0n\)

对于CPE来说,常数项一样的情况下,肯定是系数越小CPE越小。所以明显使用循环展开技术的算法效率更高。

使用CPE而非每循环周期数来度量是因为,CPE和循环次数无关,它能反映出对于给定的向量长度下程序的执行效率。像对于循环展开这种会减少循环次数并增多每次循环中的工作的优化技巧,每循环周期数这种度量不合适。

练习题5.2

  • n<=2时版本1最快
  • 3<=n<=7时版本2最快
  • n>7时版本3最快

使用初等代数中不等式的知识计算

程序实例

首先声明如下结构,该结构代表一个向量,len代表该向量的长度,*data代表向量中村的数据。

对于我这种对C语言几乎一窍不通的人,data_t类似于其它编程语言中的泛型,我记得学校讲数据结构时有一个抽象数据类型ADT,说的就是这种。即data_t是在编写一个数据结构时未指定的类型,这个类型在使用时才会被实际定义,这就让我们的数据结构可以适用于多种类型。

比如我们只需要声明如下代码,data_t就会被当作长整型。

typedef long data_t;

下面是三个向量操作的函数,new_vec创建向量,分配空间,get_vec_element获取向量中的一个元素,vec_len获取向量长度,在这之前会做边界检查。

假设我们现在是这个向量数据结构的使用者,并且我们编写了如下函数combine1用于对数组进行操作:

函数是一个简单的遍历并按某种方式累计遍历的每个值,中未确定的部分就是IDENTOP,它们指定了结果的起始值和累计的方式。我们可以按如下方式定义这两个操作:

下面是combine1在一些不同的元素类型、OP和GCC优化等级下的CPE,测量的CPU是Intel Core i7 Haswell

对于这个函数,O1的优化等级直接让性能翻了一倍左右。不过这是编译器自己帮我们做的,本章主要研究我们如何不在编译器的帮助下自己写出性能更优的程序,所以后面会逐步对这个程序进行优化。

消除循环的低效率

注意combine1中的这行代码:

我们每次执行循环,都会调用一次vec_lengthcombine2把它提到外面,函数也是正确的,这种优化称为代码移动

编译器无法对vec_length的行为做一些大胆的猜测,所以它没法帮你做这个优化。

下面是优化后的CPE,可以看到我们手动的优化比O1带来的优化更优。

好在vec_len中并没有对向量进行一次什么扫描,而是直接记录了一个len,否则这就是一场灾难,vec_len的性能会随着数组长度变长而递增,而在combine1中,vec_len会被调用n次,这是平方级别的性能下降。

练习题5.3

  1. A:1 91 90 90
  2. B:91 1 90 90
  3. C:1 1 90 90

减少过程调用

过程调用会带来开销,而且过程为了中为了保证安全性会做一些检查,比如get_vec_element中的边界检查,但是对于我们的代码来说,我们产生的所有向量访问下标都是合法的。所以除了过程调用本身的开销以外,还有可能带来额外的开销。

我记得Java中好像是有这么个优化技术,就是访问下标都合法的情况下会去掉边界检查。

对于这种操作,我们可以自己定义一个直接向量访问函数:

我个人是比较站不进行这种优化的,但这一章就是讲优化。

优化后,性能并没有明显提升,这并不说明减少过程调用这个原则是没用的,而是我们的代码中可能存在其它性能瓶颈。

消除不必要的内存引用

考虑combine3中的这个循环中的代码

严格来说它需要三次读内存和一次写内存......查看编译过后的代码消除了一次读内存:

所以指针操作就会被直接编译成内存读写,而读写内存相比寄存器来说是很慢的操作。

解决办法是使用一个局部变量来保存运算的中间值,这样编译器就可以利用寄存器了,这样就从两次读一次写变成了一次读

编译器无法做这种优化,因为可能存在*dest只是向量中某一个元素的内存别名的情况。

练习题5.4



  1. A:%xmm在更高优化等级中有点像combine4中临时变量的角色
  2. B:是的,因为每次迭代都将dest写回
  3. C:同上

理解现代处理器

下面还是先介绍下现代处理器的某些特性,以看看我们除了上述那些通用的优化方法,还有什么特定于处理器的优化方法。这些特定于处理器的优化方法在目前常见的处理器架构上也十分通用。

现代处理器为了改进性能,它采用的实际操作和向外部展示的概念模型相差甚远,看似顺序执行的指令实际上都是并行执行的,但处理器保证它所做的各种优化后的执行方式仍然与顺序语义时一致。又是公有设计私有实现的体现,已经说烂了。

流水线技术就是上面所说的最好的证明。

同时,当一些数据相关的指令无法并行执行时,就会遇到延迟界限,同时,并行性也有个度,因为毕竟处理器只是一个硬件设备,它不会魔法,所以当并行度达到处理器能达到的最高限制时,就会遇到吞吐量界限

整体操作

现代处理器采用的指令并行技术比上一章所讲的流水线技术更加复杂,是一种被称为超标量(superscalar)的技术,这种技术不仅可以在每个指令周期中执行多个操作,而且这些操作还有可能是乱序的。

下面是一个现代处理器的简化图,主要分为用来取指和根据指令序列决定对应操作的指令控制单元(ICU)和用来实际执行指令的执行单元(EU)构成:

ICU取指,指令译码将一条指令分成多个操作(微操作),并把这些操作下发给EU,EU在每个时钟周期会接受到多个操作,并且会将它们分配给不同的功能单元进行执行。

在一个典型的x86-64实现中,一条只对寄存器操作的指令比如:

addq %rax, %rdx

会被转化为一个操作一条包括一个或多个内存引用的指令会被转化为多个操作,比如:

addq %rax, 8(%rdx)

会被分解成一个从内存中加载的操作,一个算数操作和一个存储到内存的操作。

Intel Core i7 Haswell上的8个功能单元:

退役单元是用于分支预测的一个设备,指令译码时,关于该指令的信息被保存起来,一旦该指令操作完成,代表引起该指令的所有分支预测正确,指令退役,此时分支中对程序寄存器的改变才会被写入;如果引起该指令的分支预测错误,指令会被清空,所有计算出来的结果都会被丢弃。

和流水线技术一样,每个操作会使用其它操作产生的值,而不是等待指令全部完成后的写回,这里用了一种比流水线中的转发技术更加复杂的技术,不详细说明。

功能单元的性能

下面是使用CPE的方式来描述这些运算操作运用到循环中的每循环时钟周期:

从延迟的角度来说,它们等于指令的执行延迟。从直观上来看,CPE不可能小于1,但是别忘了,我们有多个运算单元,这些运算单元可以同时执行运算(但又受到加载单元的制约),所以后面我们也会说到利用某种并行性来让CPE小于1。

处理器操作的抽象模型

从机器代码到数据流图

由于书上把这个地方讲的异常复杂,所以我先简单讲一下这一节的中心思想,然后爱看的看,不爱看的直接跳到下一个小节。

就上面这张图,combine4的代码中有一个浮点乘法vmulsd和一个整数加法addq,不论如何拆分这两个指令,最后肯定有一个浮点乘法和一个整数加法操作,并且下一次循环依赖上一次循环产生的值,所以下一次循环必须要在这两个计算完全ok后才能开始,这在书上被称为数据相关。假设在算数逻辑单元中,浮点乘法的延迟是5个时钟周期,整数加法是1个,那么这个浮点乘法将成为影响程序性能的关键部分。好了,下面爱看不看了

这里主要描述在对大向量进行循环计算这种任务中,循环间的数据流的数据相关对程序性能的制约。数据相关你可以理解为两次循环间相关的数据

如上是combine4的循环中代码,在我们假想的处理器中,它会被分成五个操作,首先就是vmulsd的一个用于读取内存的load操作和用于计算的mul操作,剩下的三个指令分别被转换为三个操作,因为它们只操作寄存器。

左侧的图像说明了寄存器被这五个操作使用的情况,上面代表循环开始时的寄存器值,下面代表循环结束时的寄存器值,显然%rax只被cmp操作读取,而%rdx被读取并修改了,%xmm0也被读取并修改了。

所以,循环中的寄存器我们可以分成四类:

  1. 只读:只会被某些操作当作源值,如上面的%rax
  2. 只写:只会被某些操作当作传送数据的目的,这里没有这种寄存器
  3. 局部:只在循环内被修改和使用,每次循环之间不相关。这里没有显式的这种寄存器,但条件码寄存器算一个。cmpjne会修改它,但每次循环之间它们不相关
  4. 循环:在循环中又读又写,并且不是局部的。这里%rdx%xmm0都是。

限制循环程序性能的,是这些循环寄存器之间的操作链带来的数据相关,再次重申,数据相关就是两次循环迭代之间具有关联的数据,可以理解为上面所说的第四种寄存器

下图是重新排列后的combine4的操作。这里存在两个数据相关,第一个%xmm0保存的是每次循环的累计变量acc,它由一个load加载其中一个乘数并由mul计算,作为下一次循环的新值,第二个是用于保存循环索引i的%rdx,由一个add计算。右侧是将那些没有数据相关的操作删除之后的图。

上图更清楚的把每次循环间的数据相关给画出来了,我们假设整数乘法的延迟为5个时钟周期,而整数加法的延迟为1个,那么左边的链路需要5n个,右面的链路需要n个。显然左边的链路成为制约系统性能的关键路径。

其它性能因素

考虑之前我们测得的关于combine4的一个CPE,在整数加法情况下,CPE是1.27,但是从上面的数据链来看,明明应该是1.00,因为整数加法的延迟是0。这说明预测值只是实际值的一个下界,由于实际功能单元数量等物理限制,CPE可能会比预测要低。

习题5.5


  1. A: 2n乘法,n加法
  2. B: 受浮点数乘法的延迟制约

习题5.6

  1. A: n次乘法和n次加法
  2. B: 浮点数加法的3延迟和浮点数乘法的5延迟
  3. C: 虽然5.5中的版本有两个浮点乘法,但只有一个出现在迭代的关键路径上。

循环展开

combine5使用我们早讨论过的循环展开技术,将每次循环的操作从一个变成俩,下面是CPE的提升:

终于把我们的整数加法的CPE改进到接近延迟界限了。

循环展开主要是减少了那些循环变量的操作数量。它不可能将CPE降低到突破延迟界限的一个原因是,虽然循环次数缩小了几倍,但编译后的代码还是要顺序的执行n次算数操作,还是n个延迟所消耗的时间

循环展开在大于等于3的优化等级中被编译器来完成

提高并行性

多个累计变量

嘶,当我看到作者写的代码时我tm直呼过瘾。。。

combine6同样通过循环展开将循环展开成2x1的,然后维护两个累计变量,这样两个整数加法操作不必顺序被执行,所以可以利用两个整数运算单元来并行执行加法操作。这下,除了整数加法,大部分情况下整体CPE变成之前的1/2。

我们将这种展开称作2x2展开。

当我们将循环展开为k次时,k越大,CPE越能接近吞吐量界限。

需要注意!别忘了!!!浮点数的运算是不可交换不可结合的,所以你必须容忍combine6实际上已经不是和之前完全一致的代码了。

重新结合变换

没看懂,说是调整一下结合的顺序就能让循环中只有一条关键路径,所以达到了两倍性能的效果。

一些限制因素

寄存器溢出

当然可以不断地增加循环内并行度来获得更好的CPE,但是别忘了,CPU的资源数量是有限制的。比如你使用20个循环展开,而你的CPU只有16个寄存器,那一些中间变量不得不被存在运行时堆栈中。这时性能不升返降。

分支预测和预测错误处罚

看不动了,累了真累了。

理解内存性能

首先对于我们现在的示例,所有的内存加载的规模都完全可以放到高速缓存中,该小节只考虑使用高速缓存的内存数据加载和存储。

加载的性能

上面的combine被我们优化了很多很多个版本,但CPE始终没办法超过0.5,这是因为每次循环中都有个内存加载操作,我们只有两个加载单元,每个时钟周期顶多启动两个加载操作,所以加载单元的个数成了最后的性能瓶颈。

对于combine这种下次的加载地址与上次无关,所以两次循环之间的加载操作可以并行,但对于某些操作,比如下图的链表遍历,下次加载的地址在上一次读取的内存数据中,所以下一次循环的加载操作必须等待上一次循环的加载操作取出这个地址后才能开始。在这种情况下,加载操作往往成为关键路径中的限制

存储的性能

对于只有1个存储单元的CPU,下面代码的最高CPE为1.0。

存储操作之间不会产生数据相关,加载操作在读取写入之后的值时会受存储操作影响

下面的代码,示例A是src!=dst,所以这个读取和写入不会产生数据相关,测量之后这个CPE大概是1.3,示例Bsrc==dst,它们会产生数据相关,测量后这个CPE大概是7.3。

posted @ 2022-05-17 14:40  yudoge  阅读(448)  评论(0编辑  收藏  举报