转自 http://msdn.microsoft.com/zh-cn/library/aa289157(v=vs.71).aspx
摘要:了解如何使用 Microsoft Visual C++ 2005(以前称为 Visual C++“Whidbey”)的浮点语义管理方法来优化浮点代码。创建快速程序,同时确保仅对浮点代码执行安全优化。
C++ 中的浮点代码优化
浮点语义的 fp:precise 模式
浮点语义的 fp:fast 模式
浮点语义的 fp:strict 模式
fenv_access 杂注
fp_contract 杂注
float_control 杂注
启用浮点异常语义
相关书籍
C++ 优化编译器不仅能够将源代码转换为机器码,而且能够对机器指令进行适当的排列以便改善性 能和/或减小大小。遗憾的是,许多常用的优化在应用于浮点计算时未必安全。在下面的求和算法 [1] 中,可以看到这方面的一个恰当的示例:
float KahanSum( const float A[], int n ) { float sum=0, C=0, Y, T; for (int i=0; i<n; i++) { Y = A[i] - C; T = sum + Y; C = T - sum - Y; sum = T; } return sum; }
该函数将数组向量 A 中的 n 个浮点值相加。在循环体中,算法计算 一个“修正”值,然后将其应用于求和的下一步。与简单的求和相比,该方法大大减小了累积性舍入 误差,同时保持了 O(n) 时间复杂性。
一个不完善的 C++ 编译器可能假设浮点算法遵循与实数算法相同的代数规则。这样的编译器可能 继而错误地断定
C = T - sum - Y ==> (sum+Y)-sum-Y ==> 0;
也就是说,C 得到的值总是常量零。如果随后将该常量值传播到后续表达式中,循环体将化简为简 单的求和。更准确地说,就是
Y = A[i] - C ==> Y = A[i] T = sum + Y ==> T = sum + A[i] sum = T ==> sum = sum + A[i]
因此,对于不完善的编译器而言,KahanSum 函数的逻辑转换将是:
float KahanSum( const float A[], int n ) { float sum=0; // C, Y & T are now unused for (int i=0; i<n; i++) sum = sum + A[i]; return sum; }
尽管转换后的算法更快,但它根本没有准确表达程序员的意图。精心设计的误差修正已经 被完全消除,只剩下一个具有所有其关联误差的简单的直接求和算法。
当然,完善的 C++ 编译器知道实数算法的代数规则通常并不适用于浮点算法。然而,即使是完善 的 C++ 编译器,也可能错误地解释程序员的意图。
考虑一种常见的优化措施,它试图在寄存器中存放尽可能多的值(称为“登记”值)。在 KahanSum 示例中,这一优化可能试图登记变量 C、Y 和 T,因为这些变量仅在循环体内使用。如果寄存器精度为 52 位(双精度)而不是 23 位(单精度),这一优化可以有效地将 C、Y 和 T 的类 型提升为 double。如果没有以同样的方式登记 sum 变量,则它仍将编 码为单精度。这会将KahanSum 的语义转换为下面的语义
float KahanSum( const float A[], int n ) { float sum=0; double C=0, Y, T; // now held in-register for (int i=0; i<n; i++) { Y = A[i] - C; T = sum + Y; C = T - sum - Y; sum = (float) T; } return sum; }
尽管现在 Y、T 和 C 以更高的精度进行计算,但新的编码可能产生精确性较低的结果,具体取决 于 A[] 中的值。因而,即使看起来无害的优化也可能具有消极的后果。
这些种类的优化问题并不局限于“棘手”的浮点代码。即使是简单的浮点算法,在经过错误的优化 后也可能失败。考虑一个简单的直接求和算法:
float Sum( const float A[], int n ) { float sum=0; for (int i=0; i<n; i++) sum = sum + A[i]; return sum; }
因为一些浮点单元能够同时执行多个运算,所以编译器可能选择采用标量简化 优化。这一 优化有效地将简单的 Sum 函数从上述形式转换为以下形式:
float Sum( const float A[], int n ) { int n4 = n-n%4; // or n4=n4&(~3) int i; float sum=0, sum1=0, sum2=0, sum3=0; for (i=0; i<n4; i+=4) { sum = sum + A[i]; sum1 = sum1 + A[i+1]; sum2 = sum2 + A[i+2]; sum3 = sum3 + A[i+3]; } sum = sum + sum1 + sum2 + sum3; for (; i<n; i++) sum = sum + A[i]; return sum; }
该函数现在保持了四个独立的求和运算,它们可以在每个步骤同时处理。尽管优化后的函数现在要 快得多,但优化结果可能与非优化结果完全不同。在进行这一变化时,编译器采用了具有结合律的浮 点加法;即以下两个表达式等效:(a+b)+c == a+(b+c)。然而,对于浮点数而言,结合律并不总是适 用。现在,转换后的函数不是按以下方法求和:
sum = A[0]+A[1]+A[2]+...+A[n-1]
而是按以下方法计算结果:
sum = (A[0]+A[4]+A[8]+...) +(A[1]+A[5]+A[9]+...) +(A[2]+A[6]+A[10]+...) +(A[3]+A[7]+A[11]+...) +...
对于 A[] 的某些值而言,不同的加法运算顺序可能产生意外的结果。更为复杂的是,某些程序员 可能选择预先针对此类优化做准备,并相应地对这些优化进行补偿。在此情况下,程序可以按不同的 顺序构建数组 A,以便优化的 sum 产生预期的结果。而且,在许多情况 下,优化结果的精确性可能“足够严密”。当优化提供了令人信服的速度优点时,尤其如此。例如, 视频游戏要求具有尽可能快的速度,但通常并不要求进行高度精确的浮点计算。因此,编译器制造商 必须为程序员提供一种机制,以便控制速度和精确性之间经常背离的目标。
某些编译器通过为每种类型的优化单独提供“开关”在速度和精确性之间进行折衷。这使开发人员 可以禁用可能为其特定应用程序的浮点精确性带来变化的优化。尽管该解决方案可能提供对编译器的 高度控制,但它也会带来其他一些问题:
通常很难搞清楚需要启用或禁用哪些开关。
禁用任一优化都可能对非浮点代码的性能带来不利影响。
每个附加的开关都会引起许多新的开关组合;组合数目将很快变得难以控制。
因此,尽管为每种优化提供单独的开关看起来似乎很有吸引力,但使用此类编译器可能非常麻烦并 且不可靠。
许多 C++ 编译器提供了“一致性”浮点模型(通过 /Op 或 /fltconsistency 开关),从而使开 发人员能够创建符合严格浮点语义的程序。采用该模型时,可以防止编译器对浮点计算使用大多数优 化,同时允许其对非浮点代码使用这些优化。但是,该一致性模型具有一个缺点。为了在不同的 FPU 体系结构中返回可预测的结果,几乎所有 /Op 实现都将中间表达式舍入到用户指定的精度;例如,考 虑下面的表达式:
float a, b, c, d, e; . . . a = b*c + d*e;
为了在使用 /Op 开关时产生一致的且可重复的结果,该表达式的计算方式按如下方式实现:
float x = b*c; float y = d*e; a = x+y;
现在,最终结果在计算该表达式的每一步 中都产生了单精度舍入误差。尽管这种解释在严 格意义上并未破坏任何 C++ 语义规则,但它几乎肯定不是计算浮点表达式的最佳方法。通常,以尽可能高的可行精度计算中间结果 更为可取。例如,以如下所示的较高精度计算表达式 a=b*c+d*e 将会更好:
double x = b*c; double y = d*e; double z = x+y; a = (float)z;
或者,采用以下方式会更好:
long double x = b*c; long double y = d*e long double z = x+y; a = (float)z;
在以较高精度计算中间结果时,最终结果显然会更为精确。具有讽刺意味的是,如果采用一致性模 型,则当用户试图通过禁用不安全的优化来减少误差时,出现误差的可能性却恰恰增加了。因此,一 致性模型不仅严重降低了效率,同时还无法对精确性的提高提供任何保证。对于认真的数值程序员而 言,这看起来不像是一个很好的折衷,这也是该模型没有被广泛接受的主要原因。
从版本 8.0 (Visual C++?2005) 开始,Microsoft C++ 编译器提供了一种更好的选择。它使程序 员可以选择以下三种常规浮点模式之一:fp:precise、fp:fast 和 fp:strict。
在 fp:precise 模式下,仅对浮点代码执行安全优化,并且与 /Op 不同,以最 高可行 精度一致性地执行中间计算。
fp:fast 模式放松了浮点规则,允许以牺牲精确性为代价进行更为积极的优化。
fp:strict 模式提供了 fp:precise 的所有常规正确性,同时启用了 fp- exception 语义,并禁止在存在 FPU 环境更改(例如,对寄存器精度、舍入方向的更改等等)时进行 非法转换。
可以通过命令行开关或编译器杂注单独控制浮点异常语义;默认情况下,在 fp:precise 模式下禁 用浮点异常语义,而在 fp:strict 模式下启用该语义。编译器还提供了对 FPU 环境敏感性和某些特 定于浮点的优化(如化简)的控制。这一简单明了的模型为开发人员提供了大量针对浮点代码编译的 控制,并且无须使用太多的编译器开关,也不会带来令人讨厌的副作用。
默认的浮点语义模式是 fp:precise。如果选择该模式,编译器在优化浮点操作时将严格遵守一组 安全规则。这些规则使编译器可以在保持浮点计算精确性的前提下生成高效的机器码。为便于产生快 速的程序,fp:precise 模式禁用了浮点异常语义(尽管可以显式启用这些语义)。Microsoft 已经选 择 fp:precise 作为默认的浮点模式,因为这种模式能够创建既快速又精确的程序。
要使用命令行编译器显式请求 fp:precise,请使用 -fp:precise 开关:
cl -fp:precise source.cpp
这将指示编译器在为 source.cpp 文件生成代码时使用 fp:precise 语义。还可以使用 float_control 编译器杂注逐个函数地调用 fp:precise 模式。
在 fp:precise 模式下,编译器绝不会执行任何干扰浮点计算精确性的优化。编译器在执行赋值、 类型转换和函数调用时将始终正确地进行舍入,并且将按照与 FPU 寄存器相同的精度一致地执行中间 舍入。默认情况下,将启用安全优化(如化简)。默认情况下,将禁用异常语义和 FPU 环境敏感性。
fp:precise 浮点语义 |
解释 |
---|---|
Rounding Semantics |
在执行赋值、类型转换和函数调用时进行显式舍入,并且按寄存器精 度计算中间表达式 |
Algebraic Transformations |
严格遵守非结合性、非分配性的浮点代数,除非能够保证转换总是产 生相同的结果。 |
化简 |
默认情况下允许(另请参阅 The fp_contract Pragma) |
Order of Floating-point Evaluation |
如果不会改变最终结果,编译器可能重新排列浮点表达式的计算顺序 。 |
FPU Environment Access |
默认情况下禁用(另请参阅 The fpenv_access Pragma)。采用默认的精度和舍入模式。 |
Floating-point Exception Semantics |
默认情况下禁用(另请参阅 fp:except switch) |
fp:precise 模式总是以最高的可行 精度执行中间计算,只在表达式计算过程中的特定位 置执行显式舍入。总是 在下列四个位置舍入到用户指定的精度:(a) 进行赋值时;(b) 执行 类型转换时;(c) 浮点值作为参数传递给函数时;(d) 从函数返回浮点值时。因为总是按寄存器精度 执行中间计算,所以中间结果的精确性与平台相关(尽管精度将总是起码与用户指定的精度相当)。
考虑以下代码中的赋值表达式。赋值运算符“=”右侧的表达式将按寄存器精度计算,然后显式舍 入到赋值运算符左侧的类型。
float a, b, c, d; double a; ... x = a*b + c*d;
被计算为
float a, b, c, d; double a; ... register tmp1 = a*b; register tmp2 = c*d; register tmp3 = tmp1+tmp2; x = (double) tmp3;
要显式舍入中间结果,请引入类型转换。例如,如果通过添加显式类型转换来修改前面的代码,中 间表达式 (c*d) 将被舍入到类型转换的类型。
float a, b, c, d; double a; . . . x = a*b + (float)(c*d);
被计算为
float a, b, c, d; double a; . . . register tmp1 = a*b; float tmp2 = c*d; register tmp3 = tmp1+tmp2; x = (double) tmp3
该舍入方法的一个含义是某些似乎等效的转换实际上并不具有完全相同的语义。例如,下面的转换 将一个赋值表达式拆分为两个赋值表达式。
float a, b, c, d; . . . a = b*(c+d);
“不”等效于
float a, b, c, d; . . . a = c+d; a = b*a;
同样:
a = b*(c+d);
“不”等效于
a = b*(a=c+d);
这些编码方式不具有等效的语义,因为第二种编码方式引入了附加的赋值操作,由此引入了附加的 舍入点。
当函数返回浮点值时,该值将被舍入到函数的类型。当浮点值作为参数传递给函数时,该值将被舍 入到参数的类型。例如:
float sumsqr(float a, float b) { return a*a + b*b; }
被计算为
float sumsqr(float a, float b) { register tmp3 = a*a; register tmp4 = b*b; register tmp5 = tmp3+tmp4; return (float) tmp5; }
同样:
float w, x, y, z; double c; ... c = symsqr(w*x+y, z);
被计算为
float x, y, z; double c; ... register tmp1 = w*x; register tmp2 = tmp1+y; float tmp3 = tmp2; c = symsqr( tmp3, z);
fp:precise 模式下特定于体系结构的舍入
处理器 |
中间表达式的舍入精度 |
---|---|
x86 |
按照具有 16 位指数所提供的扩展范围的、默认的 53 位精度计算中 间表达式。当这些 53:16 值被“倒”入内存时(这在函数调用期间可能发生),扩展指数范围将被缩 小到 11 位。也就是说,倒入内存的值被转换为标准的双精度格式,只具有 11 位指数。 通过使用 _controlfp 改变浮点控制字或者通过启用 FPU 环境访问,用户可以为中间结果 的舍入切换到扩展的 64 位精度(请参阅 fpenv_access 杂注)。然而,当扩展精度寄存器值被倒入内存时,中间结果仍 将舍入到双精度。 这一特定语义随时可能更改。 |
ia64 |
中间表达式总是按 64 位精度(即扩展精度)计算。 |
amd64 |
amd64 上的 FP 语义与其他平台有所不同。出于性能原因,中间运算 按各个操作数的最高精度计算,而不是按可用的最高精度计算。要强迫计算使用比操作数更高的精度 执行,用户需要至少对子表达式中的一个操作数引入转换操作。 这一特殊语义随时可能更改。 |
当启用 fp:precise 模式时,编译器绝不会执行代数转换,除非可以证明最终结果完全相同 。对于浮点算法而言,许多惯用的实数算法代数规则有时并不适用。例如,下列表达式对于实数 是等效的,但对于浮点数却未必等效。
形式 |
说明 |
---|---|
(a+b)+c = a+(b+c) |
加法结合律 |
(a*b)*c = a*(b*c) |
乘法结合律 |
a*(b+c) = a*b + b*c |
乘法对于加法的分配律 |
(a+b)(a-b) = a*a-b*b |
代数因式分解 |
a/b = a*(1/b) |
通过乘法逆元素计算除法 |
a*1.0 = a |
乘法恒等式 |
正如本文开始时介绍的示例函数 KahanSum 中所描述的,编译器可能倾向于执行各种代数转换,以 便产生速度快得多的程序。尽管依赖于此类代数转换的优化几乎总是不正确的,但有时这些优化是完 全安全的。例如,有时将除以一个常量 值替换为乘以该常量的倒数是可取的:
const double four = 4.0; double a, b; ... a = b/four;
可以转换为
const double four = 4.0; const double tmp0 = 1/4.0; double a, b; ... a = b*tmp0;
该转换是安全的,因为优化器在编译时可以确定:对于所有浮点值 x(包括无穷大和 NaN), x/4.0 == x*(1/4.0)。通过将除法运算替换为乘法运算,编译器可以省去多个循环 — 尤其是在未直 接实现除法的 FPU 上,但要求编译器生成倒数近似和乘法-加法指令的组合。在 fp:precise 模式下 ,只有当替换乘法能够产生与除法完全相同的结果时,编译器才会执行此类优化。在 fp:precise 模 式下,编译器还可能执行一些普通的转换,但前提是结果完全相同。这些转换包括:
形式 |
说明 |
---|---|
(a+b) == (b+a) |
加法交换律 |
(a*b) == (b*a) |
乘法交换律 |
1.0*x*y == x*1.0*y == x*y*1.0 == x*y |
乘以 1.0 |
x/1.0*y == x*y/1.0 == x*y |
除以 1.0 |
2.0*x == x+x |
乘以 2.0 |
许多现代浮点单元的一项主要体系结构功能,是能够将一个乘法与后面紧跟的加法作为单个运算执 行,且没有中间舍入误差。例如,Intel 的 Itanium 体系结构提供了一些指令,将三元运算 (a*b+c) 、(a*b-c) 和 (c-a*b) 中的每一个都组合为单个浮点指令(分别为 fma、fms 和 fnma)。这些单个 指令都比执行独立的乘法和加法指令快,并且因为没有中间乘法舍入,所以更为精确。该优化可以显 著提高那些含有多个交错乘法和加法运算的函数的速度。例如,考虑以下计算两个 n 维向量的点积的 算法。
float dotProduct( float x[], float y[], int n ) { float p=0.0; for (int i=0; i<n; i++) p += x[i]*y[i]; return p; }
该计算可以通过一系列乘法-加法指令执行,形式为:p = p + x[i]*y[i] 。
该化简优化可以使用 fp_contract 编译器杂注单独进行控制。默认情况下,fp:precise 模式允许进行化简,因为它们能够提高精确性和速度。在 fp:precise 模式下,编译器不会化简带有 显式舍入运算的表达式。
示例
float a, b, c, d, e, t; ... d = a*b + c; // may be contracted d += a*b; // may be contracted d = a*b + e*d; // may be contracted into a mult followed by a mult-add etc... d = (float)a*b + c; // won't be contracted because of explicit rounding t = a*b; // (this assignment rounds a*b to float) d = t + c; // won't be contracted because of rounding of a*b
能够保留浮点表达式计算顺序的优化总是安全的,因而在 fp:precise 模式下是允许的。考虑下面 的函数,该函数计算两个单精度 n 维向量的点积。下面的第一个代码块是程序员可能编写的原函数, 后面跟着的是经过局部循环展开优化之后的同一函数(改动部分用斜体 表示)。
//original function float dotProduct( float x[], float y[], int n ) { float p=0; for (int i=0; i<n; i++) p += x[i]*y[i]; return p; } //after a partial loop-unrolling float dotProduct( float x[], float y[], int n ) { int n4= n/4*4; // or n4=n&(~3); float p=0; int i; for (i=0; i<n4; i+=4) { p+=x[i]*y[i]; p+=x[i+1]*y[i+1]; p+=x[i+2]*y[i+2]; p+=x[i+3]*y[i+3]; } // last n%4 elements for (; i<n; i++) p+=x[i]*y[i]; return p; }
该优化的主要优点是它将条件循环分支的数量减少了 75%。同时,通过增加循环体内运算的数量, 编译器现在可以有更多的机会来进一步优化。例如,某些 FPU 或许能够在执行 p+=x[i]*y[i] 中的乘 法-加法的同时取出 x[i+1] 和 y[i+1] 的值,以便在下一步中使用。此类优化对于浮点计算而言是完 全安全的,因为它保留了运算的顺序。
对于编译器来说,重新排列全部运算的顺序以便产生更快的代码通常是有利的。考虑以下代码:
double a, b, c, d; double x, y, z; ... x = a*a*a + b*b*b + c*c*c; ... y = a*a + b*b + c*c; ... z = a + b + c;
C++ 语义规则表明,程序产生结果的顺序应该好像 它首先计算 x,然后计算 y,最后计算 z。假设编译器只有四个可用的浮点寄存器。如果编译器被迫按顺序计算 x、y 和 z,它可能选择用下 面的语义来生成代码:
double a, b, c, d; double x, y, z; register r0, r1, r2, r3; ... // Compute x r0 = a; // r1 = a*a*a r1 = r0*r0; r1 = r1*r0; r0 = b; // r2 = b*b*b r2 = r0*r0; r2 = r2*r0; r0 = c; // r3 = c*c*c r3 = r0*r0; r3 = r3*r0; r0 = r1 + r2; r0 = r0 + r3; x = r0; // x = r1+r2+r3 . . . // Compute y r0 = a; // r1 = a*a r1 = r0*r0; r0 = b; // r2 = b*b r2 = r0*r0; r0 = c; // r3 = c*c r3 = r0*r0; r0 = r1 + r2; r0 = r0 + r3; y = r0; // y = r1+r2+r3 . . . // Compute z r1 = a; r2 = b; r3 = c; r0 = r1 + r2; r0 = r0 + r3; z = r0; // z = r1+r2+r3
在这种编码方式中,有几个明显多余的运算(用 斜体 表示)。如果编译器严格遵 守 C++ 语义规则,则这一顺序是必要的,因为程序可能在每次赋值内部访问 FPU 环境。但是, fp:precise 的默认设置允许编译器进行优化,就好像程序没有访问该环境一样,从而允许其重新排列 这些表达式的顺序。因而,编译器可以自由地通过按相反顺序计算上述三个值来消除冗余,如下所示 :
double a, b, c, d; double x, y, z; register r0, r1, r2, r3; ... // Compute z r1 = a; r2 = b; r3 = c; r0 = r1+r2; r0 = r0+r3; z = r0; ... // Compute y r1 = r1*r1; r2 = r2*r2; r3 = r3*r3; r0 = r1+r2; r0 = r0+r3; y = r0; ... // Compute x r0 = a; r1 = r1*r0; r0 = b; r2 = r2*r0; r0 = c; r3 = r3*r0; r0 = r1+r2; r0 = r0+r3; x = r0;
这一编码方式显然要更好,它将浮点指令的数量减少了将近 40%。x、y 和 z 的结果与原来相同, 但计算时花费了较少的系统开销。
在 fp:precise 模式下,编译器还可以交错 计算常见的子表达式,从而产生更快的代码。 例如,可以按以下方式编写用于计算二次方程根的代码:
double a, b, c, root0, root1; ... root0 = (-b + sqrt(b*b-4*a*c))/(2*a); root1 = (-b - sqrt(b*b-4*a*c))/(2*a);
尽管这些表达式之间的不同仅在于一个运算,但程序员这样编写代码可以保证以最高的可行精度计 算每个根值。在 fp:precise 模式下,编译器可以自由地交错计算 root0 和 root1,以便消除常见的 子表达式,而不会丢失精度。例如,下面的代码已经消除了几个多余的步骤,同时还能够产生完全相 同的答案。
double a, b, c, root0, root1; ... register tmp0 = -b; register tmp1 = sqrt(b*b-4*a*c); register tmp2 = 2*a; root0 = (tmp0+tmp1)/tmp2; root1 = (tmp0-tmp1)/tmp2;
其他优化可能尝试移动某些独立表达式的计算。考虑下面的算法,它在循环体内含有一个条件分支 。
vector a(n); double d, s; . . . for (int i=0; i<n; i++) { if (abs(d)>1.0) s = s+a[i]/d; else s = s+a[i]*d; }
编译器可能检测到表达式 (abs(d)>1) 的值在循环体内保持不变。这使编译器可以将 if 语句“提 升”到循环体外部,从而将上述代码转换为以下形式:
vector a(n); double d, s; . . . if (abs(d)>1.0) for (int i=0; i<n; i++) s = s+a[i]/d; else for (int i=0; i<n; i++) s = s+a[i]*d;
进行转换之后,在任一循环体内都不再有条件分支,从而显著提高了循环的总体性能。这种类型的 优化是完全安全的,因为表达式 (abs(d)>1.0) 的计算独立于其他表达式 。
如果存在 FPU 环境访问或浮点异常,则上述类型的优化是不可取的,因为它们改变了语义流。此 类优化仅在 fp:precise 模式下可用,因为 FPU 环境访问和浮点异常语义默认情况下被禁用。访问 FPU 环境的函数可以通过使用 fenv_access 编译器杂注显式禁用此类优化。同样,使用浮点 异常的函数应该使用 float_control(except…) 编译器杂注(或者使用 /fp:except 命令行开关)。
总之,fp:precise 模式允许编译器重新排列浮点表达式的计算顺序,前提是最终结果不会改变, 并且结果不依赖于 FPU 环境或浮点异常。
当启用 fp:precise 模式时,编译器将假设程序不会访问或改变 FPU 环境。如前所述,这一假设 使编译器能够重新排列或移动浮点运算,以便提高 fp:precise 模式下的效率。
某些程序可能通过使用 _controlfp 函数来改变浮点舍入方向。例如,某些程序通过执行 同一计算两次来计算算术运算的上下误差边界:第一次向负无穷舍入,第二次向正无穷舍入。因为 FPU 提供了控制舍入的方便方法,所以程序员可以选择通过改变 FPU 环境来更改舍入模式。下面的代 码通过改变 FPU 环境来计算浮点乘法的确切误差范围。
double a, b, cLower, cUpper; . . . _controlfp( _RC_DOWN, _MCW_RC ); // round to -a?? cLower = a*b; _controlfp( _RC_UP, _MCW_RC ); // round to +a?? cUpper = a*b; _controlfp( _RC_NEAR, _MCW_RC ); // restore rounding mode
在 fp:precise 模式下,编译器总是采用默认的 FPU 环境,因此编译器可以自由地忽略对 _controlfp 的调用并将上述赋值简化为 cUpper = cLower = a*b ;这显然会产生不正确的结果。要避免此类优化,请通过使用 fenv_access 编译器杂注来启 用 FPU 环境访问。
其他程序可能尝试通过检查 FPU 的状态字来检测某些浮点错误。例如,下面的代码检查是否存在 被零除和不精确的状态
double a, b, c, r; float x; . . . _clearfp(); r = (a*b + sqrt(b*b-4*a*c))/(2*a); if (_statusfp() & _SW_ZERODIVIDE) handle divide by zero as a special case _clearfp(); x = r; if (_statusfp() & _SW_INEXACT) handle inexact error as a special case etc...
在 fp:precise 模式下,将表达式计算重新排序的优化可能改变某些错误发生的位置。访问状态字 的程序应该通过使用 fenv_access 编译器杂注来启用 FPU 环境访问。
有关更多信息,请参阅杂注 fenv_access
在 fp:precise 模式下,默认情况下禁用浮点异常语义。大多数 C++ 程序员在处理异常浮点状态 时不喜欢使用系统和 C++ 异常。而且,正如前面所述,在优化浮点运算时,禁用浮点异常语义可以使 编译器获得较大的灵活性。在使用 fp:precise 模式时,请使用 fp:except 开关或 fp_control 杂注 来启用浮点异常语义。
另请参阅
启用浮点异常语义
如果启用 fp:fast 模式,编译器在优化浮点运算时将放松 fp:precise 使用的规则。该模式允许 编译器进一步优化浮点代码,以牺牲浮点精确性和正确性为代价换取速度。通过启用 fp:fast 模式, 那些不依赖高度精确浮点计算的程序可能在速度方面获得显著的提高。
可以使用命令行编译器开关来启用 fp:fast 浮点模式,如下所示:
cl -fp:fast source.cpp or cl /fp:fast source.cpp
该示例指示编译器在为 source.cpp 文件生成代码时使用 fp:fast 语义。还可以使用 float_control 编译器杂注逐个函数地调用 fp:fast 模式。
另请参阅
float_control 杂注
在 fp:fast 模式下,编译器可能执行改变浮点计算精确度的优化。编译器在执行赋值、类型转换 或函数调用时可能无法正确地舍入,并且不总是执行中间舍入。特定于浮点的优化(如化简)总是被 启用。浮点异常语义和 FPU 环境敏感性被禁用,因而不可用。
fp:fast 浮点语义 |
解释 |
---|---|
舍入语义 |
在执行赋值、类型转换和函数调用时的显式舍入可能被忽略。 可能按照性能要求以低于寄存器的精度舍入中间表达式。 |
代数转换 |
编译器可能按照实数结合性、分配性代数转换表达式;不能保证这些 转换的精确性和正确性。 |
化简 |
总是启用;不能通过杂注 fp_contract 禁用 |
浮点计算的顺序 |
编译器可以将浮点表达式的计算重新排序,即使这种更改可能改变最 终的结果 |
FPU 环境访问 |
禁用。不可用 |
浮点异常语义 |
禁用。不可用 |
与 fp:precise 模式不同,fp:fast 模式以最方便的精度执行中间计算。在执行赋值、类型转换和 函数调用时不总是发生舍入。例如,下面的第一个函数引入了三个单精度变量(C、 Y 和 T)。编译器可能选择登记这些变量,以便将 C、Y 和 T 的类型提升到双精度。
原函数:
float KahanSum( const float A[], int n ) { float sum=0, C=0, Y, T; for (int i=0; i<n; i++) { Y = A[i] - C; T = sum + Y; C = T - sum - Y; sum = T; } return sum; }
变量被登记:
float KahanSum( const float A[], int n ) { float sum=0; double C=0, Y, T; // now held in-register for (int i=0; i<n; i++) { Y = A[i] - C; T = sum + Y; C = T - sum - Y; sum = (float) T; } return sum; }
在该示例中,fp:fast 推翻了原函数的意图。最终的优化结果(保存在变量 sum 中)可能 与正确的结果大不相同。
在 fp:fast 模式下,编译器通常会试图起码保持源代码所指定的精度。然而,在某些情况下,编 译器可能选择以比源代码所指定的精度更低的精度 来执行中间表达式。例如,下面的第一段 代码块调用了平方根函数的双精度版本。在 fp:fast 模式下,编译器可以选择将对双精度 sqrt 的调用替换为对单精度 sqrt 函数的调用。这样做的结果是在执行函数调用时引 入附加的低精度舍入。
原函数
double sqrt(double)... . . . double a, b, c; . . . double length = sqrt(a*a + b*b + c*c);
优化函数
float sqrtf(float)... . . . double a, b, c; . . . double tmp0 = a*a + b*b + c*c; float tmp1 = tmp0; // round of parameter value float tmp2 = sqrtf(tmp1); // rounded sqrt result double length = (double) tmp2;
尽管精确性降低,但在面向那些提供函数(如 sqrt)的单精度、内部版本的处理器时,这 一优化可能特别有用。编译器究竟何时使用此类优化取决于平台和上下文。
而且,无法保证中间计算的精度的一致性,可以在编译器可用的任意精度级别执行中间计算。尽管 编译器将试图起码保持代码所指定的精度级别,但 fp:fast 允许优化器降低中间计算的精度,以便产 生更快或更小的机器码。例如,编译器可以进一步优化上述代码,将某些中间乘法舍入到单精度。
float sqrtf(float)... . . . double a, b, c; . . . float tmp0 = a*a; // round intermediate a*a to single- precision float tmp1 = b*b; // round intermediate b*b to single- precision double tmp2 = c*c; // do NOT round intermediate c*c to single- precision float tmp3 = tmp0 + tmp1 + tmp2; float tmp4 = sqrtf(tmp3); double length = (double) tmp4;
这种附加舍入可能来源于使用较低精度的浮点单元(如 SSE2)执行某些中间计算。因此,fp:fast 舍入的精确性与平台相关;对于一个处理器能够顺利编译的代码,对于其他处理器可能未必有效。应 该由用户确定速度优点是否能够胜过任何精确性问题。
如果 fp:fast 优化对于特定函数而言特别成问题,则可以使用 float_control 编译器杂 注将浮点模式局部切换到 fp:precise。
编译器可以通过 fp:fast 模式对浮点表达式执行特定的、不安全的代数转换。例如,在 fp:fast 模式下,可以采用下面的不安全优化。
原代码 |
Step #1 |
Step #2 |
---|---|---|
double a, b, c; double x, y, z; . . . y = (a+b); z = y a€_ a a€_ b; . . . c = x a€_ z; . . . c = x*z; . . . c = x-z; . . . c = x+z; . . . c = z-x; |
double a, b, c; double x, y, z; . . . y = (a+b); z = 0; . . . c = x a€_ 0; . . . c = x*0; . . . c = x-0; . . . c = x+0; . . . c = 0-x; |
double a, b, c; double x, y, z; . . . y = (a+b); z = 0; . . . c = x; . . . c = 0; . . . c = x; . . . c = x; . . . c = -x; |
在步骤 1 中,编译器观察到 z = y – a – b 总是等于零。尽管这在技术上是一个无效的结论,但在 fp:fast 模 式下是允许的。然后,编译器将常量值零传播到随后每个使用变量 z 的位置。在步骤 2 中, 编译器通过观察到 x-0==x、x*0==0 等等进一步执行优化。同样,尽管这些结论并不严格有效,但在 fp:fast 模式下是允许的。现在,经过优化的代码要快得多,但精确性可能大大降低,甚至是不正确 的。
当启用 fp:fast 模式时,编译器可能采用下列任一(不安全的)代数规则:
形式 |
说明 |
---|---|
(a+b)+c = a+(b+c) |
加法结合律 |
(a*b)*c = a*(b*c) |
乘法结合律 |
a*(b+c) = a*b + b*c |
乘法对于加法的分配性质 |
(a+b)(a-b) = a*a-b*b |
代数因式分解 |
a/b = a*(1/b) |
通过乘法逆元素计算除法 |
a*1.0 = a, a/1.0 = a |
乘法恒等式 |
a ±0.0 = a, 0.0-a = -a |
加法恒等式 |
a/a = 1.0, a-a = 0.0 |
相消 |
如果 fp:fast 优化对于特定函数而言特别成问题,则可以使用 float_control 编译器杂 注将浮点模式局部切换到 fp:precise。
与 fp:precise 模式不同,fp:fast 允许编译器重新排列浮点运算的顺序,以便产生更快的代码。 因此,fp:fast 模式下的某些优化可能不会保持预期的表达式顺序。例如,考虑以下计算两个 n 维向 量的点积的函数。
float dotProduct( float x[], float y[], int n ) { float p=0; for (int i=0; i<n; i++) p += x[i]*y[i]; return p; }
在 fp:fast 模式下,优化器可以对 dotProduct 函数执行高效的标量简化,从而按以下方式转换 该函数:
float dotProduct( float x[], float y[],int n ) { int n4= n/4*4; // or n4=n&(~3); float p=0, p2=0, p3=0, p4=0; int i; for (i=0; i<n4; i+=4) { p+=x[i]*y[i]; p2+=x[i+1]*y[i+1]; p3+=x[i+2]*y[i+2]; p4+=x[i+3]*y[i+3]; } p+=p2+p3+p4; // last n%4 elements for (; i<n; i++) p+=x[i]*y[i]; return p; }
在该函数的优化版本中,同时获得了四个独立的乘积-和,并随后将其相加。根据目标处理器的不 同,这一优化最高可以将 dotProduct 的计算速度提高四倍,但最终结果的精确性可能太低以至于完 全无用。如果此类优化对于单个函数或转换单元而言特别成问题,则可以使用 float_control 编译器杂注将浮点模式局部切换到 fp:precise。
如果启用 fp:strict 模式,编译器在优化浮点运算时将遵守 fp:precise 使用的所有相同规则。 该模式还启用浮点异常语义和 FPU 环境敏感性,并禁用某些特定的优化(如化简)。它是最严格的运 算模式。
可以使用命令行编译器开关来启用 fp:strict 浮点模式,如下所示:
cl -fp:strict source.cpp or cl /fp:strict source.cpp
该示例指示编译器在为 source.cpp 文件生成代码时使用 fp:strict 语义。还可以使用 float_control 编译器杂注逐个函数的调用 fp:strict 模式。
另请参阅:
float_control 杂注
在 fp:strict 模式下,编译器绝不会执行任何干扰浮点计算精确性的优化。编译器在执行赋值、 类型转换和函数调用时将始终正确地进行舍入,并且将按照与 FPU 寄存器相同的精度一致地执行中间 舍入。浮点异常语义和 FPU 环境敏感性默认情况下被启用。某些优化(如化简)被禁用,因为编译器 无法在所有情况下保证正确性。
fp:strict 浮点语义 |
解释 |
---|---|
舍入语义 |
在执行赋值、类型转换和函数调用时进行显式舍入,并且按寄存 器精度计算中间表达式。 与 fp:precise 模式相同 |
代数转换 |
严格遵守非结合性、非分配性的浮点代数,除非能够保证转换总是产 生相同的结果。 与 fp:precise 模式相同 |
化简 |
总是禁用 |
浮点计算的顺序 |
编译器不会重新排列浮点表达式的计算顺序 |
FPU 环境访问 |
总是启用。 |
浮点异常语义 |
默认情况下启用。 |
在 fp:strict 模式下,默认情况下启用浮点异常语义。要禁用这些语义,请使用“- fp:except-”开关,或者引入 float_control(except, off) 杂注。
另请参阅:
启用浮点异常语义
float_control 杂注
用法:
#pragma fenv_access( [ on | off ] )
编译器可以通过 fenv_access 杂注来进行某些可能推翻 FPU 标志测试和 FPU 模式更改的 优化。当 fenv_access 的状态被禁用时,编译器可以假设默认的 FPU 模式有效并且未测试 FPU 标志。默认情况下,对 fp:precise 模式禁用环境访问,尽管可以使用该杂注显式启用它。在 fp:strict 模式下,fenv_access 总是被启用并且不能禁用。在 fp:fast 模式下, fenv_access 总是被禁用并且不能启用。
如 fp:precise 一节中所述,某些程序员可能使用 _controlfp 函数改变浮点舍入方向。 例如,某些程序会执行同一计算两次来计算算术运算的上下误差边界:第一次向负无穷舍入,第二次 向正无穷舍入。因为 FPU 提供了控制舍入的方便方法,所以程序员可以选择通过改变 FPU 环境来更 改舍入模式。下面的代码通过改变 FPU 环境来计算浮点乘法的确切误差范围。
double a, b, cLower, cUpper; . . . _controlfp( _RC_DOWN, _MCW_RC ); // round to -8 cLower = a*b; _controlfp( _RC_UP, _MCW_RC ); // round to +8 cUpper = a*b; _controlfp( _RC_NEAR, _MCW_RC ); // restore rounding mode
当禁用时,fenv_access 杂注允许编译器采用默认的 FPU 环境;因此,编译器可以自由地 忽略对 _controlfp 的调用并将上述赋值简化为 cUpper = cLower = a*b。但是,当启用时,fenv_access 将禁止此类优化。
程序还可以检查 FPU 状态字以检测某些浮点错误。例如,下面的代码检查是否存在被零除和不精 确的情况
double a, b, c, r; float x; . . . _clearfp(); r = (a*b + sqrt(b*b-4*a*c))/(2*a); if (_statusfp() & _SW_ZERODIVIDE) handle divide by zero as a special case _clearfp(); x = (a*b + sqrt(b*b-4*a*c))/(2*a); if (_statusfp() & _SW_INEXACT) handle inexact error as a special case etc...
当 fenv_access 被禁用时,编译器可能重新排列浮点表达式的执行顺序,从而可能推翻 FPU 状态检查。启用 fenv_access 将禁止此类优化。
用法:
#pragma fp_contract( [ on | off ] )
如 fp:precise 一节中所述,化简是许多现代浮点单元的基础体系结构功能。化简功能可以将一个 乘法与后面紧跟的加法作为单个运算执行,且没有中间舍入误差的能力。例如,Intel 的 Itanium 体 系结构提供了一些指令,将三元运算 (a*b+c)、(a*b-c) 和 (c-a*b) 中的每一个都组合为单个浮点指 令(分别为 fma、fms 和 fnma)。这些单个指令比执行独立的乘法和加法指令快,并且因为没有中间 乘积舍入,所以更为精确。化简运算可以使计算 (a*b+c) 值的方式好像 两个运算都被计算到 无限精度,然后舍入到最接近的浮点数。该优化可以显著提高那些含有多个交错乘法和加法运算的函 数的速度。例如,考虑以下计算两个 n 维向量点积的算法。
float dotProduct( float x[], float y[], int n ) { float p=0.0; for (int i=0; i<n; i++) p += x[i]*y[i]; return p; }
该计算可以通过一系列乘法-加法指令执行,形式为:p = p + x[i]*y[i] 。
fp_contract 杂注规定了是否可以化简浮点表达式。默认情况下,fp:precise 模式考虑到 了化简,因为它们能够提高精确性和速度。对于 fp:fast 模式,总是启用化简。然而,因为化简可能 推翻错误状态的显式检测,所以在 fp:strict 模式下总是禁用 fp_contract 杂注。下面是一些在启用 fp_contract 杂注时可能被化简的表达式示例:
float a, b, c, d, e, t; ... d = a*b + c; // may be contracted d += a*b; // may be contracted d = a*b + e*d; // may be contracted into a mult followed by a mult-add etc... d = (float)a*b + c; // won't be contracted because of explicit rounding t = a*b; // (this assignment rounds a*b to float) d = t + c; // won't be contracted because of rounding of a*b
/fp:precise、/fp:fast、/fp:strict 和 /fp:except 开关可以逐个文件地控制浮点语义。 float_control 杂注可用来逐个函数地控制浮点语义。
用法:
#pragma float_control(push) #pragma float_control(pop) #pragma float_control( precise, on | off [, push] ) #pragma float_control( except, on | off [, push] )
杂注 float_control(push) 和 float_control(pop) 分别将浮点模式和异常选项的当前状态压入 堆栈或弹出堆栈。注意,fenv_access 和 fp_contract 杂注的状态不会受到杂注 float_control(push/pop) 的影响。
调用杂注 float_control(precise, on | off) 时将启用或禁用 precise 模式语义。同样,杂注 float_control(except, on | off) 将启用或禁用异常语义。只有在启用 precise 语义时,才能启用 异常语义。当提供可选的 push 参数时,将在更改语义之前压入 float_control 选项的状态。
命令行开关实际上是设置四个不同浮点杂注的简便方法。要逐个函数地显式选择特定的浮点语义模 式,请按下表所述,选择四个浮点选项杂注中的每一个:
float_control(precise) |
float_control(except) |
fp_contract |
fenv_access |
|
---|---|---|---|---|
-fp:strict |
on |
on |
off |
on |
-fp:strict -fp:except- |
on |
off |
off |
on |
-fp:precise |
on |
off |
on |
off |
-fp:precise fp:except |
on |
on |
on |
off |
-fp:fast |
off |
off |
on |
off |
例如,以下代码可显式启用 fp:fast 语义。
#pragma float_control( except, off ) // disable exception semantics #pragma float_control( precise, off ) // disable precise semantics #pragma fp_contract(on) // enable contractions #pragma fenv_access(off) // disable fpu environment sensitivity
注 在关闭“precise”语义之前,必须关闭异常语义。
某些异常的浮点状况(如被零除)可导致 FPU 报告硬件异常。默认情况下禁用浮点异常。通过用 _controlfp 函数修改 FPU 控制字,可以启用浮点异常。例如,下面的代码可启用被零除浮点 异常:
_clearfp(); // always call _clearfp before enabling/unmasking a FPU exception _controlfp( _EM_ZERODIVIDE, _MCW_EM );
当启用被零除异常时,任何分母等于零的除法运算都将导致报告 FPU 异常。
要将 FPU 控制字还原到默认模式,请使用
call _controlfp (_CW_DEFAULT, ~0)。
用 fp:except 标志启用浮点异常语义 与启用浮点异常不同。当启用浮点异 常语义 时,编译器必须考虑浮点运算引发异常的可能性。因为 FPU 是一个独立的处理器单元 ,所以在 FPU 中执行的指令可以与其他单元中的指令并发执行。
当启用浮点异常时,FPU 将暂停违反规则指令的执行,然后通过设置 FPU 状态字来报告异常状态 。当 CPU 到达下一条浮点指令时,它将首先检查是否存在挂起的 FPU 异常。如果有挂起的异常,处 理器将通过调用操作系统提供的异常处理程序来捕捉该异常。这意味着当浮点运算遇到异常状况时, 在执行下一个浮点运算之前不会检测到相应的异常。例如,下面的代码可捕捉被零除异常:
double a, b, c; . . . ...unmasking of FPU exceptions omitted... __try { b/c; // assume c==0.0 printf("This line shouldn't be reached when c==0.0\n"); c = 2.0*b; } __except( EXCEPTION_EXECUTE_HANDLER ) { printf("SEH Exception Detected\n"); }
如果在表达式 a=b/c 中出现被零除的情况,FPU 在执行表达式 2.0*b 中的下一个浮点运算之前将不会捕捉/引发异常。这将产生以下输出:
This line shouldn't be reached when c==0.0 SEH Exception Detected
与输出的第一行对应的 printf 不应该被执行;该语句被执行的原因是表达式 b/c 导致的浮点异常只有在执行到达 2.0*b 时才会引发。要恰好在执行 b/c 之后引发异常 ,编译器必须引入一条“等待”指令:
. . . __try { b/c; // assume this expression will cause a "divide-by-zero" exception __asm fwait; printf("This line shouldn't be reached when c==0.0\n"); c = 2.0*b; } . . .
该“等待”指令强迫处理器与 FPU 的状态同步并处理任何挂起的异常。只有当启用浮点语义时, 编译器才会生成这些“等待”指令。如果这些语义被禁用(默认情况下是这样),程序在使用浮点异 常时,可能遇到与上述错误类似的同步性错误。
当启用浮点语义时,编译器将不仅引入“等待”指令,还将禁止编译器在可能存在异常的情况下非 法优化浮点代码。这包括任何改变引发异常位置的转换。由于上述因素,启用浮点语义可能显著降低 所生成的机器码的效率,从而降低应用程序的性能。
在 fp:strict 模式下,默认情况下启用浮点异常语义。要在 fp:precise 模式下启用这些语义, 请将 -fp:except 开关添加到编译器命令行。还可以使用 float_control 杂注逐个函数地启用和禁用浮点异常语义。
就像所有硬件异常一样,浮点异常本身并不会导致 C++ 异常,而是触发结构化异常。要将浮点结 构化异常映射到 C++ 异常,用户可以采用自定义的 SEH 异常转换器。首先,引入与各个浮点异常对 应的 C++ 异常:
class float_exception : public std::exception {}; class fe_denormal_operand : public float_exception {}; class fe_divide_by_zero : public float_exception {}; class fe_inexact_result : public float_exception {}; class fe_invalid_operation : public float_exception {}; class fe_overflow : public float_exception {}; class fe_stack_check : public float_exception {}; class fe_underflow : public float_exception {};
然后,引入一个转换函数以便检测浮点 SEH 异常并引发相应的 C++ 异常。要使用该函数,请用运 行库中的 _set_se_translator(…) 函数设置 当前处理线程的结构化异常处理程序转换器。
void se_fe_trans_func( unsigned int u, EXCEPTION_POINTERS* pExp ) { switch (u) { case STATUS_FLOAT_DENORMAL_OPERAND: throw fe_denormal_operand(); case STATUS_FLOAT_DIVIDE_BY_ZERO: throw fe_divide_by_zero(); etc... }; } . . . _set_se_translator(se_fe_trans_func);
在初始化这一映射后,浮点异常的行为将与 C++ 异常一样。例如:
try { floating-point code that might throw divide-by-zero or other floating-point exception } catch(fe_divide_by_zero) { cout << "fe_divide_by_zero exception detected" << endl; } catch(float_exception) { cout << "float_exception exception detected" << endl; }
Methods and Applications of Interval Analysis
Improving Floating-Point Programming
参考
[1]Goldberg, David "What Every Computer Scientist Should Know About Floating-Point Arithmetic" Computing Surveys, 1991 年 3 月, pg. 203
关于作者
Eric Fleegal 是 Microsoft Visual C++ 工作组的成员,负责确定浮点代码生成的质量。他的兴趣包括验证数值计算和生成式编程。他最新的研究范围涉及到使用间隔分析对浮点优化进行验证。