1—些限制因素
由于关键路径指明了执行该程序所需时间的 一个基本的下界。也就是说,如果程序中有某条数据相关链,这条链上的所有延迟之和等于T,那么这个程序至少需要T个周期才能执行完。
而且功能单元的吞吐量界限也是程序执行时间的一个下界,下面介绍其他一些制约程序在实际机器上性能的因素。
1.1寄存器溢出
循环并行性的好处受汇编代码描述计算的能力限制。如果我们的并行度p超过了可用的寄存器数量,那么编译器会诉诸溢出(spillmg),将某些临时值存放到内存中,通常是在运行时堆栈上分配空间。
将combine6的多累积变量模式扩展到K=10和k = 20, 其结果的比较如下表所示:
可以看到对这种循环展开程度的增加没有改善CPE,有些甚至还变差了。
现代 X86-64 处理器有 16个寄存器,并可以使用16个 YMM寄存器来保存浮点数。一旦循环变量的数量超过了可用寄存器的数量,程序就必须在桟上分配一些变量。
1.2分支预测和预测错误处罚
现代处理器的工作远超前于当前正在执行的指令,从内存读新指令,译码指令,以确定在什么操作数上执行什么操作。只要指令遵循的是一种简单的顺序,那么这种指令流水线化就能很好地工作。
在一使用投机执行(speculative execution)的处理器中,处理器会开始执行预测的分支目标处的指令。它会避免修改任何实际的寄存器或内存位置,直到确定了实际的结果。如果预测错误会引起预测错误处罚。
C语言程序员怎么能够保证分支预测处罚不会阻碍程序的效率呢?对于这个问题没有简单的答案,但 是下面的通用原则是可用的。
1. 不要过分关心可预测的分支
可以看到错误的分支预测的影响可能非常大,但是这并不意味着所有的程序分支都会减缓程序的执行。实际上,现代处理器中的分支预测逻辑非常善于辨别不同的分支指令的有规律的模式和长期的趋势。
例如,在合并函数中结束循环的分支通常会被预测为选择分支,因此只在最后一次会导致预测错误处罚。
2. 书写适合用条件传送实现的代码
分支预测只对有规律的模式可行。程序中的许多测试是完全不可预测的,依赖于数据的任意特性,例如一个数是负数还是正数。对于这些测试,分支预测逻辑会处理得很糟糕。
对于本质上无法预测的情况,如果编译器能够产生使用条件数据传送而不是使用条件控制转移的代码,可以极大地提高程序的性能。
2.理解内存性能
所有的现代处理器都包含一个或多个高速缓存(cache)存储器,以对这样少量的存储器提供快速的访问。下面介绍加载(从内存读到寄存器)和存储(从 寄存器写到内存)操作的程序的性能,只考虑所有的数据都存放在高速缓存中的情况。
2.1加载的性能
一个包含加载操作的程序的性能既依赖于流水线的能力,也依赖于加载单元的延迟。
对于每个被计算的元素都有一个从内存被加载的过程。
第3行上的movq指令是这个循环中关键的瓶颈。后面寄存器%rdi 的每个值都依赖于加载操作的结果,而加载操作又以%rdi中的值作为它的地址。因此,直到前一次迭代的加载操作完成,下一次迭代的加载操作才能开始。这个函数的CPE等于4.00, 是由加载 操作的延迟决定的。
2.2 存储的性能
存储(store)操作,它将一个寄存器值写到内存。这个操作的性能,尤其是与加载操作的相互关系,包括一些很细微的问题。
与加载操作一样,在大多数情况中,存储操作能够在完全流水线化的模式中工作,每个周期开始一条新的存储。
由于存储操作并不影响任何寄存器值。因此,就其本性来说,一系列存储操作不会产生数据相关。只有加载操作会受存储操作结果 的影响,因为只有加载操作能从由存储操作写的那个位置读回值。
上图中所示的函数 write _read 说明了加载和存储操作之间可能的相互影响。
如图所示。存储单元包含一个存储缓冲区, 它包含已经被发射到存储单元而又还没有完成的存储操作的地址和数据,这里的完成包括更新数据高速缓存。
提供这样一个缓冲区,使得一系列存储操作不必等待每个操作都更新高速缓存就能够执行。当一个加载操作发生时,它必须检查存储缓冲区中的条目,看有没有地址相匹配。
内存操作的实现是包括许多细微之处。
对于寄存器操作,在指令被译码成操作的时候,处理器就可以确定哪些指令会影响其他哪些指令。另一方面,对于内存操作,只有到计算出加载和存储的地址被计算出来以后,处理器才能确定哪些指令会影响 其他的哪些。高效地处理内存操作对许多程序的性能来说至关重要。