3.8 浮点方面的考虑
当对浮点应用进行编程时,最好先一开始用一个高级编程语言,诸如C、C++或Fortran。许多编译器都尽可能地执行浮点调度和优化。然而,为了产生最优代码,编译器可能需要一些辅助。
3.8.1 优化浮点代码的准则
用户/源编码规则13:用适当的开关来允许编译器使用SSE2,SSE3指令。
遵从这个过程来调查你的浮点应用的性能:
● 理解编译器如何处理浮点代码。
● 查看汇编日志来看看什么变换已经执行在程序上了。
● 学习应用程序中的循环嵌套占用执行时间。
● 判断编译器为何没创建最快的代码。
● 看看是否有一个可被解决的依赖
● 判定问题域:总线带宽、Cache位置、踪迹Cache带宽或指令延迟。关注优化问题域。比如,添加PREFETCH指令将没有帮助,如果总线已经饱和的话。如果踪迹Cache带宽是个问题,那么添加预取微操作可能会降低性能。
同时,一般来说,遵循以下在本章所讨论的通用编码建议,包括:
● 阻塞Cache
● 使用预取
● 允许向量化
● 循环展开
用户/源编码规则14:确定你的应用在正常值范围内来避免非正常的值、下溢等等。超出范围的数会引起非常高的负荷。
用户/源编码规则15:不要使用双精度,除非有需要。在x87 FPU中将控制字设置(PC)为“单精度”。这允许单精度(32位)计算来更快地完成某些操作(比如某些除法)。然而,小心引入多于总共两个值的浮点控制字,否则,会有很大的性能处罚。见3.8.3小节。
用户/源编码规则16:使用快速的float到int例程、FISTTP或SSE2指令。如果对这些例程进行编码,那么使用FISTTP指令,若SSE3可用的话,否则使用CVTTSS2SI与CVTTSD2SI指令,若用SSE2来编码的话。
许多库生成的x87代码会做一些不必要的工作。SSE3中的FISTTP指令可以不去访问浮点控制字(FCW)而使用截断将浮点值转换为16位、32位或64位整数。指令CVTTSS2SI与CVTTSD2SI节省了许多微操作以及一些存储转运延迟,对于某些编译器实现。这避免了改变舍入模式。
用户/源编码规则17:移除数据依赖允许了无序引擎从代码萃取更多的ILP[译者注:指令级并行]。当对一个数组求和时,使用部分求和而不是一单个累加器。
例如,要计算z = a + b + c + d,不要用:
X = A + B;
Y = X + C;
Z = Y + D;
而是使用:
X = A + B;
Y = C + D;
Z = X + Y;
用户/源编码规则18:通常,数学库在计算基本函数时利用了超越[译者注:transcendental]指令(比如,FSIN)。如果没有特别需要要使用扩展80位精度来计算超越函数[译者注:transcendental function],那么应用应该考虑一种替换的,基于软件的方式,诸如使用插值技术的基于算法的一个查找表。通过选择想要的数值精度以及查找表的大小以及利用SSE与SSE2指令的并行,用这些技术来提升超越函数的性能是有可能的。
3.8.2 浮点模式和异常
当处理浮点数时,高速处理器必须频繁地处理一些情景,需要在硬件或代码中进行一些特殊处理。
3.8.2.1 浮点异常
最时常发生的性能下降是由于使用了被开启的浮点异常条件[译者注:设置了浮点异常条件的某些标志位],诸如:
● 算术上溢
● 算术下溢
● 非规格化的操作数
非规格化的浮点数以以下两种方式影响性能:
● 当用作操作数时直接影响性能
● 当作为一个下溢情景而产生时间接影响性能
如果一个浮点应用从不下溢,那么非规格化只能来自浮点常量。
用户/源编码规则19:非规格化浮点常量应该尽可能被避免。
非规格化与算术下溢异常在执行x87指令或SSE/SSE2/SSE3指令期间可能发生。在基于Intel NetBurst微架构的处理器当执行SSE/SSE2/SSE3指令时以及当速度比顺应IEEE标准更重要时,更高效地处理这些异常。以下段落给了一些如何优化代码以减少与浮点异常相关的性能降低的建议。
3.8.2.2 在x87 FPU代码中处理浮点异常
在3.8.2.1小节中列出了每种特殊情况,就性能而言,成本较高的“浮点异常”。出于那个原因,x87 FPU代码应该重写以避免这些情况。
基本上有三种方法来减少x87 FPU代码的上溢/下溢情况的影响:
● 选择足够大的浮点数据类型来容纳结果而不生成算术上溢和下溢异常。
● 测量操作数/结果的范围来尽可能减少算术上溢/下溢情况的发生次数。
● 将中间结果保存在x87 FPU寄存器栈上直到最终结果被计算出再存储到存储器中。当把结果保存在x87 FPU栈中时,上溢或下溢不太可能发生(这是因为栈上的数据溢扩展双精度格式被存放的,并且上溢/下溢条件能相应地被探测出)。
● 非规格化的浮点常量(它们是只读的,并因而不会改变)应该被避免,如果可能的话用零或相同符号的来代替。
3.8.2.3 在SSE/SSE2/SSE3代码中的浮点异常
涉及到设置浮点异常标志位的大部分特殊情况以硬件高效地处理。当一个被置位的上溢异常在执行SSE/SSE2/SSE3代码时发生时,处理器硬件能处理它而不会有性能处罚。
下溢异常以及非规格化源操作数通常根据IEEE754手册来处理,但这可能导致严重的性能延迟。如果程序员愿意牺牲IEEE754兼容性来换取速度,那么x87提供了两个非IEEE754兼容模式用于需要速度的场合,这种场合下下溢和输入是频繁的:FTZ模式与DAZ模式。
当FTZ模式被允许时,一个下溢结果被自动转换为一个带有正确符号的零。尽管这个行为不与IEEE754顺从。由于当FTZ模式被允许时非规格化的结果不会产生,所以只有可能会遭遇FTZ模式的非规格化浮点数作为指定为常量的浮点数(只读)。
DAZ模式用于当运行一个SIMD浮点应用时高效地处理非规格话操作数。当DAZ模式被允许后,输入非规格化数被视作为带有相同符号的零。允许DAZ模式在性能作为主要目的时是处理非规格化浮点常量的一种方法。
如果脱离IEEE754说明是可接受的并且性能是决定性的,那么以FTZ和DAZ被允许的状态下运行SSE/SSE2/SSE3。
注:DAZ模式在SSE和SSE2扩展下都可用,尽管用这个模式所期望的速度提升完全仅在SSE代码下实现。
3.8.3 浮点模式
在奔腾III处理器上,FLDCW指令是一个昂贵的操作。在奔腾4处理器早先的型号,FLDCW仅为一个应用在x87 FPU控制字(FCW)的两个常量值之间交替的场合有所增强,诸如对整数执行转换时。在奔腾M、Intel Core Duo以及Intel Core 2 Duo处理器中,FLDCW相对于先前的处理器有所提升。
特定地,在奔腾4处理器的前两代中FLDCW的优化允许程序员在两个常量值之间高效地切换。为了使FLDCW优化有效,两个常量FCW值仅被允许在FCW中的以下5位有所不同:
FCW[8-9]:精度控制
FCW[10-11]:舍入模式
FCW[12]:无穷大控制
如果程序员需要修改FCW中的其它位(比如:屏蔽位),那么FLDCW指令仍然是一个昂贵的操作。
在一个应用在三个(或更多的)常量值之间循环的场景下,FLDCW指令优化不会应用,并且性能下降对每个FLDCW指令发生。
对这个问题的一种解决方法是选择两个常量FCW值,利用对FLDCW指令的优化仅在这两个常量FCW值之间进行交替,并且设计一些方法来实现需要第三个FCW值的任务,不需要实际上将FCW改变为一第三个常量值。一种可替代的解决方案是将那个代码进行结构化,以至于应用程序仅在两个常量FCW值之间每隔一段时间交替一次。当应用程序稍后在一对不同的FCW值之间进行交替时,程序性能仅在这个切换期间下降。
SIMD应用不太可能在FTZ与DAZ模式值之间进行切换,这个是所期待的。结果,SIMD控制字的延迟要比浮点控制寄存器的要长。对MXCSR寄存器的一次读具有一相当长的延迟,而对该寄存器的一次写是一个串行指令。
对于单精度和双精度,没有分开独立的控制字;两者都用同一个模式。值得注意的是,这应用于FTZ和DAZ模式。
汇编/编译器编码规则60:将对浮点控制字的改变最小化到8-12比特。对超过两个值的改变(每个值是下列比特的组合:精度、舍入和无穷大控制,以及在FCW中剩余的比特)导致流水线深度次序上的延迟。
3.8.3.1 舍入模式
许多库提供了浮点到整数的库例程,将浮点值转为整数。这些库中有许多遵从ANSI C编码标准,里面陈述了舍入模式应该是截断。在奔腾4处理器上,可以使用CVTTSD2SI以及CVTTSS2SI指令用截断来转换操作数而不需要改变舍入模式。使用这些指令来节省成本的方法已经足够证明了使用SSE以及SSE2是合理的,每当涉及到截断时尽可能使用。
对于x87浮点,FIST指令使用在浮点控制字(FCW)所表示的舍入模式。舍入模式通常采用“最近舍入”,所以许多编译器编写者在处理器中的舍入模式中实现了一个改变,为了顺从C和FORTRAN标准。这个实现需要使用FLDCW指令来改变处理器上的控制字。对于舍入、精度以及无穷大比特的改变,使用FSTCW指令来存储浮点控制字。然后,使用FLDCW指令来改变舍入模式为截断。
在一个典型的改变FCW中舍入模式的代码序列中,一条FSTCW指令通常后面跟着一次加载操作。从存储器的加载操作应该是一个16位的操作数以防止存储转运问题。如果对先前存储的FCW字的加载操作涉及到一个8位或一个32位操作,那么这将会导致一个存储转运问题,由于在存储操作与加载操作之间数据大小的误匹配。
为了避免存储转运问题,确保对FCW的写和读都是16位操作。
如果对舍入、精度和无穷大比特有超过1个比特的改变,并且舍入模式对结果并不重要,那么使用例3-58中的算法来避免同步问题、FLDCW指令的负荷以及不得不改变舍入模式。注意,那个例子遭受了存储转运问题,这将会导致性能处罚。然而,其性能仍然比改变舍入、精度以及无穷大位其中两个值来得更好。
例3-58:避免改变舍入模式的算法
_fto132proc lea ecx, [esp - 8] sub esp, 16 ; 分配帧 and ecx, -8 ; 对齐8的边界上的指针 fld st(0) ; 复制FPU栈顶 fistp qword ptr [ecx] fild qword ptr [ecx] mov edx, [ecx + 4] ; 整数的高DWORD mov eax, [ecx] ; 整数的低DWORD test eax, eax je integer_QnaN_or_zero integer_QnaN_or_zero: fsubp st(1), st ; TOS = d - round(d), { st(1) = (st(1) - st) & pop ST } test edx, edx ; 判定整数的符号 jns positive ; 数字是负数 fstp dword ptr [ecx] ; 减法的结果 mov ecx, [ecx] ; diff(single - precision)的DWORD add esp, 16 xor ecx, 80000000h add ecx, 7fffffffh ; 如果diff < 0,那么将整数减1 adc eax, 0 ; INC EAX(加进位标志位) ret positive: fstp dword ptr [eax] ; 减法17-18结果 mov ecx, [ecx] ; diff(单精度)的DWORD add esp, 16 add ecx, 7fffffffh ; 如果diff < 0,那么将整数减1 sbb eax, 0 ; DEC EAX(减进位标志) ret integer_QnaN_or_zero: test edx, 7fffffffh jnz arg_is_not_integer_QnaN add esp, 16 ret
汇编/编译器编码规则61:最小化对舍入模式改变次数。不要在舍入模式中使用改变来实现取底和取顶功能,如果这涉及到总共超过舍入、精度和无穷大比特集的两个值。
3.8.3.2 精度
如果单精度足够的话,那么就使用单精度而不是双精度。这个是真的,因为:
● 单精度操作允许对更长的SIMD向量的使用,由于有更多的单精度数据元素能适应在一个SIMD寄存器中。
● 如果x87 FPU控制字中的精度控制(PC)域对单精度置1,那么浮点除法器能比起对一双精度计算或一个扩展双精度计算来,完成一单精度计算要快得多。如果PC域被设置为双精度,那么这将允许对双精度数据的那些x87 FPU操作与扩展双精度计算比起来更快地完成计算。这些特征影响了包括浮点除法和平方根在内的计算。
汇编/编译器编码规则62:最小化对精度模式的改变次数。
3.8.3.3 提升并行度并对FXCH的使用
x87指令集对其其中的一个操作数依赖于浮点栈。如果依赖图是一颗树,而这树意味着每个中间结果仅被使用一次并且代码被仔细安排,那么经常可以仅使用栈顶或存储器中的操作数,以避免使用被埋在栈顶下面的操作数。当操作数需要从栈的中部被拉出来时,可以使用一条FXCH指令来切换栈顶与栈上另一条目的操作数。
FXCH指令也能被用于提升并行度。可以叠交依赖链来向硬件调度器暴露出更多独立的指令。一条FXCH指令可能需要有效地寄存器名字空间以至于有更多操作数可以是同时活动的。
然而,在基于Intel NetBurst微架构的处理器中,FXCH抑制了踪迹Cache中的发布带宽。FXCH这么做不仅是因为它消耗了一个槽,而且也因为利用FXCH的发布槽的限制。如果应用程序不被发布或隐退带宽所绑定,那么FXCH将没有影响。
在基于Intel NetBurst微架构处理器的有效指令窗口大小足够大来允许远离下一个迭代的指令被叠交。这经常避免了使用FXCH来提升并行度的需要。
FXCH指令应该仅当它需要表达一个算法或增强并行度的时候被使用。如果寄存器名字空间的大小是一个问题,那么推荐对XMM寄存器的使用。
汇编/编译器编码规则63:仅在需要增加有效名字空间的地方使用FXCH。
这反过来允许指令被重新安排以及对并行可用。无序执行阻止了对使用FXCH来搬移很短距离的指令的必要性。
3.8.4 x87 vs SIMD浮点权衡
在x87浮点代码与标量浮点代码(使用SSE和SSE2)之间有一些不同点。以下不同点应该驱使要使用哪些寄存器和指令的决定:
● 当一个SIMD浮点指令的一个输入操作数含有比可表示的数据类型范围更小的值时,一个非正常的异常发生。这导致了一个非常严重的性能处罚。一个SIMD操作具有一个冲刷为零模式,在此模式下结果不会下溢。从而后面的计算将不会面对处理非正常输入操作数的性能处罚。比如,在具有低光照程度的3D应用的情况下,使用冲刷为零模式能将性能提升比起具有大量下溢的应用来有50%之多。
● 标量浮点SIMD指令具有比起等价的x87指令具有更少的延迟。标量SIMD浮点乘法指令可以被流水化,而x87乘法指令则不能。
● 只有x87支持超越指令。
● x87支持80位浮点,双精度扩展浮点。SSE支持最大32位精度。SSE2支持最大64位精度。
● 标量浮点寄存器可以被直接访问,避免FXCH以及栈顶约束。
● 在基于NetBurst微架构的处理器中用SSE2以及SSE将浮点带有截断的转为整数的成本比起对舍入模式的改变或先前在例3-58中描述的代码序列要小很多。
汇编/编译器编码规则64:使用SSE2或SSE,除非你需要x87特性。大部分SSE2算术操作具有比起x87来更短的延迟,并且它们消除了与x87寄存器栈管理相关联的负荷。
3.8.4.1 在Intel Core Solo以及Intel Core Duo处理器上的标量SSE/SSE2性能
在Intel Core Solo以及Intel Core Duo处理器上,提升的译码以及微操作融合的结合允许原来是二、三和四个微操作的指令走过所有的译码器。作为这样的一个结果,标量SSE/SSE2代码能匹配x87代码执行通过两个浮点单元的性能。在奔腾M处理器上,标量SSE/SSE2代码会表现出大约30%的性能下降,相对于通过两个浮点单元的x87代码执行。
在具有将浮点转为整型的转换、单精度除法指令,或精度改变的代码序列中,由一个编译器生成的x87代码一般以单精度的方式将数据写入存储器并再次将它读出为了减少减少精度。使用SSE/SSE2标量代码而不是x87代码能生成一个巨大的性能收益,在基于Intel NetBurst微架构上,并且在Intel Core Solo与Intel Core Duo处理器上有适度的性能收益。
建议:使用编译器开关来生成SSE2标量浮点代码而不是x87代码。
当编写标量SSE/SSE2代码时,注意清除一个XMM寄存器中未使用槽的内容的需要以及相关的性能影响。
比如,从存储器用MOVSS或MOVSD加载数据引起一个额外的微操作来将XMM寄存器的高位部分清零。
在奔腾M,Intel Core Solo,以及Intel Core Duo处理器中,这个处罚可以通过使用MOVLPD来避免。然而,使用MOVLPD在奔腾4处理器上导致一个性能处罚。
当混合单精度与双精度代码时会发生另一种情况。在基于Intel NetBurst微架构的处理器中,使用CVTSS2SD具有相对于下列可替换的代码序列的性能处罚:
XORPS XMM1, XMM1
MOVSS XMM1, XMM2
CVTPS2PD XMM1, XMM1
在Intel Core Solo以及Intel Core Duo处理器中,使用CVTSS2SD比起此替换序列更可取。
3.8.4.2 带整数操作数的x87浮点操作
对于基于Intel NetBurst微架构的处理器,将采用16位整型操作数的浮点操作(FIADD, FISUB, FIMUL以及FIDIV)分开为两条指令(FILD与一个浮点操作)更为高效。然而,对于具有32位整型操作数的浮点操作,使用FIADD、FISUB、FIMUL以及FIDIV相比于使用独立的指令是同样高效的。
汇编/编译器编码规则65:对于FILD设法使用32位操作数而不是16位的操作数。然而,在对于通过分别写两个32位存储器操作数的一半而引入一个存储转运问题的开销则不要这么做。
3.8.4.3 x87浮点比较指令
当执行x87浮点比较时应该使用FCOMI和FCMOV指令。使用FCOM,FCOMP以及FCOMPP指令一般需要额外的指令,像FSTSW。后者替换方案引起更多要被解码的微操作,并且应该要被避免。
3.8.4.4 超越功能
如果一个应用程序需要用软件来模拟数学函数,为了性能或其它理由(见3.8.1小节),那么可以值得内联数学库调用,因为这个调用以及涉及到这样调用的序言/尾声会严重影响操作的延迟。
注意,超越函数仅在x87浮点中支持,而不在SSE或SSE2中支持。