CSAPP(第三版)第五章优化程序性能学习笔记
编写高效程序需要做到以下几点:第一,我们必须选择一组适当的散粉阿和数据结构。第二,我们必须编写出编译器能有效优化以转换成高效可执行代码的源代码。
在程序开发和优化的工程中,我们必须考虑代码使用的方式,以及影响它的关键因素。通常,程序员必须在实现和维护程序的简单性与它的运行时度之间做出权衡。
理想的情况是,编译器能够接受我们编写的任何代码,并产生尽可能高效的,具有指定性行为的机器级程序。
程序优化的第一步就是消除不必要的工作,让代码尽可能有效的执行所期望的任务。这包括消除不必要的函数调用,条件测试和内存引用。
优化编译器的能力和局限性
阻碍优化的因素:
- 内存别名使用
- 函数调用(可用内联函数替换优化函数调用)
表示程序性能
我们引入度量标准镁元素的周期数(Cycles Per Element,CPE),作为一种表示程序性能并指导我们改进代码的方法。
处理器活动的顺序是由时钟控制的,时钟提供了某个频率的规律信号,通常用千兆赫兹(GHz),即十亿周期每秒来表示。
CPE(每元素的周期数)的有效值越低越好。
程序示例
定义一下数据结构,生成变量,访问向量以及确定向量长度的基本过程。
通过申明数据类型data_t,初始值IDENT和运算符OP来测量整数/浮点数数据的累加/累乘函数的性能
对应的CPE度量值如下
最好的方法是实验加分析:反复尝试不同方法,进行测量,检查汇编代码来确定底层的性能瓶颈。
消除循环的低效率
对combiner1函数优化,将计算向量长度的代码移到循环外面得到combine2.
改进后的性能如下
这个优化是一类常见的优化例子,称为代码移动。这类优化包括识别要执行多次但是计算结果不会改变的计算。
减少过程调用
过程调用通常会带来开销,并且会阻碍编译器对程序的优化。
我们可以看到combine2函数在循环中会反复调用get_vev_element函数来获得下一个向量元素,而在get_vev_element函数中会反复检查数组边界,我们可以发现该步骤在combine2函数中是冗余的,会损害性能。
我们可以将其改为以下形式来减少函数调用
消除不必要的内存引用
combine3进行编译,得到循环内对应的会变代码。
每次循环时,首先内存读取*dest的值,然后再写回内存中,再次迭代时,又读取再写回。一直重复。
理解现代处理器
当一系列操作必须按照严格顺序执行时,就会遇到延迟界限,因为在下一条指令开始之前,这条指令必须结束。当代码中的数据相关限制了处理器利用指令级并行的能力时,延迟界限能够限制程序性能。吞吐量界限刻画了处理器功能单元的元素计算能力。这个界限是程序性能的终极限制。
整体操作
上图是一个简易的现代微处理器示意图,它具有两个特点:
- 超标量,意思是可以在每个时钟周期执行多个操作
- 乱序,意思是指令执行的顺序不一定要与他们在机器级程序中的顺序一致
整个设计包含2个部分: - 指令控制单元,负责从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作
- 执行单元,执行上面的基本操作
ICU从高速缓存中读取指令,指令高速换尊是一个特殊的告诉存储器,它包含最新访问的指令。
分支预测:处理器会猜测是否会选择分支,同事还预测分支的目标地址。
投机执行:去除位于它预测的分支调到的地方的指令,并对指令译码,甚至在他确定分支预测是否正确之前就开始执行这些操作。
指令译码逻辑接收实际的程序指令,转换成一组基本操作(微操作)。
这种译码对指令进行分解,允许任务在一组专门的硬件单元之间进行分割。这些单元可以并行的执行多条指令的不同部分。
EU接收来自取值单元的操作。通常,每个时钟周期会接收多个操作,这些操作会被分派到一组功能单元中,他们会执行实际的操作。
读写内存由加载和存储单元实现。加载单元处理从内存读数据到处理器的操作,他具有一个加法器完成地址计算。存储单元处理从处理器写数据到内存的操作,也有一个加法器完成地址计算。
使用投机执行技术对操作求值,最终结果不会存放寄存器和数据内存中,直到处理器能确定应该实际执行这些指令。
分支操作送到EU,不是确定分支往哪去,而是确定分支预测是否正确。错误则从新寻找,直到正确。
功能单元的性能
Inter Core i7 Haswell参考机的8个功能单元,编号0-7
- 整数运算、浮点乘、整数和浮点数除法、分支
- 整数运算、浮点加、整数乘、浮点乘
- 加载、地址计算
- 加载、地址计算
- 存储
- 整数运算
- 整数运算、分支
- 存储、地址计算
其中,整数运算包含加法、位级操作和移位等等。存储操作需要两个功能单元,一个用于计算存储地址,一个使用保存数据。我们可以发现,其中有4个能进行整数运算的功能单元,说明处理器一个时钟周期内可执行4个整数运算操作。其中有2个能进行加载的功能单元,说明处理器一个时钟周期可读取两个操作数。
改参考机运算有以下数值来刻画: - 延迟,表示完成运算所需要的总时间
- 发射时间,表示两个连续的同类型的运算之间需要的最小时钟周期数
- 容量,表示能够执行概运算的功能单元的数量
从整数运算到浮点运算,延迟是增加的。还可以看到加法和乘法运算的发射时间都为1,意思是说在每个时钟周期,处理器都可以开始一条新的这样的运算。这种很短的发射时间是通过使用流水线实现的。流水线化的功能单元实现为一系列的计算,每个阶段完成一部分的运算。发射时间为1的功能单元被称为完全流水线化(fully pipelined):每个时钟周期可以开始一个新的运算。
表达发射时间的一种更常见的方法是指明这个功能单元的最大吞吐量,定义为发射时间的倒数。
延迟界限给出了任何必须按照严格顺序完成合并运算的函数所需要的最小号CPE值。根据功能单元产生结果的最大速率,吞吐量界限隔出了CPE的最小界限。
处理器操作的抽象模型
- 从机器级代码到数据流图
- 其他性能因素
循环展开
循环展开是一种程序变换,通过增加每次迭代计算的元素的数量,减少循环的迭代次数。
循环展开能够从两个方面改进程序的性能:
- 减少了不直接有助于程序结果的操作的数量,例如循环索引计算和条件分支
- 提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量。
提高并行性
多个累积变量
对于一个可结合和可交换的合并运算来说,比如整数加法或减法,我们可以通过将一组合并运算分割成两个或者更多的部分,并在最后合并结果来提高性能。
重新结合变换
打破顺序相关从而使性能提高到延迟界限之外的方法。可以通过括号位置不同重新结合变换,达到改变向量元素和累计值的合并顺序,形成新的循环展开方式,提高性能
优化合并代码的结果小结
一些限制因素
寄存器溢出
循环并行性的好处受汇编代码秒速计算的能力限制。如果我们的并行度P超过了可用的寄存器数量,name编译器会述诸溢出,将某些临时值放到内存中,通常是在运行时堆栈上分配空间。
分支预测和预测错误处罚
当分支预测逻辑不能正确预测一个分支是否要跳转的时候,条件分支可能会招致很大的预测错误处罚。预测错误惩罚19个时钟周期。
通用原则:
- 不要过分关心可预测的分支
- 书写适合用条件传送实现的代码
理解内存性能
加载的性能
一个包含加载操作的程序的性能既依赖于流水线的能力,也依赖于加载单元的延迟。
存储的性能
存储操作不会产生数据相关。只有加载操作会受存储操作结果的影响,因为只有加载操作能从由存储操作写的哪个位置读回值。
存储单元包含一个存储缓冲区,它包含已经被发射到存储单元而又还没有完成的存贮操作的地址和数据,这里的完成包括高速缓存就能够执行。
应用:性能提高技术
- 高级设计
- 基本编码原则
- 消除连续的函数调用
- 消除不必要的内存引用
- 低级优化
- 展开循环,降低开销,并且使得进一步的优化称为可能
- 通过使用例如多个累计变量和重新结合等技术,找到方法提高指令集并行
- 用功能性的风格重写条件操作,使得编译采用条件数据传送