Intel64及IA-32架构优化指南——3.4优化执行引擎前端
3.4优化执行引擎前端
优化前端包括了以下两个方面:
● 维持对执行引擎微操作的稳定供给——分支预测失败会扰乱微操作流,或导致执行引擎在非构筑代码路径中的微操作执行流上浪费执行资源。在这个细节上的代码调整多数要关注分支预测单元的工作机制。通用技术在3.4.1小节“分支预测优化”中描述。
● 提供微操作流来尽可能多地利用执行带宽和隐退带宽——对于Intel Core微架构以及Intel Core双核家族,这方面关注维持高译码吞吐。在代号为Sandy Bridge的Intel微架构中,这方面关注保持运行在被译码的指令Cache的热代码。[译者注:原文中,这里的“hot”拼写错了,写成了“hod”。]为Intel Core微架构最大化译码吞吐的技术包含在3.4.2小节“取指和译码优化”。
3.4.1 分支预测优化
分支优化在性能上具有非常重要的影响。通过理解分支的流动并提高其可预测性,你可以极大地提升代码的速度。
对分支预取的优化有帮助的有:
● 将代码和数据保持在独立的存储页中[译者注:即将代码和数据存放在不同的存储器页面中,这用于具有分页机制的处理器中]。这个非常重要;见3.6小节“优化存储器访问”。
● 尽可能地消除分支。
● 将代码安排为遵循静态分支预测算法
● 在旋锁等待循环中使用PAUSE指令。
● 内联函数以及将调用和返回配对。
● 循环展开,使得重复执行的循环具有少于16次的迭代(除非这导致代码尺寸过度增加)
● 将分支独立分开,使得这些分支的发生频率尽可能地低于每三个微操作。
3.4.1.1 消除分支
消除分支能提升性能,因为:
● 它减少了预测失败的可能性。
● 它减少了所需要的分支目标缓存(BTB)条目。从没执行到的条件分支不消耗BTB条目。
消除分支有四个主要方式:
● 将代码安排为使得基本代码块能连续。
● 循环展开,在3.4.1.7“循环展开”中描述
● 使用CMOV指令
● 使用SETCC指令
下列规则应用于分支消除:
汇编/编译器编码规则1:将代码安排为使得基本代码块能连续从而消除不必要的分支。
对于奔腾M处理器,每个分支都会计数。甚至正确预测的分支在传递给处理器的有用代码的数量上都有一个负面影响。同时,执行到的分支消耗分支预测结构中的空间并且额外的分支也制造了结构容量上的压力。
汇编/编译器编码规则2:尽可能地使用SETCC以及CMOV指令来消除不可预测的条件分支。对可预测的分支不要做这件事。不要使用这些指令来消除所有不可预测的条件分支(因为使用这些指令将会导致由于需要执行一个条件分支的两条不同路径从而导致执行负荷)。此外,将一个条件分支转为SETCC或CMOV带来了数据依赖的控制流依赖并且限制了无序引擎的性能。当在调整代码时,注意所有Intel64和IA-32处理器通常具有非常高的分支预测率。一般来说分支预测失败是比较少的。只有在计算时间的增加比一次所期望的分支预测失败的成本小的时候使用这些指令。
考虑一行C代码,具有一个依赖几个常量中的一个的条件:
X = (A < B) ? CONST1 : CONST2;
这段代码比较了两个值,A和B。 如果条件为真,X被置为CONST1;否则,它被置为CONST2。等价于上述C代码的一个汇编代码序列可能含有不可被预测的分支,如果对A和B两个值都没有修改。
例3-1展示了不可预测分支的汇编代码。不可预测的分支可以用SETCC指令给移除掉。例3-2展示了没有分支的最优代码。
// 例3-1:带有一个不可预测分支的汇编代码 cmp a, b ;条件 jbe L30 ;条件分支 mov ebx const1 ;ebx保存着X jmp L31 ;非条件分支 L30: mov ebx, const2 L31:
// 例3-2:消除分支的代码优化 xor ebx, ebx ;清零ebx(C代码中的X) cmp A, B setgl bl ;当ebx等于0或1时,将它与条件补码相或 sub ebx, 1 ;ebx=11...11或00...00 and ebx, CONST3 ;CONST3=CONST1-CONST2 and ebx, CONST2 ;ebx为CONST1或CONST2
在例3-2中的优化代码先将EBX置为零,然后比较A和B的值。如果A大于等于B,那么EBX被置为1。然后EBX被减1,与常量值的差相与。这个使得EBX的值要么为零,要么为常量差值。通过将CONST2加回到EBX,正确的值就被写入到EBX中。当CONST2等于零时,最后条指令可以被删除。
在奔腾II和后继的处理器中,另一种消除分支的方法是使用CMOV和CFMOV指令。例3-3展示了如何使用CMOV来改变一条TEST和分支指令序列,从而消除一个分支。如果TEST置1相等标志,那么EBX中的值将被移到EAX中。这个分支是数据依赖的,并且被表达为一个不可预测分支。
//例3-3:用CMOV指令消除分支 test ecx, ecx jne 1H mov eax, ebx 1H: ;为了要优化代码,将jne和mov结合到一条cmovcc指令检查相等标志 test ecx, ecx ;测试标志 cmoveq eax, ebx ;如果相等标志被置1,那么将ebx移到eax中。1H标签不再需要
CMOV和FCMOV在奔腾II和后继处理器上可用,但在奔腾处理器与更早的IA-32处理器上不可用。确定要用CPUID指令检查一个处理器是否支持这些指令。
3.4.1.2 旋转等待以及空闲循环
奔腾4处理器引入了一条新的PAUSE指令;该指令在Intel 64和IA-32处理器实现中在架构上是一条NOP。
对于奔腾4和后面的处理器,这条指令扮演了一个暗示,代码序列是一个旋转等待循环。在这样的循环中没有一条PAUSE指令的话,奔腾4处理器可能在退出循环时遭受严重的处罚,因为处理器可能探测到违反存储器次序的可能。插入PAUSE指令能极大地减少存储器次序违背的可能性并从而提升性能。
在例3-4中,代码会一直旋转直到存储器位置A与存储在寄存器EAX中的值匹配。这样的代码序列在当保护一个临界区、生产者-消费者序列、用于栅栏或其它同步的时候是常用的。
//例3-4:PAUSE指令的使用 lock: cmp eax, a jne loop ;在临界区中的代码 loop: pause cmp eax, a jne loop jmp lock
3.4.1.3 静态预测
在BTB(见3.4.1,“分支预测优化”)[译者注:分支目标缓存]中没有历史数据的分支使用一个静态预测算法来预测。奔腾4、奔腾M、Intel Core Solo以及Intel Core Duo处理器具有类似的静态预测算法:
● 预测会采取的非条件分支
● 预测不会采取的间接分支
此外,基于NetBurst微架构的处理器中的条件分支使用下列静态预测算法进行预测:
● 后向预测会采取的条件分支;规则适合于循环
● 前向预测不会采取的条件分支
奔腾M、Intel Core Solo以及Intel Core Duo处理器并不会根据跳转方向静态预测条件分支。所有条件分支都被动态预测,甚至是在第一次出现时。
下列规则应用于静态消除。
汇编/编译器编码规则3:将代码安排为与静态分支预测算法相一致:让跟在一个条件分支后面的向下通过的代码尽可能地成为带有一个前向目标分支的目标,并且使跟在一个条件分支后的向下通过的代码对于带有一个后向目标的一个分支尽可能地不成为目标。
例3-5描述了静态分支预测算法。一个IF-THEN条件的代码体被预测。
// 例3-5:奔腾4处理器静态分支预测算法 //前向条件分支不被采取(向下通过) IF<condition>{... ↓ } IF<condition>{... ↓ } //后向条件分支被采取 LOOP {... ↑ --}<condition> //非条件分支被采取 JMP ------ →
例3-6以及例3-7对于一个静态预测算法提供了基本规则。在例3-6中,后向分支(JC BEGIN)在第一次通过时不在BTB[译者注:目标分支缓存]中;从而,BTB并不发布一个预测。然而,静态预测器将预测分支会被采取,这样分支预测失败就不会发生。
//例3-6:静态采取预测 Begin: mov eax, mem32 and eax, ebx imul eax, edx shld eax, 7 jc Begin
例3-7中的第一条分支指令(JC BEGIN)是一个有条件的前向分支。它在第一次通过时不在BTB中,但静态预测器将会预测分支向下通过。静态预测算法会正确地预测CALL CONVERT指令会被采取,甚至在该分支在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微架构不使用静态预测试探。然而,为了维护Intel 64和IA-32处理器的一致性,软件应该默认维持静态预测试探。
3.4.1.4 内联,调用和返回
返回地址栈机制提高了静态和动态预测器以对调用和返回做特殊的优化。它持有16个条目,对于覆盖大部分程序的调用深度来说已经是足够大了。如果有多于16个嵌套调用和多于16个返回快速连续执行,那么性能就会下降。
在Intel NetBurst微架构中的踪迹Cache维护着调用和返回的分支预测信息。只要带有调用或返回的踪迹仍然在踪迹Cache中并且调用和返回目标仍然未变,那么在上述所描述的返回地址栈的深度限制将不会妨碍性能。
为了允许使用返回栈机制,调用和返回必须成对匹配。如果这个做到了,那么以会影响性能的方式超过栈深度的可能性将会很低。
下列规则应用于内联、调用和返回。
汇编/编译器编码规则4:近程调用必须与近程返回匹配,远程调用必须与远程返回匹配。将返回地址压在栈上并跳转到将被调用的例程不被推荐,因为它造成了在调用和返回上的误匹配。
调用和返回是昂贵的;出于以下原因使用内联:
● 参数传递负荷可以被消除。
● 在一个编译器中,内联一个函数给优化揭露了更多的机会。
● 如果内联例程含有分支,那么调用者的额外上下文可以在例程中提升分支预测。
● 一个预测失败的分支,在一个小函数内会导致比在函数被内联的那些函数中发生预测失败遭受更大的性能处罚。
汇编/编译器编码规则5:选择性地内联一个函数,如果这么做减少了代码尺寸或如果函数较小并且调用位置被频繁执行。
汇编/编译器编码规则6:不要内联一个函数,如果这么做增加了工作集大小从而超出了适应于踪迹Cache的大小。
汇编/编译器编码规则7:如果有超过16个嵌套调用和返回快速连续执行;考虑将带有内联的程序进行改变以减少调用深度。
汇编/编译器编码规则8:对含有糟糕预测率的分支的小函数赐予内联。如果一个分支预测失败导致一条RETURN被贸然地预测为采取,那么可能会遭到性能处罚。
汇编/编译器编码规则9:如果一个函数的最后一条语句是调用另一个函数,考虑将那个调用转换为一个跳转。这将节省调用/返回负荷以及节省在返回栈缓存中的一个条目。
汇编/编译器编码规则10:在一个16字节的块中不要放多于四条分支。
汇编/编译器编码规则11:在一个16字节的块中不要放多于两个结束循环分支。
3.4.1.5 代码对齐
仔细安排代码可以增强Cache和存储器位置。可能的基本块序列应该被放在存储器中相邻的位置上。这会牵涉到移除不可能的代码,诸如代码序列中的错误处理条件。见3.7小节,“预取”,优化指令预取器。
汇编/编译器编码规则12:所有分支目标应该为16字节对齐。
汇编/编译器编码规则13:如果一个条件的代码体不太可能被执行,那么它应该被放置在程序的另一部分。如果它高度不可能被执行并且代码位置是一个预测发布,那么它应该被放在一个不同的代码页上。
3.4.1.6 分支类型选择
间接分支和调用的默认被预测的目标是向下直通的路径。如果当硬件预测对那个分支可用,那么向下直通预测被重载[译者注:被硬件预测重载]。对于一个间接分支,来自分支预测硬件的被预测分支目标是先前所执行的分支目标。
对向下直通路径的默认预测仅仅是一个有意义的预测发布,如果没有分支预测可用,由于糟糕的代码位置或莫名其妙的分支冲突问题。对于间接调用,预测向下直通路径通常不是一个预测发布,由于执行将可能返回到与相关联的返回之后的那条指令。
立即跟在一个间接分支之后放置数据会导致一个性能问题。如果数据由全零组成,它看上去就像一个长长的ADD到存储器目的的流[译者注:即后面的数据会被处理器当作为指令而被预取],而这会导致资源冲突并且降低分支恢复速度。同时,立即跟在间接分支之后的数据可能会作为到分支预测硬件的分支出现,而这会导致分支脱离而取执行其它数据页。这会导致后续的自修改代码问题。
汇编/编译器编码规则14:当存在间接分支时,设法将一个间接分支的最可能目标放置到立即跟在间接分支之后。作为二选一地,如果间接分支是公共的,但它们不能被分支预测硬件所预测,那么在那个间接分支之后跟一条UD2指令,而这将阻止处理器沿着向下直通路径译码。
由代码构建而导致的间接分支(诸如switch语句、被计算的GOTO或通过指针的调用)可以跳转到任意数量的位置。如果代码序列是那种一个分支的目标目的在大部分时间走到同一地址,那么BTB将会在大部分时间精确地预测。由于只有一个被采取的(非直通的)目标可以被存放在BTB中,带有多个被采取的目标的间接分支可能有更低的预测率。被存储目标的有效个数可能会通过引入额外的条件分支而增加。将一个条件分支添加到一个目标中是富有成效的,如果:
● 分支方向与引导到那个分支的分支历史有相互关系;即,不单单是最后的目标,而且还有它是如何到达这个分支的。
● 源/目标对使用额外的分支预测容量通常足以保证。这会增加间接分支的错误预测从而增加整个分支错误预测的数量。如果错误预测分支的数量非常大,那么获利更低。
用户/源码编码规则1:如果一个间接分支具有两个或更多的公共采取的目标并且那些目标中至少有一个与导致那个分支的分支历史相关联,那么将这个间接分支转换为一个树,一个或多个间接分支放在那些目标的条件分支之前。将这种“剥皮式”过程应用到与分支历史相关联的一个间接分支的公共目标。
这个规则的目的是通过增强分支的可预测性(甚至以添加更多分支的开销)来减少错误预测的总数。被添加的分支必须是可预测的,这样才有价值。这种可预测性的理由是与预测分支历史的强关联性。即,在分支前所采取的方向是在考虑之中的分支方向的一个良好的指示器。
例3-8展示了一个先于条件分支的一个目标与一个间接分支的一个目标之间的关联的例子。
// 例3-8:带有两个受优待的目标的间接分支 funcion() { int n = rand(); // 在0到RAND_MAX之间的随机整数 if(!(n & 0x01){ // n将有50%的概率为0 n = 0; // 更新分支历史以预测采取 } // 带有多个采取的目标的间接分支会有更低的预测率 switch(n){ case 0: handle_0(); break; // 公共目标,与向前采取的分支历史有相互关系 case 1: handle_1(); break; // 非公共的 case 3: handle_3(); break; // 非公共的 default: handle_other(); // 公共目标 } }
对于一个编译器和一个汇编语言程序员,相互关联性很难用分析法来确定。用和不用剥皮法以尽力编写代码获得最佳性能可能会对计算性能会有所成果。
例3-9展示了剥离带有相关联的分支历史的一个间接分支的最受优待的目标。
// 例3-9:一个剥离技术以减少间接分支错误预测 function() { int n = rand(); // 从0到RAND_MAX的随机整数 if(!(n & 0x0x1)){ n = 0; // n将有一半机率为0 } if(!n) handle_0(); // 剥离带有相关联分支历史的最公共的目标 switch(n){ case 1: handle_1(); break; // 非公共的 case 3: handle_2(); break; // 非公共的 default: handle_other(); // 使受优待的目标在向下直通路径中 } }
3.4.1.7 循环展开
循环展开的利益如下:
● 循环展开分批承担分支符合,由于它消除了分支以及一些代码以管理归纳变量[译者注:归纳变量——induction variable是指循环中每次增加或减少固定数值,或者与循环次数呈一定数学解析关系的变量。归纳变量的识别、约化、替代和删除等工作可以提高代码的执行效率和并行安全,也可以用于值域估计、边界检测等方面]。
● 循环展开允许处理器渐进地调度(或流水)循环以隐藏延迟。如果在你展开依赖链以暴露关键路径时具有足够的空闲寄存器以保持变量活跃,这个将是非常有用的。
● 循环展开将代码暴露给各种其它优化,诸如移除冗余负载,公共子表达式消除等等。
● 奔腾4处理器可以对一个具有16个或更少的迭代的内部循环正确地预测分支退出(如果迭代次数是可预测的并且循环中没有条件分支)。这样,如果循环体大小不过度并且可能的迭代次数是已知的,那么展开内部循环,直到它们有最多有16次迭代。对于奔腾M处理器,不要展开多于64次迭代的循环。
循环展开的潜在成本有:
● 过度展开或对非常大的循环的展开会导致增加代码尺寸。如果被展开的循环不再适应于踪迹Cache(TC)内,那么这会是有害的。
● 展开循环体含有分支的循环增加了对BTB[译者注:分支目标缓存]容量的要求。如果被展开循环的迭代次数是16或更少,那么分支预测器应该能够正确地预测循环体内交替方向的分支。
汇编/编译器编码规则15:展开小循环,直到分支和归纳变量的负荷(通常)负责低于循环的执行时间少于10%。
汇编/编译器编码规则16:避免过分地循环展开;这反而会惩罚踪迹Cache或指令Cache。
汇编/编译器编码规则17:展开频繁执行的循环并且具有一个可预测数量的迭代以将迭代次数减少到16个或更少。除非这增加代码尺寸以至于工作集不再适应于踪迹或指令Cache,否则就这么干。如果循环体含有多于一个条件分支,那么循环展开,以至于迭代次数为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 对分支预测的编译器支持
编译器生成的代码在奔腾4,奔腾M,Intel Core Duo处理器以及基于Intel Core微架构的处理器中提升分支预测效率。Intel C++编译器通过以下几点来实现这个功能:
● 将代码和数据保持在独立的页中。
● 使用带条件的搬移指令来消除分支
● 生成与静态分支预测算法相一致的代码
● 在适当的地方内联
● 如果迭代次数是可预测的,那么做循环展开
伴随着剖析指导的优化,编译器对于一个函数最频繁执行的路径可以排列基本的块以消除分支或至少提升它们的可预测性。分支预测不需要在源代码层次上作关心。更多信息可见C++编译器文档。
3.4.2 取指令和译码优化
Intel Core微架构提供了若干种机制以增加前端的吞吐。利用这些特征的技巧在下面讨论。
3.4.2.1 对微融合优化
对一个寄存器和一个存储器操作数操作的一条指令比起其相应的寄存器版本译码为更多的微操作。使用寄存器-寄存器版本来代替前种指令同样的工作通常需要两条指令序列。后一种序列可能导致减少取指带宽。
汇编/编译器编码规则18:为了提升取指/译码吞吐,更偏向给予一条指令的存储器风格比起同样指令的仅寄存器风格,如果这样的指令能从微融合获益。
下列例子是某些类型的微融合,它们可以被所有译码器处理:
● 所有对存储器的存储,包括存储立即数。存储在内部作为两个独立的微操作执行:存储地址以及存储数据。
● 所有在寄存器和存储器之间的“读-修改”(加载+操作)指令,比如
ADDPS XMM9, OWORD PTR [RSP+40] FADD DOUBLE PTR [RDI+RSI*8] XOR RAX, QWORD PTR[RBP+32]
● “加载和跳转”形式的所有指令,比如
JMP [RDI+200] RET
● 带有立即数操作数和存储器操作数的CMP和TEST
带有RIP相对编址的一条Intel 64指令在以下情况下不被宏融合
● 当需要一个额外的立即数时,比如:
CMP [RIP+400], 27 MOV [RPI+3000], 142
● 当出于程序流的目的而需要一个RIP时,比如:
JMP [RIP+5000000]
在这些情况下,Intel Core微架构以及代号名为Sandy Bridge的Intel微架构从译码器0提供了一条2个微操作流,导致了译码带宽的一点损失,由于2个微操作流必须从被对齐的译码器引导到译码器0。
RIP编址对访问全局数据可以是公共的。由于它不会从微融合获利,编译器可以考虑用其它存储器寻址方法来访问全局数据。
3.4.2.2 对宏融合优化
宏融合将两条指令融合到一单个微操作上。Intel Core微架构在有限的情况下执行这个硬件优化。
被宏融合对的第一条指令必须是一条CMP或TEST指令。这条指令可以是寄存器-寄存器,寄存器-立即数,或一个被宏融合的寄存器-存储器比较。第二条指令(在指令流中邻近的)应该是一个条件分支。
由于这些对在基本的迭代的编程序列中,宏融合提升了性能,甚至不用重新编译二进制文件。所有译码器可以在每个周期译码一个宏融合的对,最多到三条其它指令,可以导致一个峰值译码带宽达到每个周期5条指令。
每条宏融合指令带有一单个分派进行执行。这个过程减少了延迟,在这种情况下展现出一个周期从分支错误预测处罚中移除。软件也获得了所有其它融合利益:增加了重命名和隐退带宽,用于流动中指令的更多存储,用更少比特表现了更多工作从而节省能源。
下列列表详细列出了什么时候你能使用宏融合:
● CMP和TEST在比较时可以被融合:
寄存器-寄存器。比如:CMP EAX,ECX; JZ label
寄存器-立即数。比如:CMP EAX,0x80; JZ label
寄存器-存储器。比如:CMP EAX,[ECX]; JZ label
存储器-寄存器。比如:CMP [EAX], ECX; JZ label
● TEST可以与所有条件跳转融合。
● 在Intel Core微架构中,CMP只能与下列条件跳转融合。这些条件跳转检查进位标志(CF)或全零标志(ZF)跳转。可用宏融合的条件跳转列表如下:
JA或JNBE
JAE或JNB或JNC
JE或JZ
JNA或JC或JB
JNE或JNZ
当在比较存储器-立即数时(比如,CMP [EAX], 0x80; JZ label)CMP和TEST不能被融合。宏融合不支持Intel Core微架构的64位模式。
● Intel微架构代号名为Nehalem支持以下宏融合中的增强:
——CMP可以用下列条件跳转被融合(而在Intel Core微架构中是不被支持的):
● JL或JNGE
● JGE或JNL
● JLE或JNG
● JG或JNLE
——宏融合在64位模式下被支持
● 在代号名为Sandy Bridge的Intel微架构下增强的宏融合在表3-1中概括,额外信息在2.1.2.1小节以及例3-15中描述。
汇编/编译器编码规则19:在可能使用支持宏融合的指令对的地方用上宏融合。如果可能,使用TEST来代替CMP。尽可能使用无符号变量以及无符号跳转。设法在逻辑上校验一个一个变量在比较时是非负的。尽可能避免存储器-立即数风格的CMP或或TEST。然而,不要添加其它指令来避免使用存储器-立即数风格。
例3-11。宏融合,无符号迭代计数
// C代码 /*** 不带宏融合 ***/ for(int i = 0; i < 1000; i++) // 带符号迭代抑制了宏融合 a++; /*** 带有宏融合 ***/ for(unsigned int i = 0; i < 1000; i++) // 无符号迭代计数与宏融合兼容 a++;
; 汇编代码 ; 不带宏融合 ; for(int i = 0; i < 1000; i++) mov dword ptr[i], 0 jmp First Loop: mov eax, dword ptr[i] add eax, 1 mov dword ptr[i], eax First: cmp dword ptr[i], 3E8H ; CMP MEM-IMM, JGE抑制了宏融合 jge End ; a++ mov eax, dword ptr[a] addqq eax, 1 mov dword ptr[a], eax jmp LOOP End: ; 带有宏融合 ; for(unsigned int i = 0; i < 1000; i++) xor eax, eax mov dword ptr[i], eax jmp first Loop: mov eax, dword ptr[i] add eax, 1 mov dword ptr[i], eax First: cmp eax, 3E8H ; CMP REG-IMM, JAE准许了宏融合 jae End ; a++ mov eax, dword ptr[a] add eax, 1 mov dword ptr[a], eax jmp Loop End:
例3-12:宏融合,if语句
// C代码 /*** 不带宏融合 ***/ int a = 7; // 带符号的迭代计数抑制了宏融合 if(a < 77) a++; else a--; /*** 带有宏融合 ***/ unsigned int a = 7; // 无符号迭代计数与宏融合兼容 if(a < 77) a++; else a--;
; 汇编代码 ; 不带宏融合 ; int a = 7 mov dword ptr [a], 7 ; if(a < 77) cmp dword ptr [a], 4DH ; CMP MEM-IMM, JGE抑制了宏融合 jge Dec ; a++ mov eax, dword ptr [a] add eax, 1 mov dword ptr [a], eax ; else jmp End ; a-- Dec: mov eax, dword ptr [a] sub eax, 1 mov dword ptr [a], eax End: ; 带有宏融合 ; unsigned int a = 7; mov dword ptr [a], 7 ; if(a < 77) mov eax, dword ptr [a] cmp eax, 4DH jae Dec ; a++ add eax, 1 mov dword ptr [a], eax ; else jmp End ; a-- Dec: sub eax, 1 mov dword ptr [a], eax End:
汇编/编译器编码规则20:软件可以允许宏融合,当它可以在逻辑上确定一个变量在比较时是非负的;使用TEST适当地允许宏融合当用一个变量与0比较时。
例3-13:宏融合,带符号变量
;不带有宏融合 test ecx, ecx jle OutSideTheIF cmp ecx, 64H jge OutSidetheIF <IF BLOCK CODE> OutSideTheIF: ; 带有宏融合 test ecx, ecx jle OutSideTheIF cmp ecx, 64H jae OutSideTheIF <IF BLOCK CODE> OutSideTheIF:
对于带符号或无符号的变量'a';"CMP a, 0"与"TEST a, a"产生同样的结果并且也考虑标志位。由于TEST往往可以更好地被宏融合,软件可以使用"TEST a, a"来代替"CMP a, 0"出于允许宏融合的目的。
例3-14:宏融合,带符号比较
; 不带宏融合 ; if(a == 0) cmp a, 0 jne lbl ... lbl: ; if(a >= 0) cmp a, 0 jl lbl ... lbl: ; 带有宏融合 ; if(a == 0) test a, a jne lbl ... lbl: ; if(a >= 0) test a, a jl lbl ... lbl:
代号名为Sandy Bridge的Intel微架构允许更多的算数与逻辑指令与条件分支进行宏融合。在ALU端口已经很拥挤的循环中,执行这些宏融合的其中之一可以缓解压力,由于宏融合指令只消耗端口5,而不是一个ALU端口加上端口5。
在例3-15中,“add/cmp/jnz”循环含有两条ALU指令,可以通过端口0,1,5被分派。因此,端口5有更高的概率可能绑定到任一个引起JNZ等待一个周期的ALU指令。“sub/jnz”循环,ADD/SUB/JNZ能够在同一个周期被派生的可能性增加,因为只有SUB是自由可以与端口0、1或5绑定的。
例3-15:代号名为Sandy Bridge的Intel微架构额外的宏融合获益
; 可替换的add+cmp+jnz lea rdx, buff xor rcx, rcx xor eax, eax loop: add eax, [rdx + 4 * rcx] add rcx, 1 cmp rcx, LEN jnz loop ; 用sub+jnz的循环控制 lea rdx, buff - 4 xor rcx, LEN loop: add eax, [rdx + 4 * rcx] sub rcx, 1 jnz loop
3.4.2.3 改变长度的前缀(LCP)
一条指令的长度可以达到15个字节。某些前缀可以动态改变译码器必须识别的一条指令的长度。一般,预译码单元将在字节流中估计一条指令的长度,假定没有LCP。当预译码器在取指行中碰到一个LCP,那么它必须使用一个更慢的长度译码算法。伴随着更慢的长度译码算法,预译码器在6个周期内取址,而不是通常的1个周期。机器流水线的正常的排队吞吐一般无法隐藏LCP处罚。
可动态改变一条指令长度的前缀包括:
● 操作数大小前缀(0x66)
● 地址大小前缀(0x67)
指令MOV DX, 01234h在基于Intel Core微架构的处理器以及Intel Core Duo和Intel Core Solo处理器中受到LCP延迟的影响。含有imm16[译者注:16位立即数]作为它们的固定编码,但不需要LCP改变立即数大小的部分的指令不受LCP延迟影响。在64位模式中的REX前缀(4xh)会改变两类指令的大小,但不会引起LCP处罚。
如果LCP延迟在一个密集循环中发生,它会引起严重的性能下降。当译码不是一个瓶颈时,比如在浮点数处理较多的代码中,单独的LCP延迟通常不引起性能下降。
汇编/编译器编码规则21:使用imm8或imm32值生成代码,而不是imm16值。
如果需要imm16,加载相等的imm32到一个寄存器中并在寄存器中使用字值[译者注:16位寄存器值]。
双重LCP延迟
受LCP延迟影响并跨越16字节取指行边界的指令会引起LCP延迟触发两次。下列对齐情况会引起LCP延迟触发两次:
● 用一个MODR/M和SIB字节编码的一条指令,以及取指Cache行边界跨越在MODR/M与SIB字节之间。
● 起始于一条取指行的第13字节的偏移的一条指令使用寄存器和立即数字节偏移寻址模式引用一个存储器位置。
第一个延迟是由于第一个取指行,而第二个延迟是由于第二个取指行。一个双重LCP延迟引起了11个周期的译码处罚。
下列例子引起LCP延迟一次,不管它们的取指行位置(指令的第一个字节的取指行的位置):
ADD DX, 01234H ADD word ptr [EDX], 01234H ADD word ptr 012345678H[EDX], 01234H ADD word ptr [012345678H], 01234H
下列指令引起一个双重LCP延迟,当一个取指行起始于第13字节的偏移时:
ADD word ptr [EDX+ESI], 01234H ADD word ptr 012H[EDX], 01234H ADD word ptr 012345678H[EDX+ESI], 01234H
为了避免双重LCP延迟,不要使用受LCP延迟影响的使用SIB字节编码或用字节移置的寻址模式的指令。
错误的LCP延迟
错误的LCP延迟具有与LCP延迟相同的特征,但发生在没有任何imm16值的指令上。
错误的LCP延迟会在以下情况下发生:(a)带有LCP的指令,使用F7操作码编码,以及(b)位于一个取指令行第14个字节的偏移处。这些指令是:NOT、NEG、DIV、IDIV、MUL以及IMUL。错误的LCP遭受延迟,因为指令长度译码器在下一条取指行之前不能确定指令的长度,而这个会将指令的确切的操作码保持在其MODR/M字节中。
下列技术能帮助避免错误的LCP延迟:
● 将所有指令的F7群的short操作使用完整的32位版本向上投射到long。
● 确保F7操作码永远不在一个取指行的第14个字节的偏移处开始。
汇编/编译器编码规则22:确保使用0xF7操作码字节的指令不在一个取指令行的第14个字节偏移处起始;并且避免使用这些指令操作16位数据,将short数据向上投射到32位。
例3-16:用0xF7群指令避免错误的LCP延迟
; 一个在译码器中引起延迟的序列 neg word ptr a ; 代替序列以避免延迟 movsx eax, word ptr a neg eax mov word ptr a, AX
3.4.2.4 优化循环流探测器(LSD)
适应以下条件的循环在Intel Core微架构中被LSD所探测并从指令队列中重新执行送到译码器:
● 必须小于等于四个16字节的取指。
● 必须小于等于18条指令。
● 可以含有不多于四个所采取的分支并且没有一个是RET。
● 应该通常具有多余64次迭代。
代号名为Nehalem的Intel微架构中的循环流探测器通过以下方式被提升:
● 将译码好的微操作Cache到指令译码器队列(IDQ,见2.3.2小节)中以送到重命名/分配阶段。
● LSD的大小被增加到28个微操作。
许多计算密集循环、搜索和软件字符串搬移匹配这些特征。这些循环超过了BPU预测容量并总是在一个分支错误预测中终止。
汇编/编译器编码规则23:将一个循环长的指令序列分成不大于LSD尺寸的更短的指令块。
汇编/编译器编码规则24:避免含有LCP延迟的循环展开,如果循环展开块超过LSD的大小。
3.4.2.5 在代号名为Sandy Bridge的Intel微架构中发掘LSD微操作发射带宽
LSD持有创建小的“无限”个循环的微操作。来自LSD的微操作分配在无序引擎中。LSD中的循环以一个所采取的跳转到循环开始处的分支结束。在循环末处所采取的分支总是在该周期中所分配的最后一个微操作。在循环开始处的指令总是在下一个周期被分配。如果代码性能受前端带宽约束,那么未使用的分配槽导致分配中的泡沫,从而会导致性能下降。
在代号名为Sandy Bridge的Intel微架构中分配带宽每周期是四个微操作。当LSD中的微操作的数量造成最少未使用的分配槽的个数时,性能是最佳的。你可以使用循环展开来控制LSD中的微操作的个数。
在例3-17中,代码对所数组所有元素求和。原始代码在每次迭代累加一个元素。每次迭代它有三个微操作,都在一个周期内分配。代码吞吐在每个周期为一次加载。
当循环展开一次时,每次迭代有5个微操作,这些微操作在两个周期内被分配。代码吞吐仍然为每个周期一次加载。从而没有性能增益。
当循环展开两次时,每次迭代有7个微操作,仍然在两个周期内被分配。由于两次加载可以在每个周期内被执行,因此这个代码具有一个潜在吞吐,两个周期内有三次加载操作。
例3-17:在LSD中的循环展开优化发射带宽
; 无循环展开 lp: add tax, [rsi + 4 * rcx] dec rcx jnz lp ; 循环展开一次 lp: add eax, [rsi + 4 * rcx] add eax, [rsi + 4 * rcx + 4] add rcx, -2 jnz lp ; 循环展开两次 lp: add eax, [rsi + 4 * rcx] add tax, [rsi + 4 * rcx + 4] add tax, [rsi + 4 * rcx + 8] add rcx, -3 jnz lp
3.4.2.6 优化已译码的指令Cache
已译码的指令Cache在代号名为Sandy Bridge的Intel微架构中是一个新特征。从已译码的指令Cache运行代码具有两个优势:
● 有更高带宽送给无序引擎微操作。
● 前端不需要译码已在已译码的指令Cache中的代码。这节省了能源。
在已译码的指令Cache与遗留的译码流水线之间的切换有负荷。如果你的代码在前端与已译码的指令Cache之间切换频繁,那么处罚会比只运行在遗留流水线的更高。
确保“热”代码从已译码的指令Cache送出。
● 确保每个热代码块少于大约500条指令。尤其,不要在一个循环内展开多于500条指令。这应该允许已译码的指令Cache的驻留,甚至当超线程被允许时。
● 对于在一个循环内带有非常大的计算块的应用,考虑循环分裂:将该循环分割成多个适应于已译码指令Cache的循环,而不是一单个溢出的循环。
● 如果一个应用程序可以确保每个核心只运行一个线程,那么它可以增加热代码块尺寸到大约1000条指令。
密集的读-修改-写代码
已译码的指令Cache仅可以保持多达18个微操作每32个字节对齐的存储块。因此,带有被编码为很少一些字节的一个高集中指令的代码仍然具有许多微操作,可能超过18个微操作限制从而不进入已译码的指令Cache。读-修改-写(RMW)指令是这种指令的一个好的例子。
RMW指令接受一个存储器源操作数,一个寄存器源操作数,并使用源存储器操作数作为目的。相同的功能可以通过两或三条指令实现:第一条指令读源操作数,第二条指令用第二个寄存器源操作数执行操作,而最后一条指令将结果写回存储器。这些指令往往导致相同个数的微操作但使用更多的字节编码同样的功能。
RMW指令可以被广泛使用的一个情况是当编译器激烈地优化代码大小的时候。
这里有一些可能的解决方案以将热代码适应于已译码的指令Cache中:
● 用两条或三条具有相同功能的指令来代替RMW指令。比如,“adc [rdi], rcx”仅有三个字节的长度;等价的序列“adc rax, [rdi]”+“mov [rdi], rax”具有六字节的占用空间。
● 对齐代码,使得密集部分被分解成两个不同的32字节块。这个解决方案在当使用一个工具时是有用的,该工具自动地对齐代码,并且对于代码改变不去关心。
● 通过在循环中添加多个字节NOP来铺展代码。注意,这个解决方式为执行添加了微操作。
为已译码的指令Cache对齐无条件分支
对于正在进入已译码的指令Cache的代码 ,每个无条件分支是最后一个占用一个已译码的指令Cache路的微操作。从而,每一个32字节对齐的存储块,只有三个无条件分支可以进入已译码的指令Cache。
无条件的分支在跳转表和切换声明中是使用频繁的。以下是这些构造的例子,以及写这些例子的方法,使得它们适应于已译码的指令Cache。
编译器为C++虚拟类创建方法或DLL分派表创建跳转表。每个无条件分支消耗5个字节;从而7个这样的分支能够与一个32字节的块相关联。因此,跳转表可能不适应于已译码的指令Cache,如果无条件分支在每个32字节对齐的存储块中太密集。这可能导致在分支表的前后代码执行的性能下降。
解决方案是在分支表中的分支之间添加多字节NOP指令。这个会增加代码尺寸并应该被谨慎使用。然而,这些NOP不会被执行,从而在后面的流水阶段没有处罚。
switch-case结构表示了一个类似的情景。一个case条件的每个计算造成一个无条件分支。使用多字节NOP的相同的解决方法可以应用于适应于一个对齐的32字节存储块内的每三个连续的无条件分支。
在一个已译码的指令Cache路中的两个分支
已译码的指令Cache在一个路中可以保持多达两个分支。在一个32字节对齐的块中的密集分支,或它们的带有其它指令的定序会阻止所有块中指令的微操作进入已译码的指令Cache。这不会经常发生。当它发生时,你可以在适当的地方放置NOP指令。确定这些NOP指令不是热代码的一部分。
3.4.2.7 为奔腾M处理器译码器的调度规则
奔腾M处理器具有三个译码器,但以高带宽支持微操作的译码规则比奔腾III处理器更宽松一些。这提供了在编译器中构件一个前端跟踪器并设法正确地调度指令的机会。译码器限制有:
● 第一个译码器在每个时钟周期能够译码由四个或更少的微操作所组成的一条微指令。它可以处理任一个数的字节,最多到15个。有多个前缀的指令需要额外的周期。
● 两个额外的译码器,每个可以在每个时钟周期内译码一条微指令(假定该指令是一个微操作,长度为七个字节)。
● 由超过四个微操作组成的指令会耗费多个周期来译码。
汇编/编译器编码规则25:避免在一个栈操作序列(POP,PUSH,CALL,RET)中显式地对ESP进行引用。
3.4.2.8 其它译码准则
汇编/编译器编码规则26:使用长度小于八个字节的简单指令。
汇编/编译器编码规则27:避免使用前缀以改变立即数以及移置大小。
长指令(多于七个字节)限制了奔腾M处理器上的每个周期的已译码指令的个数。每个前缀将一个字节添加到指令长度,可能限制译码器的吞吐。此外,多个前缀只能被第一个译码器译码。当被译码时,这些前缀也会遭致延迟。如果无法避免多个前缀或一个前缀改变立即数或移置的大小,那么出于其它一些理由,在延迟流水线的指令之后调度它们。