9. 为速度优化
9.1. 识别代码中最关键部分
优化软件不只是摆弄正确的汇编指令。许多现代的应用程序在载入模块、资源文件、数据库、接口框架等方面,比程序实际进行的计算,要花多得多的时间。优化计算时间不会有用,如果程序把99.9%的时间花在计算之外的事情上。在开始优化前,找出时间最多花在哪里是重要的。有时,解决方案会是从C#变为C++、使用不同的用户接口框架、把文件输入与输出组织得不一样、缓存网络数据、避免动态内存分配等,而不是使用汇编语言。更多讨论,参考手册1《优化C++软件》。
使用汇编代码优化软件,仅对高度CPU密集程序,比如音频与图像处理、加密、排序、数据压缩以及复杂数学计算,才是有意义的。
在CPU密集软件程序中,你通常会发现超过99%的CPU时间花在最内层循环中。因此,识别出软件的最关键部分是必须的,如果你希望改进计算的速度。优化不那么关键部分的代码将不仅是浪费时间,它还使代码不那么清晰,不那么容易调试与维护。大多数编译器套装包括一个可以告诉你代码哪个部分是最关键的分析器。如果没有分析器,并且哪部分代码是最关键的不明显,那么在代码各处设置若干递增的计数器变量,查看哪个部分执行次数最多。使用第167页描述的方法来测量代码每部分花了多少时间。
研究代码最关键部分中使用的算法,看是否可以改进,是重要的。通过选择最优的算法,通常就可以获得比任何其他优化方法更快的速度。
9.2. 乱序执行
所有现代x86处理器可以乱序执行指令。考虑这个例子:
; Example 9.1a, Out-of-order execution
mov eax, [mem1]
imul eax, 6
mov [mem2], eax
mov ebx, [mem3]
add ebx, 2
mov [mem4], ebx
这段代码做两件彼此无关的事情:[mem1]乘以6,2加上[mem3]。如果[mem1]恰好不在缓存中,那么在从主存获取这个操作数时,CPU必须等待许多时钟周期。在这个时间,CPU将找别的事情来做。它不能执行第二条指令imul eax,6,因为它依赖第一条指令的输出。但第四条指令mov ebx, [mem3]与之前的指令无关,因此,在等待[mem1]时执行mov ebx, [mem3]与add ebx, 2是可能的。CPU有许多支持高效乱序执行的特性。当然,最重要的是,检测一条指令是否依赖之前指令输出的能力。另一个重要的特性是寄存器重命名。假定在例子9.1a中,对乘法与加法都使用相同的寄存器,因为没有更多的空闲寄存器:
; Example 9.1b, Out-of-order execution with register renaming
mov eax, [mem1]
imul eax, 6
mov [mem2], eax
mov eax, [mem3]
add eax, 2
mov [mem4], eax
例子9.1b将工作得像例子9.1a一样快,因为CPU能够对同一个逻辑寄存器eax使用不同的物理寄存器。这以一个非常优雅的方式工作。每次写eax时,CPU分配一个新的物理寄存器来保存eax的值。这意味着上面的代码,在CPU内部,被改变为对eax使用4个不同物理寄存器的代码。第一个寄存器用于从[mem1]载入值。第二个寄存器用于imul指令的输出。第三个寄存器用于从[mem3]载入值。而第四个寄存器用于add指令的输出。对同一个逻辑寄存器使用不同的物理寄存器,在例子9.1b中,能够让CPU使最后3条指令与前3条指令无关。这个机制要高效工作,CPU必须有许多物理寄存器。不同的微处理器的物理寄存器数是不同的,但通常你可以假定这个数量对相当数目的指令重排是足够的。
寄存器局部
某些CPU可以保持一个寄存器不同的部分独立,而其他CPU总是把一个寄存器视为整体。如果修改例子9.1b,使第二部分使用16位寄存器,那么我们有一个假依赖问题:
; Example 9.1c, False dependence of partial register
mov eax, [mem1] ; 32 bit memory operand
imul eax, 6
mov [mem2], eax
mov ax, [mem3] ; 16 bit memory operand
add ax, 2
mov [mem4], ax
这里,指令mov ax, [mem3]仅改变寄存器eax的低16位,而高16位保留从imul指令得到的值。Intel、AMD与VIA的某些CPU不能重命名一个寄存器局部。后果是,mov ax, [mem3]必须等imul指令完成,因为需要合并[mem3]的低16位与来自imul指令的高16位。
其他CPU能够将寄存器分解为各部分,以避免假依赖,但在这两部分必须再次接合起来的情形下,这有另一个劣势。例如,假定来自9.1c中的代码后跟PUSH EAX。在某些处理器上,为了把EAX的两部分结合起来,这条指令必须等待它们回收,代价是5 ~ 6个时钟周期。其他处理器为把在寄存器两部分结合起来,将产生一个额外的μop。
可以通过movzx eax, [mem3]替换mov ax, [mem3]来避免这些问题。这重置eax的高位,打破了对eax之前值的依赖。在64位模式中,写32位寄存器就足够了,因为这总是重置一个64位寄存器的高半部。因此,movzx eax, [mem3]与movzx rax, [mem3]做完全相同的事。这条指令的32位版本比64位版本短1字节。应该避免高8位寄存器AH,BH,CH,DH的任何使用,因为它会导致假依赖及更低效的代码。
对修改某些标记位、保持其他位不变的指令,标记寄存器会导致类似的问题。例如,INC与DEC指令不改变进位标记,但修改零与符号标记。
微操作
另一个重要的特性是将指令分解为微操作(缩写为μop或uop)。以下例子展示了这的好处:
; Example 9.2, Splitting instructions into uops
push eax
call SomeFunction
push eax指令做两件事。它从栈指针减去4,把eax保存到由栈指针指向的地址。现在假定eax是一个长的、耗时计算的结果。这延迟了push指令。Call指令依赖由这条push指令修改的栈指针的值。如果这些指令没有被分解为μop,那么call指令将不得不等待,直到push指令完成。但CPU将push eax指令分解为sub esp, 4,后跟mov [esp], eax。微操作sub esp, 4可以在eax就绪前执行,因此call指令将只等待sub esp, 4,不用等mov [esp], eax。
执行单元
在CPU可以同时做多件事时,乱序执行变得更高效。许多CPU可以同时进行两、三或四件事,如果事情彼此无关,且不使用CPU中相同的执行单元。大多数CPU有至少两个整数ALU(算术逻辑单元),因此,每时钟周期它们可以执行两个或更多整数加法。通常有一个浮点加法单元与一个浮点乘法单元,因此同时可以进行一次浮点加法与乘法。可能有多个内存读单元与一个内存写单元,因此同时进行内存读写是可能的。在许多处理器上,每时钟周期最大平均μop数是3或4个,因此,例如在相同时钟周期内进行一个整数操作、一个浮点操作与一个内存操作是可能的。最大算术操作数(内存读写以外的操作)限制为每时钟周期2或3个μop,依赖于CPU。
流水线化指令
典型地浮点操作需要多个时钟周期,但通常它们是流水线化的,比如在之前加法完成前,可以开始一个新的浮点加法。MMX与XMM指令使用浮点执行单元,在许多CPU上甚至是整数指令。哪些指令可以同时执行或流水线化的细节,以及每条指令需要多少时钟周期,都是CPU特定的。每种类型CPU的细节在手册3《Intel,AMD与VIA CPU微架构》及手册4《指令表》中描述。
总结
为了最大程度地利用乱序执行,你需要知道的最重要的事情是:
- 至少以下寄存器是可以重命名的:所有通用寄存器、栈指针、标记寄存器、浮点寄存器、MMX、XMM及YMM寄存器。某些CPU还可重命名段寄存器与浮点控制字。
- 写整个寄存器,而不是寄存器局部,以防止假依赖。
- INC与DEC指令在某些CPU是低效的,因为它们仅写标记寄存器的局部(不包括进位标记)。使用ADD与SUB来避免假依赖,或者标记寄存器的低效分解。
- 每条指令依赖之前的指令串不能乱序执行。避免长依赖链(参考第59页)。
- 内存操作数不能被重命名。
- 可以在前面对不同地址的内存写之前,执行内存读。应该尽早计算任何指针或索引寄存器,使CPU可以验证内存操作数的地址是不同的。
- 不能在前面写之前执行内存写,但写缓冲可以保存若干个挂起的写,通常是4个或更多。
- 在大多数处理器上,内存读可以前面另一个读之前或同时进行。
- 如果代码包含不同类别指令良好混合,比如:简单整数指令、浮点加法、乘法、内存读、内存写,CPU可以同时做更多的事。
9.3. 指令获取、解码与回收
指令获取可以是一个瓶颈。许多处理器每时钟周期不能获取超过16字节指令代码。如果这些限制被证明是关键的,使指令尽可能短可能是必要的。使指令更短的一个方法是以指针替换内存操作数(参考第10章,68页)。如果指令获取是一个瓶颈,内存操作数的地址可以在一个循环外载入指针寄存器。类似的,大常量可以载入寄存器。
在大多数处理器上,跳转会延迟指令获取。在关键代码中使跳转数最少,是重要的。没有被采用以及正确预测的分支不会延迟指令获取。因此,组织if-else分支,使最频繁被采用的分支是该条件跳转不被采用的分支,是有好处的。
大多数处理器以16字节或32字节对齐块获取指令。把关键循环入口及子例程入口对齐到16,尽量减少代码中16字节边界数,是有利的。或者,确保在一个关键循环入口或子例程入口的前几条指令中,没有16字节边界。
指令解码通常是一个瓶颈。支持最好解码的指令组织是特定于处理器的。Intel PM处理器要求4-1-1解码模式。这意味着产生2、3或4个μop的指令应该被两个单μop指令隔开。在Core2处理器上,最优解码模式是4-1-1-1。在AMD处理器上,最好避免产生超过2个μop的指令。
带有多个前缀的指令会减慢解码。指令可具有的、不减慢解码的最大前缀数,在32位Intel处理器上是1,在Intel P4E处理器上是2,在AMD处理器上是3,在Core2上是无限。避免地址大小前缀。在带有立即数的指令上避免操作数大小前缀。例如,最好用MOV EAX, 2替换MOV AX, 2。
在具有追踪缓存的处理器上,解码很少是一个瓶颈,但追踪缓存的最优使用有特定的要求。
大多数Intel处理器有称为寄存器读暂停的问题。如果代码有几个经常读、但很少写的寄存器,这会发生。
在大多数处理器上,指令回收会是一个瓶颈。AMD处理器以及Intel PM与P4处理器每时钟周期可以回收不超过3个μop。Core2处理器每时钟周期可以回收4个μop。每时钟周期可以回收不超过1个被采用的跳转。
所有这些细节都是处理器特定的。参考手册3《Intel,AMD与VIA CPU微架构》。
9.4. 指令时延与吞吐率
一条指令的时延是,从该指令开始执行到结果就绪所需的时钟周期数。它执行一条依赖链所需的时间是,链中所有指令时延之和。
一条指令的吞吐率是,每时钟周期可以执行的相同类型指令的最大数量,如果这些指令都是无关的。我更喜欢列吞吐率倒数,因为这比较时延与吞吐率更容易。吞吐率倒数是,从指令开始执行到同一类型另一条无关指令开始执行的平均时间,或者在一系列相同类型的无关指令中,每指令的时钟周期数。例如,在Core2处理器上,浮点加法有3时钟周期的时延与每指令1时钟的吞吐率倒数。这意味着每次加法,如果每个加法依赖前面加法的结果,处理器使用3时钟周期,但如果这些加法是无关的,每个加法仅1时钟周期。
手册4《指令表》包含了Intel、AMD与VIA许多相异处理器上几乎所有指令的时延与吞吐率的详尽列表。
下面列表展示了某些典型值。
指令 | 典型时延 | 典型吞吐率倒数 |
整数移动 | 1 | 0.33-0.5 |
整数加法 | 1 | 0.33-0.5 |
整数布尔 | 1 | 0.33-1 |
整数偏移 | 1 | 0.33-1 |
整数乘法 | 3-10 | 1-2 |
整数除法 | 20-80 | 20-40 |
浮点加法 | 3-6 | 1 |
浮点乘法 | 4-8 | 1-2 |
浮点除法 | 20-45 | 20-45 |
整数向量加法 (XMM) | 1-2 | 0.5-2 |
整数向量乘法 (XMM) | 3-7 | 1-2 |
浮点向量加法 (XMM) | 3-5 | 1-2 |
浮点向量乘法 (XMM) | 4-7 | 1-4 |
浮点向量除法 (XMM) | 20-60 | 20-60 |
内存读 (已缓存) | 3-4 | 0.5-1 |
内存写 (已缓存) | 3-4 | 1 |
跳转或调用 | 0 | 1-2 |
表9.1. 典型指令时延与吞吐率 |
9.5. 打破依赖链
为了利用乱序执行,必须避免长依赖链。考虑以下计算100个数之和的C++例子:
// Example 9.3a, Loop-carried dependency chain
double list[100], sum = 0.;
for (int i = 0; i < 100; i++) sum += list[i];
这个代码进行一百次加法,每个加法依赖前面的结果。这是一个循环携带依赖链。一个循环携带依赖链可以非常长,在很长时间里完全阻止乱序执行。只有i的计算可与浮点加法必须进行。
假定浮点加法时延为4,吞吐率倒数是1,最优的实现将有4个累加器,使得在浮点加法器的流水线中总是有4个加法。在C++,这看起来像:
// Example 9.3b, Multiple accumulators
double list[100], sum1 = 0., sum2 = 0., sum3 = 0., sum4 = 0.;
for (int i = 0; i < 100; i += 4) {
sum1 += list[i];
sum2 += list[i+1];
sum3 += list[i+2];
sum4 += list[i+3];
}
sum1 = (sum1 + sum2) + (sum3 + sum4);
这里,有4条并行运行的依赖链,每条依赖链是原来长度的四分之一。累加器的最优个数是指令的时延(这里是浮点加法),除吞吐率倒数。用于具有多个累加器的循环的汇编代码的例子,参考第59页(译注:即上面的例子)。
获得理论最大吞吐率是不可能的。并行依赖链越多,CPU越难最优地调度及重排μop。如果依赖链是分支的或陷入的(entangled),这特别困难。
某些微处理器每时钟周期可以执行4或5条指令,使用宏操作融合甚至更多。微处理器支持的指令级并行越高,避免长依赖链越重要。
依赖链不仅出现在循环中,还出现在线性代码里。这样的依赖链也可以打破、例如,y = a + b + c + d可以改变为y = (a + b) + (c + d),使这两个括号可以并行计算。
有时,有不同的可能方式,以不同时延实现相同的计算。例如,你可以在分支与条件移动间选择。分支有最短的时延,但条件移动避免了分支误预测的危险(参考第61页)。哪个实现是最优的,依赖于分支的可预测性以及依赖链的长度。
把一个寄存器置零的一个常见方式是XOR EAX, EAX或SUB EAX, EAX。某些处理器知道这些指令与该寄存器的之前值是无关的。因此,任何使用该寄存器新值的指令将无需等待XOR或SUB指令之前的值就绪。这些指令有助于打破不必要的依赖。下面的列表汇总了,在不同的处理器上,当源与目标相同时,哪些指令被视为打破依赖:
指令 | P3及更早 | P4 | PM | Core2 | AMD |
XOR | - | x | - | x | x |
SUB | - | x | - | x | x |
SBB | - | - | - | - | x |
CMP | - | - | - | - | - |
PXOR | - | x | - | x | x |
XORPS, XORPD | - | - | - | x | x |
PANDN | - | - | - | - | - |
PSUBxx | - | - | - | x | - |
PCMPxx | - | - | - | x | - |
表9.2. 在源与目标相同时,打破依赖的指令
不应该使用一个寄存器的8位或16位局部来打破依赖。例如,在某些处理器上,XOR AX, AX打破一个依赖,但不是所有。但XOR EAX, EAX足以打破在64位模式下对RAX的依赖。
SBB EAX, EAX显然依赖于进位标记,即使它不依赖于EAX。
你还可以使用这些指令来打破对标记的依赖。例如,在Intel处理器中,旋转指令对标记有一个假依赖。这可以通过以下方式消除:
; Example 9.4, Break dependence on flags
ror eax, 1
sub edx, edx ; Remove false dependence on the flags
ror ebx, 1
你不能使用CLC来打破进位标记上的依赖。
9.6. 跳转与调用
跳转、分支与调用不一定增加代码的执行时间,因为它们通常可与别的并行执行。不过,在关键代码中,因为以下原因,跳转等的数量应该保持尽量少:
- 在无条件跳转或被采用条件跳转后,依赖于微处理器,代码的获取通常被推迟1 ~ 3个时钟周期。如果跳转目标在一个16字节或32字节获取块结尾附近,延迟最大(即在一个可16整除地址前)。
- 当在非连续子例程间跳转时,代码缓存变得碎片化且更低效。
- 在代码包含许多跳转时,带有μop缓存或追踪缓存的微处理器可能在缓存中保存相同代码的多个实例。
- 分支目标缓冲(BTB)仅可以保存有限数量的跳转目标地址。一个BTB不命中的代价是许多时钟周期。
- 条件跳转根据先进的预测机制预测。误预测代价是高的,就像下面解释那样。
- 在大多数处理器上,在全局分支模式历史表及分支历史寄存器中,分支会彼此干扰。因此,一个分支会降低其他分支的预测率。
- 返回通过返回栈缓冲来预测,返回栈缓冲仅能保存若干返回地址,通常8或更多。
- 在较旧的处理器上,间接跳转与间接调用预测不良。
所有现代CPU有一个包含指令获取、解码、寄存器重命名、μop重排与调度、执行、回收等步骤的执行流水线。依赖于特定的微架构,流水线的步骤数从12到22。当一条分支指令输入到流水线时,CPU不确定知道下一个获取到流水线的指令是哪条。在这个分支指令被执行,确知该分支的方向之前,还需要12 ~ 22时钟周期。这个不确定性可能会打断流水线中的指令流。不是等待12或更多时钟周期来得到答案,CPU尝试猜分支会去哪个方向。这个猜测基于该分支之前的行为。如果最后几次,该分支去往相同的方向,那么预测它这次也将去这个方向。如果该分支有规律地交替去往两个方向,那么预测它将继续交替。
如果预测是正确的,那么通过将正确的分支载入流水线,开始解码、推测执行该分支中的指令,CPU节省了大量的时间。如果预测错误,那么在几个时钟周期后,发现这个错误,通过冲刷流水线、丢弃推测执行的结果,修正这个错误。分支误预测的代价从12到超过50个时钟周期,依赖流水线的长度以及微架构的其他细节。这个代价是如此高,已经实现了非常先进的算法来改进分支预测。这些算法在手册3《Intel,AMD与VIA CPU微架构》中解释。
通常,你可以假定在这些情形里,大部分时间分支被正确预测:
- 如果分支总是去往相同的方向。
- 如果分支遵循一个简单的重复模式,且在一个有很少或没有其他分支的循环里。
- 如果分支与前面的分支相关。
- 如果分支是一个具有小常量重复计数的循环,且在循环中很少或没有条件跳转。
最坏的情形是,在大约50%时间里去往其中一个方向的分支,不遵循任何规则的模式,且与前面任何分支都不相关。这样一个分支将在50%时间误预测。这个代价如此高,如果可能,该分支应该为条件移动或查找表所替换。
一般来说,你应该尝试保持预测不良的分支数,以及循环内分支数尽量少。如果可以减少循环内分支数,分解或展开一个循环可能是有利的。
间接跳转与间接调用通常预测不良。较旧的处理器将只是预测间接跳转或调用去往上次的方向。许多较新的处理器能够识别间接跳转简单的重复模式。
返回通过所谓的返回栈缓冲来预测,这是一个栈上返回地址镜像的先进后出缓冲。具有16项的返回栈缓冲可以正确预测嵌套深度最多16的子例程的所有返回。如果子例程的嵌套深度超过返回栈缓冲的大小,那么将在较外层嵌套级看到失败,而不是猜测的更关键的较内层嵌套级。因此,大小为8的返回栈缓冲在大多数情形里都足够了,除了深度嵌套的递归函数。
如果有一个调用没有一个匹配的返回,或一个返回没有前面的调用,返回栈缓冲将失败。因此,总是匹配调用与返回是重要的。不要使用RET指令以外的其他手段跳出一个子例程。也不要把RET指令用作一个间接跳转。远程调用应该与远程返回匹配。
使条件跳转最少被采用
在大多数处理器上,不采用分支的效率与吞吐率要好于采用的分支。因此,先放置最频繁的分支是有好处的:R2nLQzefR
; Example 9.5, Place most frequent branch first
Func1 PROC NEAR
cmp eax,567
je L1
; frequent branch
ret
L1: ; rare branch
ret
消除调用
通过一个跳转,替换后跟一个返回的调用:
; Example 9.6a, call/ret sequence (32-bit)
Func1 PROC NEAR
...
call Func2
ret
Func1 ENDP
这可以改变为:
; Example 9.6b, call+ret replaced by jmp
Func1 PROC NEAR
...
jmp Func2
Func1 ENDP
这个修改不会与返回栈缓冲机制冲突,因为对Func1的调用匹配从Func2的返回。在栈对齐的系统中,在跳转前恢复栈指针是必须的:
; Example 9.7a, call/ret sequence (64-bit Windows or Linux)
Func1 PROC NEAR
sub rsp, 8 ; Align stack by 16
...
call Func2 ; This call can be eliminated
add rsp, 8
ret
Func1 ENDP
这可以改变为:
; Example 9.7b, call+ret replaced by jmp with stack aligned
Func1 PROC NEAR
sub rsp, 8
...
add rsp, 8 ; Restore stack pointer before jump
jmp Func2
Func1 ENDP
消除无条件跳转
通常,通过拷贝跳转到的代码来消除跳转是可能的。拷贝的代码通常可以是一个循环epilog或函数epilog。以下例子是一个在循环内有一个if-else分支的函数:
; Example 9.8a, Function with jump that can be eliminated
FuncA PROC NEAR
push ebp
mov ebp, esp
sub esp, StackSpaceNeeded
lea edx, EndOfSomeArray
xor eax, eax
Loop1: ; Loop starts here
cmp [edx+eax*4], eax ; if-else
je ElseBranch
... ; First branch
jmp End_If
ElseBranch:
... ; Second branch
End_If:
add eax, 1 ; Loop epilog
jnz Loop1
mov esp, ebp ; Function epilog
pop ebp
ret
FuncA ENDP
可以通过复制循环epilog,消除到End_If的跳转:
; Example 9.8b, Loop epilog copied to eliminate jump
FuncA PROC NEAR
push ebp
mov ebp, esp
sub esp, StackSpaceNeeded
lea edx, EndOfSomeArray
xor eax, eax
Loop1: ; Loop starts here
cmp [edx+eax*4], eax ; if-else
je ElseBranch
... ; First branch
add eax, 1 ; Loop epilog for first branch
jnz Loop1
jmp AfterLoop
ElseBranch:
... ; Second branch
add eax, 1 ; Loop epilog for second branch
jnz Loop1
AfterLoop:
mov esp, ebp ; Function epilog
pop ebp
ret
FuncA ENDP
在例子9.8b中,通过制作循环epilog的两个拷贝,消除循环内无条件跳转。最频繁执行的分析应该首先出现,因为第一个分支是最快的。到AfterLoop的无条件跳转也可以消除。这通过拷贝函数epilog来完成:
; Example 9.8b, Function epilog copied to eliminate jump
FuncA PROC NEAR
push ebp
mov ebp, esp
sub esp, StackSpaceNeeded
lea edx, EndOfSomeArray
xor eax, eax
Loop1: ; Loop starts here
cmp [edx+eax*4], eax ; if-else
je ElseBranch
... ; First branch
add eax, 1 ; Loop epilog for first branch
jnz Loop1
mov esp, ebp ; Function epilog 1
pop ebp
ret
ElseBranch:
... ; Second branch
add eax, 1 ; Loop epilog for second branch
jnz Loop1
mov esp, ebp ; Function epilog 2
pop ebp
ret
FuncA ENDP
消除到AfterLoop的跳转所获得的收益,小于消除到End_If跳转所得到的收益,因为它在循环外部。但在这里我展示它是为了显示复制函数epilog的一般性方法。
以条件移动替换条件跳转
大多数要消除的重要跳转是条件跳转,特别是如果对它们的预测不好。例如:
// Example 9.9a. C++ branch to optimize
a = b > c ? d : e;
这可以条件跳转或条件移动实现:
; Example 9.9b. Branch implemented with conditional jump
mov eax, [b]
cmp eax, [c]
jng L1
mov eax, [d]
jmp L2
L1: mov eax, [e]
L2: mov [a], eax
; Example 9.9c. Branch implemented with conditional move
mov eax, [b]
cmp eax, [c]
mov eax, [d]
cmovng eax, [e]
mov [a], eax
条件移动的好处是,它避免了分支误预测。但它有增加依赖链长度的坏处,而预测分支则打破这个依赖链。如果来自9.9c例子中的代码是一条依赖链的部分,那么mov指令增加了这个链的长度。在Intel处理器上,cmov的时延是2时钟周期,在AMD处理器上是1时钟周期。如果以例子9.9b那样的条件跳转来实现相同的代码,且如果分支被正确预测,那么结果无需等待b与c就绪。它仅需要等d或e,取决于选择谁。这意味着依赖链被预测分支打破,而以条件移动实现的代码必须等待b,c,d与e都可用。如果d与e是复杂表达式,在使用条件移动时,两者都要计算,而如果使用条件跳转,仅其中一个需要计算。
根据经验,如果代码是一条依赖链的部分,且预测准确率超过75%,我们可以说条件跳转比条件移动快。如果在选择其他操作数时可以避免d或e长时间的计算,条件跳转也是优先选择的。
循环携带依赖链对条件移动的缺点特别敏感。例如,第111页的例子12.16a的代码,使用循环内分支比条件移动更高效,即使分支预测得不好。这是因为浮点条件移动增加了循环携带依赖链的长度,且因为条件移动的实现必须计算所有的power*xp值,即使在不使用它们时。
循环携带依赖链的另一个例子是排序列表里的一个二分查找。如果要查找的对象在整个列表中随机分布,那么分支预测率将接近50%,使用条件移动将更快。但如果这些对象通常彼此靠近,使预测率更高,那么使用条件跳转比条件移动更高效,因为每次实现正确分支预测时,依赖链被打破。
在向量寄存器中,在每元素的基础上,进行条件移动也是可能的。细节参考第120页。有特殊的向量指令来获取两个数的最小或最大值。查找最小或最大值,使用向量寄存器可能比整数或浮点寄存器更快。
以条件设置指令替换条件跳转
如果一个条件跳转用于将一个布尔变量设置为0或1,使用条件设置指令通常更高效。例如:
// Example 9.10a. Set a bool variable on some condition
int b, c;
bool a = b > c;
; Example 9.10b. Implementation with conditional set
mov eax, [b]
cmp eax, [c]
setg al
mov [a], al
条件设置指令仅写8位寄存器。如果需要一个32位结果,在比较前将寄存器余下部分置零:
; Example 9.10c. Implementation with conditional set, 32 bits
mov eax, [b]
xor ebx, ebx ; zero register before cmp to avoid changing flags
cmp eax, [c]
setg bl
mov [a], ebx
如果没有空闲寄存器,使用movzx:
; Example 9.10d. Implementation with conditional set, 32 bits
mov eax, [b]
cmp eax, [c]
setg al
movzx eax, al
mov [a], eax
如果true需要一个全1值,使用neg eax。
如果预测率好且代码是一条长依赖链的部分,条件跳转的实现比条件设置要快,如前一节解释的那样(第65页)。
以比特操作指令替换条件跳转
有时通过机灵地操作比特与标记,获得与分支相同的效果是可能的。对比特操作技巧,进位标记特别有用:
; Example 9.11, Set carry flag if eax is zero:
cmp eax, 1
; Example 9.12, Set carry flag if eax is not zero:
neg eax
; Example 9.13, Increment eax if carry flag is set:
adc eax, 0
; Example 9.14, Copy carry flag to all bits of eax:
sbb eax, eax
; Example 9.15, Copy bits one by one from carry into a bit vector:
rcl eax, 1
无需分支,计算一个有符号整数的绝对值是可能的:
; Example 9.16, Calculate absolute value of eax
cdq ; Copy sign bit of eax to all bits of edx
xor eax, edx ; Invert all bits if negative
sub eax, edx ; Add 1 if negative
下面的例子找出两个无符号数的最小值:if (b > a) b = a;
; Example 9.17a, Find minimum of eax and ebx (unsigned):
sub eax, ebx ; = a-b
sbb edx, edx ; = (b > a) ? 0xFFFFFFFF : 0
and edx, eax ; = (b > a) ? a-b : 0
add ebx, edx ; Result is in ebx
或者,对无符号数,忽略溢出:
; Example 9.17b, Find minimum of eax and ebx (signed):
sub eax, ebx ; Will not work if overflow here
cdq ; = (b > a) ? 0xFFFFFFFF : 0
and edx, eax ; = (b > a) ? a-b : 0
add ebx, edx ; Result is in ebx
下一个例子在两个数之间选择:if (a < 0) d = b; else d = c;
; Example 9.18a, Choose between two numbers
test eax, eax
mov edx, ecx
cmovs edx, ebx ; = (a < 0) ? b : c
在Intel处理器上,条件移动不是特别高效,且在较旧的处理器上不可用。在某些情形里,替代实现可能更快。下面的例子给出了与例子9.18a相同的结果。
; Example 9.18b, Choose between two numbers without conditional move:
cdq ; = (a < 0) ? 0xFFFFFFFF : 0
xor ebx, ecx ; b ^ c = bits that differ between b and c
and edx, ebx ; = (a < 0) ? (b ^ c) : 0
xor edx, ecx ; = (a < 0) ? b : c
在条件移动低效的处理器上,例子9.18b可能比9.18a块。例子9.18b破坏ebx的值。
这些技巧是否比条件跳转块,依赖于预测率,如上面解释。