3.4. 优化前端
优化前端包括两个方面:
· 维持对执行引擎稳定的微操作供应——误预测分支会干扰微操作流,或导致执行引擎在非构建代码路径(non-architectedcode path)中的微操作流上浪费执行资源。大多数这方面的调整集中在分支预测单元。常见的技术在3.4.1节“分支预测优化”中讨论。
· 供应微操作流尽可能利用执行带宽与回收带宽——对于IntelCore微架构与IntelCore Due处理器家族,这方面集中在维持高的解码吞吐率。在Intel微架构SandyBridge中,这方面集中在从已解码ICache保持热代码(hodcode?)运行。最大化IntelCore微架构解码吞吐率的技术在3.4.2节“取指与解码优化”中讨论。
3.4.1.分支预测优化
分支优化对性能有重要的影响。通过理解分支流并改进它们的可预测性,你可以显著提升代码的深度。
有助于分支预测的优化有:
· 将代码与数据保持在不同的页面。这非常重要;更多信息参考3.6节“优化内存访问”。
· 尽可能消除分支。
· 将代码安排得与静态分支预测算法一致。
· 在自旋等待循环中使用PAUSE指令。
· 内联函数,并使调用与返回成对。
· 在需要时循环展开,使重复执行的循环的迭代次数不多于16(除非这会导致代码的大小过度增长)。
· 避免在一个循环里放置两条条件分支指令,使得两者都有相同的目标地址,并且同时属于(即包含它们最后字节地址在内)同一个16字节对齐的代码块。
3.4.1.1. 消除分支
消除分支提高了性能,因为:
· 它减少了误预测的可能性。
· 它减少了所要求的分支目标缓冲(branch target buffer,BTB)项。永远不会被采用的条件分支不消耗BTB资源。
消除分支有4个主要的方法:
· 安排代码使得基本块连续。
· 展开循环,就像3.4.1.7节“循环展开”讨论的那样。
· 使用CMOV指令。
· 使用SETCC指令。
以下规则适用于分支消除:
Assembly/Compiler编程规则1.(影响MH,普遍性M)安排代码使基本块连续,并消除不必要的分支。
Assembly/Compiler编程规则2.(影响M,普遍性ML)使用SETCC及CMOV指令来尽可能消除不可预测条件分支。对可预测分支不要这样做。不要使用这些指令消除所有的不可预测条件分支(因为归咎于要求执行1个条件分支两条路径的要求,使用这些指令将导致执行开销)。另外,将一个条件分支转换为SETCC或CMOV是以控制流依赖交互数据依赖,限制了乱序引擎的能力。在调整时,注意到所有的Intel64与IA-32处理器通常具有非常高的分支预测率。一贯被误预测的分支通常很少见。仅当增加的计算时间比一个误预测分支的预期代价要少,才使用这些指令。
考虑一行条件依赖于其中一个常量的C代码:
X = (A < B) ? CONST1: CONST2;
代码有条件地比较两个值,A和B。如果条件成立,设置X为CONST1;否则设置为CONST2。一个等效于上面C代码的汇编代码序列可以包含不可预测分支,如果在这两个值中没有相关性。
例子3-1显示了带有不可预测分支的汇编代码。不可预测分支可以通过使用SETCC指令移除。例子3-2显示了没有分支的优化代码。
例子3-1. 带有一个不可预测分支的汇编代码
cmp a, b ; Condition jbe L30 ; Conditional branch mov ebx const1 ; ebx holds X jmp L31 ; Unconditional branch L30: mov ebx, const2 L31: |
例子3-2. 消除分支的优化代码
xor ebx, ebx ; Clear ebx (X in the C code) cmp A, B setge bl ; When ebx = 0 or 1 ; OR the complement condition sub ebx, 1 ; ebx=11...11 or 00...00 and ebx, CONST3 ; CONST3 = CONST1-CONST2 add ebx, CONST2 ; ebx=CONST1 or CONST2 |
例子3-2中的优化代码将EBX设为0,然后比较A和
B。如果A不小于B,EBX被设为1。然后递减EBX,并与常量值的差进行AND。这将EBX设置为0或值的差。通过将CONST2加回EBX,正确的值写入EBX。在CONST2等于0时,可以删除最后一条指令。
删除分支的另一个方法是使用CMOV及FCMOV指令。例子3-3显示了如何使用CMOV修改一个TEST及分支指令序列,消除一个分支。如果这个TEST设置了相等标记,EBX中的值将被移到EAX。这个分支是数据依赖的,是一个代表性不可预测分支。
例子3-3. 使用CMOV指令消除分支
test ecx, ecx jne 1H mov eax, ebx
1H: ; To optimize code, combine jne and mov into one cmovcc instruction that checks the equal flag test ecx, ecx ; Test the flags cmoveq eax, ebx ; If the equal flag is set, move ; ebx to eax- the 1H: tag no longer needed |
3.4.1.2. 旋等待与循环
Pentium 4处理器引入了一条新的PAUSE指令;在Intel64与IA-32处理器实现中,在架构上,这个指令是一个NOP。
对于Pentium4与后续处理器,这条指令作为代码序列是一个自旋等待循环的一个提示。在这样的循环里没有一条PAUSE指令,在退出这个循环时,Pentium4处理器可能遭遇严重的性能损失,因为处理器可能检测到一个可能的内存次序违例。插入PAUSE指令显著降低了一个内存次序违例的可能性,结果提升了性能。
在例子3-4中,代码自旋直到内存位置A匹配保存在寄存器EAX的值。在保护一个临界区时,在生产者-消费者序列中,这样的代码序列通常用于屏障(barrier)或其他同步。
例子3-4. PAUSE指令的使用
lock: cmp eax, a jne loop ; Code in critical section: loop: pause cmp eax, a jne loop jmp lock |
3.4.1.3. 静态预测
在BTB中没有历史的分支(参考3.4.1节“分支预测优化”)使用一个静态预测算法来预测:
· 预测无条件分支将被采用。
· 预测间接分支将不被采用。
下面规则适用于静态消除:
Assembly/Compiler编程规则3.(影响M,普遍性H)安排代码与静态分支预测算法一致:使紧跟一个条件分支的代码是一个带有前向目标分支可能的目标,或者是一个带有后向目标分支不大可能的目标。
例子3-5展示了静态分支预测算法。一个IF-THEN条件的主体被预测。
例子3-5. 静态分支预测算法
//Forward condition branches not taken (fall through) IF<condition> {.... ↓ }
IF<condition> {... ↓ }
//Backward conditional branches are taken LOOP {... ↑ −− }<condition>
//Unconditional branches taken JMP ------> |
例子3-6与例子3-7提供了用于一个静态预测算法的基本规则。在例子3-6中,后向分支(JCBEGIN)第一次通过时不在BTB里;因此,BTB不会发布一个预测。不过,静态预测器将预测该分支将被采用,因此一个误预测将不会发生。
例子3-6. 静态的采用预测
Begin: mov eax, mem32 and eax, ebx imul eax, edx shld eax, 7 jc Begin |
在例子3-7中第一个分支指令(JCBEGIN)是一个条件前向分支。在第一次通过时它不在BTB里,但静态预测器将预测这个分支将落空(fallthrough)。静态预测算法正确地预测CALLCONVERT指令将被采用,即使在该分支在BTB中拥有任何分支历史之前。
例子3-7. 静态的不采用预测
mov eax, mem32 and eax, ebx imul eax, edx shld eax, 7 jc Begin mov eax, 0 Begin: call Convert |
Intel Core微架构不使用静态预测启发式。不过,为了维持Intel64与IA-32处理器间的一致性,软件应该缺省维护这个静态预测启发式。
3.4.1.4. 内联,调用与返回
返回地址栈机制(returnstack mechanism)扩充了静态与动态预测器来特别地优化调用与返回。它有16项,足以覆盖大多数程序的调用深度。如果存在一条一连串超过16个嵌套调用与返回的链,性能可能会恶化
在IntelNetBurst微架构中追踪缓存(tracecache)为调用与返回维护分支预测信息。只要调用或返回的追踪维持在追踪缓存里,调用与返回目标维持不变,上面描述的返回地址栈的深度限制将不会妨碍性能。
要启用返回栈机制,调用与返回必须成对匹配。如果这完成了,以一个影响性能的方式超出栈深度的可能性非常低。
下面规则适用于内联,调用及返回:
Assembly/Compiler编程规则4.(影响MH,普遍性MH)近程调用(near call)必须与近程返回匹配,而远程调用(farcall)必须与远程返回匹配。将返回地址压栈并跳转到被调用的例程是不建议的,因为它创建了调用与返回的一个失配。
调用与返回是高代价的;出于以下原因使用内联:
· 参数传递开销可以被消除。
· 在一个编译器里,内联一个函数揭示了更多的优化机会。
· 如果被内联的例程包含分支,调用者额外的上下文可以提高例程内分支预测。
· 在一个小函数里,如果该函数被内联了,一个误预测分支导致的性能损失更小。
Assembly/Compiler编程规则5.(影响MH,普遍性MH)选择性内联一个函数,如果这样做会降低代码大小,或者如果这是小函数且该调用点经常被执行。
Assembly/Compiler编程规则6.(影响H,普遍性H)不要内联一个函数,如果这样做会使工作集大小超出可以放入追踪缓存的程度。
Assembly/Compiler编程规则7.(影响ML,普遍性ML)如果存在一连串超过16个嵌套调用与返回;考虑使用内联改变程序以减少调用深度。
Assembly/Compiler编程规则8.(影响ML,普遍性ML)倾向内联包含低预测率分支的小函数。如果一个分支误预测导致一个RETURN被过早预测为被采用,可能会导致性能损失。
Assembly/Compiler编程规则9.(影响L,普遍性L)如果一个函数最后的语句是对另一个函数的调用,考虑将该调用转换为一个跳转。这将节省该调用/返回开销,连同返回栈缓冲的一项。
Assembly/Compiler编程规则10.(影响M,普遍性L)不要在一个16字节块里放4个以上分支。
Assembly/Compiler编程规则11.(影响M,普遍性L)不要在一个16字节块里放2个以上循环结束分支。
3.4.1.5. 代码对齐
小心安排代码可以提高缓存与内存的局部性。很可能基本块序列在内存里连续放置。这可能涉及从该序列移除不可能的代码,比如处理错误条件的代码。参考3.7节“预取”,关于指令预取器的优化。
Assembly/Compiler编程规则12.(影响M,普遍性H)所有的分支目标应该16字节对齐。
Assembly/Compiler编程规则13.(影响M,普遍性H)如果一块条件代码不太可能执行,它应该被放在程序的另一个部分。如果它非常可能不执行,而且代码局部性是个问题,它应该被放在不同的代码页。
3.4.1.6. 分支类型选择
间接分支与调用的缺省预测目标是落空(fallthrough)路径。当该分支有可用的硬件预测时,落空预测会被覆盖。对一个间接分支,分支预测硬件的预测分支目标是之前执行的分支目标。
归咎于不良的代码局部性或病态的分支冲突问题,如果没有分支预测可用,落空路径的缺省预测才是一个大问题。对于间接调用,预测落空路径通常不是问题,因为,执行将可能回到相关返回之后的指令。
在间接分支后立即放置数据会导致性能问题。如果该数据是全0,它看起来像一长串对内存目的地址的ADD,这会导致资源冲突并减慢分支恢复。类似,紧跟间接分支的数据对分支预测硬件看起来可能像分支,会造成跳转执行其他数据页。这会导致自修改代码的问题。
Assembly/Compiler编程规则14.(影响M,普遍性L)在出现间接分支时,尝试使一个间接分支最有可能的目标紧跟在该间接分支后。或者,如果间接分支是普遍的,但它们不能由分支预测硬件预测,以一条UD2指令根在间接分支后,它将阻止处理器顺着落空路径解码。
从代码构造(比如switch语句,计算的GOTO(computedGOTO)或通过指针的调用)导致的间接分支可以跳转到任意数目的位置。如果代码序列是这样的,大多数时间一个分支的目标是相同的地址,那么BTB在大多数时间将预测准确。因为仅被采用(非落空)目标会保存在BTB里,带有多个被采用目标的间接分支会有更低的预测率。
通过引入额外的条件分支,所保存目标的实际数目可以被增加。向一个目标添加一个条件分支是有成效的,如果:
· 分支目标(direction)与导致该分支的分支历史有关;也就是说,不仅是最后的目标,还有如何来到这个分支。
· 源/目标对足够常见,值得使用额外的分支预测能力。这可能增加总体的分支误预测数,同时改进间接分支的误预测。如果误预测分支数目非常大,收益率下降。
User/Source编程规则1.(影响M,普遍性L)如果一个间接分支具有2个以上经常采用的目标,并且其中至少一个是与分支历史有关,那么将该间接分支转换为一棵树,其中在一个或多个间接分支之前是这些目标的条件分支。对一个与分支历史有关的间接分支的常用目标应用这个“剥离”。
这个规则的目的是通过提升分支的可预测性(即使以添加更多分支为代价)来减少误预测的总数。添加的分支必须是可预测的。这样可预测性的一个原因是与前面分支历史的强关联。也就是说,前面分支采取的目标是考虑中分支目标的一个良好指示。
例子3-8显示了一个间接分支目标与前面的一个条件分支目标相关的简单例子。
例子3-8. 带有两个倾向目标的间接分支
function () { int n = rand(); // random integer 0 to RAND_MAX if ( ! (n & 0x01) ) { // n will be 0 half the times n = 0; // updates branch history to predict taken } // indirect branches with multiple taken targets // may have lower prediction rates
switch (n) { case 0: handle_0(); break; // common target, correlated with // branch history that is forward taken case 1: handle_1(); break; // uncommon case 3: handle_3(); break; // uncommon default: handle_other(); // common target } } |
|
很难通过分析确定相关,对于一个编译器及一个汇编语言程序员。评估剥离以及不剥离时的性能,从一个编程工作得到最好的性能,可能是富有成效的。
以相关分支历史剥离一个间接分支的最受青睐目标的一个例子显示在例子3-9中。
例子3-9. 降低间接分支误预测的一个剥离技术
function () { int n = rand(); // Random integer 0 to RAND_MAX if( ! (n & 0x01) ) THEN n = 0; // n will be 0 half the times if (!n) THEN handle_0(); // Peel out the most common target // with correlated branch history { switch (n) { case 1: handle_1(); break; // Uncommon case 3: handle_3(); break; // Uncommon default: handle_other(); // Make the favored target in // the fall-through path } } } |
3.4.1.7. 循环展开
展开循环的好处有:
· 展开分摊了分支的开销,因为它消除了分支以及一部分管理归纳变量(inductionvariable)的代码。
· 展开允许进取地调度(或流水线化)该循环以隐藏时延。随着依赖链延伸展露出关键路径,如果你有足够的空闲寄存器来保存变量的生命期,这是有用的。
· 展开向其他优化展露出代码,比如移除重复的读,公共子表达式消除等。
循环展开的潜在代价是:
· 过度展开或展开非常大的循环会导致代码尺寸增加。如果展开后循环不再能够放入追踪缓存(TC),这是有害的。
· 展开包含分支的循环增加了对BTB容量的需求。如果展开后循环的迭代次数是16或更少,分支预测器应该能够正确地预测在方向更迭(alternatedirection)的循环体中的分支。
Assembly/Compiler编程规则15.(影响H,普遍性M)展开小的循环,直到分支与归纳变量的开销占据(通常)不到10%的该循环执行时间。
Assembly/Compiler编程规则16.(影响H,普遍性M)避免过度展开循环;这可能冲击(trash)追踪缓存或指令缓存。
Assembly/Compiler编程规则17.(影响M,普遍性M)展开经常执行且有可预测迭代次数的循环,将迭代次数降到16以下,包括16。除非它增加了代码大小,使得工作集不再能放入追踪或指令缓存。如果循环体包含多个条件分支,那么展开使得迭代次数是16/(条件分支数)。
例子3-10显示了展开如何使得其他优化发生。
例子3-10. 循环展开
展开前: do i = 1, 100 if ( i mod 2 == 0 ) then a( i ) = x else a( i ) = y enddo 展开后: do i = 1, 100, 2 a( i ) = y a( i+1 ) = x enddo |
在这个例子中,循环执行100次将X赋给每个偶数元素,将Y赋给每个奇数元素。通过展开循环,你可以更高效地进行赋值,在循环体内移除了一个分支。
3.4.1.8. 分支预测的编译器支持
编译器生成代码提升在Intel处理器中分支预测的效率。IntelC++编译器这样来达成它:
· 将代码与数据保持在不同的页。
· 使用条件移动指令来消除分支。
· 生成与静态分支预测算法一致的代码。
· 在合适的地方进行内联。
· 如果迭代次数可预测,展开循环。
使用反馈式(profile-guided)优化,编译器可以布置基本块,消除一个函数最频繁执行路径上的分支,或至少提高它们的可预测性。在源代码级别,无需担心分支预测。更多信息,参考IntelC++编译器文档。