GCC为什么不将a * a * a * a * a * a优化为(a * a * a)*(a * a * a)?


2120

我正在对科学应用程序进行一些数值优化。我注意到的一件事是,GCC将pow(a,2)通过将其编译为来优化该调用a*a,但是该调用pow(a,6)并未进行优化,实际上将调用该库函数pow,这大大降低了性能。(相反,可执行文件Intel C ++编译器icc将消除对的库调用pow(a,6)。)

我很好奇的是,当我替换pow(a,6)a*a*a*a*a*a使用GCC 4.5.1和选项“ -O3 -lm -funroll-loops -msse4”时,它使用5 mulsd条指令:

movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13

而如果我写(a*a*a)*(a*a*a),它将产生

movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm13, %xmm13

这将乘法指令的数量减少到3. icc具有相似的行为。

为什么编译器无法识别此优化技巧?


13
“识别pow(a,6)”是什么意思?
Varun Madiath 2011年

659
嗯...你知道a a a a a和(a a a)*(a a * a)与浮点数不一样吗?您必须为此使用-funsafe-math或-ffast-math或其他名称。
戴蒙

106
我建议您阅读David Goldberg撰写的“每位计算机科学家应该知道的有关浮点运算的知识”:download.oracle.com/docs/cd/E19957-01/806-3568/…之后,您将对以下内容有更全面的了解您刚刚走进的焦油坑!
Phil Armstrong,

189
一个完全合理的问题。20年前,我提出了相同的一般性问题,并且通过消除单个瓶颈,将蒙特卡洛模拟的执行时间从21小时减少到7小时。内循环中的代码在此过程中执行了13万亿次,但它使模拟进入了一个通宵的窗口。(请参阅下面的答案)

23
也许也(a*a)*(a*a)*(a*a)加入进来。乘法次数相同,但可能更准确。
Rok Kralj

Answers:


2738

因为浮点数学不是关联的。以浮点乘法将操作数分组的方式会影响答案的数值精度。

结果,大多数编译器在对浮点计算进行重新排序时非常保守,除非他们可以确定答案将保持不变,或者除非您告诉他们您不关心数值精度。例如:-fassociative-math选项的gcc允许GCC重新关联浮点运算,或甚至-ffast-math其允许甚至对速度精度的更积极的折衷选择。


10
是。使用-ffast-math可以进行这种优化。好主意!但是,由于我们的代码比速度更关注准确性,因此最好不要通过它。
xis

19
IIRC C99允许编译器执行此类“不安全”的FP优化,但是GCC(在x87以外的任何产品上)都在合理地尝试遵循IEEE 754,这不是“错误界限”。只有一个正确的答案
tc。

14
的实现细节pow不在这里或那里;这个答案甚至没有参考pow
斯蒂芬·佳能

14
@nedR:ICC默认为允许重新关联。如果要获得符合标准的行为,则需要设置-fp-model preciseICC。 clanggcc默认为严格符合wrt重新关联。
斯蒂芬·佳能

49
@xis,这不是真的-fassociative-math很麻烦;它只是a*a*a*a*a*a(a*a*a)*(a*a*a)是不同的。这与准确性无关;这是关于标准一致性和严格可重复的结果,例如,在任何编译器上都具有相同的结果。浮点数已经不准确。用编译很少不合适-fassociative-math
Paul Draper 2014年

652

Lambdageek正确指出,由于浮点数不具有关联性,因此a*a*a*a*a*ato的“优化”(a*a*a)*(a*a*a)可能会更改该值。这就是C99禁止使用它的原因(除非用户明确允许,通过编译器标志或编译指示)。通常,假定程序员是出于某种原因写了她所做的事情,而编译器应该尊重这一点。如果需要(a*a*a)*(a*a*a),请写下。

不过,写起来可能很痛苦。为什么使用时编译器不能做正确的事情pow(a,6)?因为这样做是错误的事情。在具有良好数学库的平台上,pow(a,6)a*a*a*a*a*a或都要准确得多(a*a*a)*(a*a*a)。为了提供一些数据,我在Mac Pro上进行了一个小实验,测量了在[1,2)之间的所有单精度浮点数的a ^ 6评估中的最差错误:

worst relative error using    powf(a, 6.f): 5.96e-08
worst relative error using (a*a*a)*(a*a*a): 2.94e-07
worst relative error using     a*a*a*a*a*a: 2.58e-07

使用pow而不是乘法树可以将错误范围限制为4。除非经过用户许可(例如通过-ffast-math),否则编译器不应(而且通常不会)进行“优化”以增加错误。

请注意,GCC提供__builtin_powi(x,n)了的替代方案pow( ),后者应生成一个内联乘法树。如果您要在准确性与性能之间进行权衡,但又不想启用快速计算,请使用该选项。


29
还要注意,Visual C ++提供了pow()的“增强”版本。通过调用_set_SSE2_enable(<flag>)flag=1,它将如果可能的话使用SSE2。这会稍微降低精度,但会提高速度(在某些情况下)。MSDN:_set_SSE2_enable()pow()
TkTech 2011年

18
@TkTech:任何降低的精度是由于Microsoft的实现,而不是所用寄存器的大小。如果库编写器有这样的动机,则可以仅使用32位寄存器来进行正确取整 pow。有一些基于SSE的pow实现比大多数基于x87的实现准确,也有一些在速度和准确性之间进行权衡的实现。
斯蒂芬·佳能

9
@TkTech:当然,我只是想澄清一下,准确性的降低是由于库编写者做出的选择,而不是使用SSE所固有的。
斯蒂芬·佳能

7
我很想知道您在这里用来计算相对误差的“金标准”是什么-我通常希望如此a*a*a*a*a*a,但事实并非如此!:)
j_random_hacker

8
@j_random_hacker:因为我正在比较单精度结果,所以双精度就可以满足黄金标准—从a a的aa的两倍的误差*大大小于任何单精度计算的误差。
斯蒂芬·佳能

168

另一个类似的情况下:大多数编译器不会优化a + b + c + d(a + b) + (c + d)(这是一个优化由于第二表达可以更好流水线)给出(即,作为和评价它(((a + b) + c) + d))。这也是由于极端情况:

float a = 1e35, b = 1e-5, c = -1e35, d = 1e-5;
printf("%e %e\n", a + b + c + d, (a + b) + (c + d));

这个输出 1.000000e-05 0.000000e+00


10
这并不完全相同。更改乘/除顺序(不包括被0除)比更改乘/除顺序更安全。以我的拙见,编译器应尝试将mults./divs关联起来。因为这样做可以减少操作总数,并且除了性能提高以外,还可以提高精度。
CoffeDeveloper

4
@DarioOO:这不是更安全。乘法和除法与指数的加法和减法相同,并且更改顺序很容易导致临时值超出指数的可能范围。(不完全相同,因为指数不会遭受精度损失……但是表示形式仍然很有限,并且重新排序可能会导致无法表示的值)
Ben Voigt

8
我认为您缺少一些演算背景。将两个数字相乘并相除会引入相同数量的错误。虽然两个数字相减/相加可能会引入较大的误差,尤其是当两个数字的数量级不同时,因此,与子/相加相比,重新布置mul / div的安全性更高,因为它会导致最终误差的微小变化。
CoffeDeveloper

8
@DarioOO:mul / div的风险有所不同:重新排序要么使最终结果的变化可忽略不计,要么指数在某个点(以前没有)溢出,并且结果有很大不同(可能是+ inf或0)。
彼得·科德斯

@GameDeveloper以无法预测的方式施加精度增益是一个很大的问题。
curiousguy19

80

Fortran(专为科学计算而设计)具有内置的幂运算符,据我所知,Fortran编译器通常会以与您所描述的相似的方式来优化对整数幂的提升。不幸的是,C / C ++没有幂运算符,只有库函数pow()。这不会阻止智能编译器pow对特殊情况进行特殊处理并以更快的方式对其进行计算,但是似乎它们不那么常用...

几年前,我试图使以最优方式计算整数幂的方法更加方便,并提出了以下内容。它是C ++,而不是C,仍然取决于编译器在如何优化/内联处理方面有些精明。无论如何,希望您会发现它在实践中很有用:

template<unsigned N> struct power_impl;

template<unsigned N> struct power_impl {
    template<typename T>
    static T calc(const T &x) {
        if (N%2 == 0)
            return power_impl<N/2>::calc(x*x);
        else if (N%3 == 0)
            return power_impl<N/3>::calc(x*x*x);
        return power_impl<N-1>::calc(x)*x;
    }
};

template<> struct power_impl<0> {
    template<typename T>
    static T calc(const T &) { return 1; }
};

template<unsigned N, typename T>
inline T power(const T &x) {
    return power_impl<N>::calc(x);
}

为好奇而澄清:这并没有找到计算幂的最佳方法,但是由于找到最佳解是一个NP完全问题,而且无论如何,这仅适用于小幂(相对于使用pow),因此没有必要大惊小怪细节。

然后将其用作power<6>(a)

这使得输入幂很容易(不需要a用括号来拼写6 s),并且使您可以进行这种优化,而-ffast-math不必担心某些精度相关的问题,例如补偿求和(此操作的顺序很重要) 。

您可能还会忘记这是C ++,并且仅在C程序中使用它(如果它使用C ++编译器进行编译)。

希望这会有用。

编辑:

这是我从编译器得到的:

对于a*a*a*a*a*a

    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0

对于(a*a*a)*(a*a*a)

    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm0, %xmm0

对于power<6>(a)

    mulsd   %xmm0, %xmm0
    movapd  %xmm0, %xmm1
    mulsd   %xmm0, %xmm1
    mulsd   %xmm0, %xmm1

36
找到最佳功率树可能很困难,但是由于它只对小功率有用,因此显而易见的答案是对其进行一次预计算(Knuth提供最多100个表)并使用该硬编码表(这是gcc在powi内部进行的操作) 。
Marc Glisse 2013年

7
在现代处理器上,速度受到延迟的限制。例如,相乘的结果可能在五个周期后可用。在那种情况下,找到最快的方式来创造力量可能会更加棘手。
gnasher729 2014年

3
您也可以尝试查找给出相对舍入误差的最低上限或最低平均相对舍入误差的幂树。
gnasher729 2014年

1
Boost也对此提供支持,例如boost :: math :: pow <6>(n); 我认为它甚至试图通过提取公因子来减少乘法次数。
gast128

请注意,最后一个等价于(a ** 2)** 3
minmaxavg

62

实际上,GCC确实会优化a*a*a*a*a*a(a*a*a)*(a*a*a)a为整数。我尝试使用以下命令:

$ echo 'int f(int x) { return x*x*x*x*x*x; }' | gcc -o - -O2 -S -masm=intel -x c -

有很多gcc标志,但是没有花哨。他们的意思是:从stdin读;使用O2优化级别;输出汇编语言列表,而不是二进制文件;清单应使用英特尔汇编语言语法;输入是用C语言编写的(通常是从输入文件扩展名推断出语言,但是从stdin读取时没有文件扩展名);并写入标准输出。

这是输出的重要部分。我用一些注释来注释它,以指示汇编语言中发生了什么:

; x is in edi to begin with.  eax will be used as a temporary register.
mov  eax, edi  ; temp = x
imul eax, edi  ; temp = x * temp
imul eax, edi  ; temp = x * temp
imul eax, eax  ; temp = temp * temp

我在Linux Mint 16 Petra(Ubuntu衍生产品)上使用系统GCC。这是gcc版本:

$ gcc --version
gcc (Ubuntu/Linaro 4.8.1-10ubuntu9) 4.8.1

正如其他张贴者所指出的,在浮点数中此选项是不可能的,因为浮点数算法不具有关联性。


12
这对于整数乘法是合法的,因为二进制补码溢出是不确定的行为。如果将要发生溢出,则无论重新排序操作如何,它都会在某处发生。因此,没有溢出的表达式求值相同,溢出的表达式是未定义的行为,因此编译器可以更改发生溢出的位置。gcc也会这样做unsigned int
彼得·科德斯

51

因为32位浮点数(例如1.024)不是1.024。在计算机中,1.024是一个间隔:从(1.024-e)到(1.024 + e),其中“ e”表示错误。有些人没有意识到这一点,并且还认为a * a中的*代表任意精度数字的乘法,而这些数字没有任何错误。某些人未能意识到这一点的原因也许是他们在小学时进行的数学计算:仅使用理想数工作且没有错误,并且认为在进行乘法运算时可以简单地忽略“ e”是可以的。他们看不到“ float a = 1.2”,“ a * a * a”和类似的C代码隐含的“ e”。

如果大多数程序员都认识到(并且能够执行)C表达式a * a * a * a * a * a实际上不使用理想数的想法,那么GCC编译器将可以自由地优化“ a * a” * a * a * a * a”变成“ t =(a * a); t * t * t”,这需要较少的乘法运算。但是不幸的是,GCC编译器不知道编写代码的程序员是否认为“ a”是一个带错误或不带错误的数字。因此,GCC只会做源代码的样子-因为这就是GCC的“裸眼”。

......一旦你知道那种程序员的是什么,你是,你可以使用“-ffast -数学”开关告诉GCC说:“嘿,GCC,我知道我在做什么!”。这将允许GCC将a * a * a * a * a * a转换为不同的文本-它看起来与a * a * a * a * a * a * a不同-但仍会在的错误间隔内计算数字a * a * a * a * a * a。可以,因为您已经知道自己正在使用间隔而不是理想数字。


52
浮点数是准确的。它们不一定完全符合您的期望。此外,使用epsilon的技术本身就是如何处理现实中的事物的近似方法,因为真正的预期误差与尾数的比例有关,即,通常您的尾数最多为1 LSB,但是随着如果不小心,将执行所有操作,因此在对浮点进行任何无关紧要的操作之前,请咨询数值分析人员。如果可以,请使用适当的库。
Donal Fellows

3
@DonalFellows:IEEE标准要求浮点计算所产生的结果与源操作数为精确值时最精确地匹配结果,但这并不意味着它们实际代表精确值。在很多情况下,将0.1f视为(1,677,722 +/- 0.5)/ 16,777,216,应该将其显示为不确定性所隐含的小数位数,而不是将其视为精确的数量(1,677,722 +/- 0.5)/ 16,777,216(应显示为24个十进制数字)。
2012年

23
@supercat:关于浮点数据确实表示精确值的观点,IEEE-754很清楚;第3.2-3.4节是相关章节。当然,您可以选择以其他方式解释它们,就像您可以选择以3 +/- 0.5的int x = 3含义解释一样x
斯蒂芬·佳能

7
@supercat:我完全同意,但这并不意味着它Distance并不完全等于其数值。这意味着数值只是对要建模的某些物理量的近似值。
斯蒂芬·佳能

10
对于数值分析,如果您将浮点数解释为不是精确的值(不是确切的值),那将使您的大脑感谢您。例如,如果x在4.5左右的某个位置,且误差小于0.1,并且您计算(x + 1)-x,则“时间间隔”解释会为您提供0.8到1.2的间隔,而“精确值”解释会告诉您您的结果将是1,最大精度为2 ^(-50)。
gnasher729 2014年

34

尚无张贴者提到浮动表达式的收缩(ISO C标准,6.5p8和7.12.2)。如果将FP_CONTRACTpragma设置为ON,则允许编译器将表达式a*a*a*a*a*a视为单个操作,就好像是通过一次取整精确地对其进行了计算一样。例如,编译器可以用更快更准确的内部幂函数代替它。这一点特别有趣,因为行为的一部分由程序员直接在源代码中控制,而最终用户提供的编译器选项有时可能不正确地使用。

FP_CONTRACT编译指示的默认状态是实现定义的,因此默认情况下允许编译器进行此类优化。因此,需要严格遵循IEEE 754规则的可移植代码应将其显式设置为OFF

如果编译器不支持该编译指示,则必须避免任何此类优化,以保持保守,以防开发人员选择将其设置为OFF

GCC不支持此编译指示,但使用默认选项时,它假定为ON:因此,对于具有硬件FMA的目标,如果要阻止转换a*b+c为fma(a,b,c),则需要提供一个选项,例如-ffp-contract=off(将编译指示显式设置为OFF)或-std=c99(告诉GCC符合某些要求)。 C标准版本,此处为C99,因此遵循上述段落)。过去,后一种选择不会阻止转换,这意味着GCC在这一点上不符合要求:https//gcc.gnu.org/bugzilla/show_bug.cgi?id = 37845


3
长期存在的热门问题有时会表明他们的年龄。在2011年,有人问及回答了这个问题,当时可以原谅GCC,因为他们不完全遵守当时的C99标准。当然现在是2014年,所以GCC…天哪。
Pascal Cuoq 2014年

但是,您是否应该在没有被接受的答案的情况下回答比较近期的浮点问题呢?咳嗽stackoverflow.com/questions/23703408咳嗽
Pascal Cuoq 2014年

我发现...令人不安的是gcc没有实现C99浮点编译指示。
David Monniaux '16

1
@DavidMonniaux编译指示根据定义是可选实现的。
蒂姆·塞吉

2
@TimSeguine但是,如果未实现某个编译指示,则其默认值需要对该实现进行最严格的限制。我想这就是大卫在想的。对于GCC,如果使用ISO C模式,此问题现在已针对FP_CONTRACT修复:它仍未实现编译指示,但在ISO C模式下,现在假定编译指示已关闭。
vinc17 '18

28

正如Lambdageek指出的那样,浮点乘法不是关联的,因此精度可能会降低,但是当精度更高时,您可能会反对优化,因为您需要确定性的应用程序。例如,在游戏模拟客户端/服务器中,每个客户端都必须模拟您希望确定点浮点计算的同一个世界。


3
@greggo不,那还是确定性的。在任何意义上都不会添加随机性。
爱丽丝

9
@Alice似乎很清楚,Bjorn在此使用了“确定性”,即代码在不同平台和不同编译器版本等(外部变量可能不受程序员控制)下给出相同的结果-而不是缺少在运行时的实际数字随机性。如果您指出这不是该词的正确用法,那么我不会对此进行争论。
greggo 2014年

5
@greggo除了你对他所说的话的解释之外,这仍然是错误的。这就是IEEE 754的重点,可以为跨平台的大多数(如果不是全部)操作提供相同的特性。现在,他没有提到平台或编译器版本,如果您希望每个远程服务器/客户端上的每个操作都相同,这将是一个有效的问题……。但是从他的陈述中并不明显。一个更好的词可能是“可靠相似”之类的。
爱丽丝

8
@Alice,您在争论语义,这是在浪费所有人的时间,包括您自己的时间。他的意思很清楚。
拉纳鲁2014年

11
@Lanaru标准的全部要点是语义;他的意思绝对不清楚。
爱丽丝

28

通常会精心设计库函数(例如“ pow”)以产生最小的错误(在一般情况下)。这通常是通过样条曲线逼近函数实现的(根据Pascal的评论,最常见的实现似乎是使用Remez算法

基本上是以下操作:

pow(x,y);

固有误差与任何单次乘法或除法的误差大致相同

同时进行以下操作:

float a=someValue;
float b=a*a*a*a*a*a;

的固有误差大于单个乘法或除法误差的5倍(因为您要组合5个乘法)。

编译器应该对正在执行的优化类型非常谨慎:

  1. 如果优化pow(a,6)a*a*a*a*a*a可以提高性能,但显着降低精度浮点数。
  2. 如果对其进行优化a*a*a*a*a*apow(a,6)则实际上可能会降低精度,因为“ a”是一些允许无错误相乘的特殊值(2的幂或一些小整数)
  3. 如果优化pow(a,6)(a*a*a)*(a*a*a)或者(a*a)*(a*a)*(a*a)仍然可以比作精度的损失pow函数。

通常,您知道对于任意浮点值,“ pow”的精度要比您最终可以编写的任何函数更好,但是在某些特殊情况下,多次乘法可能具有更好的精度和性能,这取决于开发人员选择更合适的值,最终对代码进行注释,以使其他人都无法“优化”该代码。

唯一有意义的事情(个人观点,以及显然在GCC中没有任何特定的优化或编译器标志的选择)要进行优化,应该将“ pow(a,2)”替换为“ a * a”。那将是编译器供应商应该做的唯一明智的事情。


7
投票者应该意识到这个答案是完全可以的。我可以引用许多资料和文献来支持我的回答,而且我可能比浮雕作者更多地参与浮点精度。在StackOverflow中添加其他答案未涵盖的缺失信息是完全合理的,因此请保持礼貌并解释您的原因。
CoffeDeveloper

1
在我看来,斯蒂芬·佳能(Stephen Canon)的回答涵盖了您必须说的话。您似乎坚持说libms是用样条实现的:它们通常使用参数约简(取决于所实现的函数)加上一个多项式,该多项式的系数已由或多或少的Remez算法变体获得。对于libm函数,结点处的平滑度不被认为是值得追求的目标(如果最终精度足够高,则无论该域被分割成多少段,它们都会自动变得非常平滑)。
Pascal Cuoq

答案的后半部分完全错失了编译器应该产生实现源代码所说的句点的观点。当您指的是“准确性”时,也使用“精确”一词。
Pascal Cuoq

感谢您的输入,我对答案进行了小幅更正,最后两行中仍然存在新内容^^
CoffeDeveloper 2015年

27

我根本不希望这种情况得到优化。表达式包含可以重新组合以删除整个操作的子表达式的情况很少发生。我希望编译器作者将时间投入到更可能导致显着改进的领域上,而不是覆盖很少遇到的边缘情况。

从其他答案中得知,使用适当的编译器开关确实可以优化此表达式,这让我感到惊讶。优化要么是微不足道的,要么是更常见的优化的边缘案例,要么是编译器编写者非常彻底。

正如您在此处所做的那样,向编译器提供提示没有错。重新排列语句和表达式,以了解它们将带来什么不同,这是微优化过程中正常且预期的部分。

尽管考虑到两个表达式传递不一致的结果(没有适当的切换)可能会证明编译器是合理的,但您不必受此限制的约束。差异将非常小,以至于如此之大,以至于如果差异对您很重要,那么您首先就不应使用标准浮点算法。


17
正如另一位评论者所指出的那样,这是荒谬的,是不真实的。差异可能高达成本的一半至10%,而且如果在紧密的循环中运行,将转化为许多浪费的指令,而这些指令却可能带来微不足道的额外精度。说您在进行蒙特卡洛操作时不应该使用标准FP,就像在说要始终使用飞机穿越全国一样。它忽略了许多外部性。最后,这并非罕见的优化;死代码分析和代码缩减/重构非常普遍。
爱丽丝

21

这个问题已经有了一些好的答案,但是为了完整起见,我想指出,C标准的适用部分是5.1.2.2.3 / 15(与C.1中的1.9 / 9相同)。 C ++ 11标准)。本节指出,只有当运算符确实是关联的或可交换的时,才可以重新组合。


12

gcc实际上可以进行此优化,即使对于浮点数也是如此。例如,

double foo(double a) {
  return a*a*a*a*a*a;
}

变成

foo(double):
    mulsd   %xmm0, %xmm0
    movapd  %xmm0, %xmm1
    mulsd   %xmm0, %xmm1
    mulsd   %xmm1, %xmm0
    ret

-O -funsafe-math-optimizations。但是,此重新排序违反了IEEE-754,因此需要该标志。

正如彼得·科德斯(Peter Cordes)在评论中指出的那样,带符号整数可以进行这种优化而无需进行优化,-funsafe-math-optimizations因为它可以准确地确定何时没有溢出,如果存在溢出,您将获得未定义的行为。所以你得到

foo(long):
    movq    %rdi, %rax
    imulq   %rdi, %rax
    imulq   %rdi, %rax
    imulq   %rax, %rax
    ret

只是-O。对于无符号整数,这甚至更容易,因为它们的mod幂为2,因此即使面对溢出也可以自由地重新排序。


1
Godbolt链接带有double,int和unsigned。gcc和clang都以相同的方式优化了这三个方法(使用-ffast-math
Peter Cordes

@PeterCordes谢谢!
查尔斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.