关于汇编:为什么GCC不优化a * a * a * a * a * a到(a * a * a)*(a * a * a)?

Why doesn't GCC optimize a*a*a*a*a*a to (a*a*a)*(a*a*a)?

我正在做一些科学应用的数值优化。我注意到的一点是,gcc会通过编译成a*a来优化调用pow(a,2),但是调用pow(a,6)没有优化,实际上会调用库函数pow,这大大降低了性能。(相反,英特尔C++编译器,可执行的EDOCX1,4),将消除对EDCOX1(2)的库调用。

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

1
2
3
4
5
6
movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13

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

1
2
3
4
movapd  %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm14, %xmm13
mulsd   %xmm13, %xmm13

它将乘法指令的数目减少到3。icc有类似的行为。

为什么编译器不认识这种优化技巧?


因为浮点数学不是关联的。对浮点乘法中的操作数进行分组的方式对答案的数值精度有影响。

因此,大多数编译器对于重新排序浮点计算非常保守,除非他们可以确保答案保持不变,或者除非您告诉他们您不关心数字精度。例如:GCC的-fassociative-math选项允许GCC重新关联浮点运算,甚至允许-ffast-math选项允许更积极地权衡精度与速度。


lambdageek正确地指出,由于浮点数不具有关联性,因此a*a*a*a*a*a(a*a*a)*(a*a*a)的"优化"可能会改变该值。这就是为什么C99不允许它(除非用户特别允许,通过编译器标志或pragma)。一般来说,假设是程序员写了她所做的事情是有原因的,编译器应该尊重这一点。如果你想要(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]之间对所有单精度浮点数的^6进行评估时的最大误差:

1
2
3
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( )的替代方案,它应该生成一个内联乘法树。如果你想用精确性来换取性能,但又不想启用快速数学,那么就使用它。


另一个类似的例子是:大多数编译器不会将a + b + c + d优化为(a + b) + (c + d)(这是一种优化,因为第二个表达式可以更好地进行管道化),并按照给定的方式对其进行评估(即,作为(((a + b) + c) + d))。这也是因为角落案例:

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

输出1.000000e-05 0.000000e+00


Fortran(为科学计算而设计)有一个内置的幂运算符,据我所知,Fortran编译器通常会以与您描述的类似的方式优化提升为整数幂。C+C++不幸的是没有一个幂算子,只有库函数EDCOX1〔0〕。这并不能阻止智能编译器专门处理pow,并在特殊情况下以更快的方式计算它,但似乎它们不太常见…

几年前,我试图使以最佳方式计算整数幂更为方便,并提出了以下几点。它是C++,而不是C,它仍然依赖于编译器在如何优化/内嵌东西方面有点聪明。不管怎样,希望你能在实践中发现它有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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)来使用。

这使得输入幂很容易(不需要用parens拼写6个as),并且允许您在没有-ffast-math的情况下进行这种优化,以防您具有精度依赖性,如补偿求和(一个操作顺序至关重要的示例)。

您可能还可以忘记这是C++,只是在C程序中使用它(如果它用C++编译器编译)。

希望这是有用的。

编辑:

这就是我从编译器中得到的:

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

1
2
3
4
5
6
    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0

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

1
2
3
4
    movapd  %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm1, %xmm0
    mulsd   %xmm0, %xmm0

对于power<6>(a)

1
2
3
4
    mulsd   %xmm0, %xmm0
    movapd  %xmm0, %xmm1
    mulsd   %xmm0, %xmm1
    mulsd   %xmm0, %xmm1


当a是整数时,gcc确实将aaa aaa优化为(aaa)(aaa)。我试过这个命令:

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

海湾合作委员会的旗帜很多,但没有什么特别之处。它们的意思是:从stdin读取;使用o2优化级别;输出汇编语言列表而不是二进制文件;列表应使用Intel汇编语言语法;输入为C语言(通常从输入文件扩展名推断语言,但从stdin读取时没有文件扩展名);并写入stdout。

这是输出的重要部分。我用一些注释对其进行了注释,指出了汇编语言中正在发生的事情:

1
2
3
4
5
; 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

我在LinuxMint16 Petra上使用系统gcc,这是一个Ubuntu的衍生工具。以下是GCC版本:

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

正如其他海报所指出的,这个选项在浮点中是不可能的,因为浮点运算实际上是不相关的。


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

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

…一旦你知道你是什么样的程序员,你可以使用"-ffast math"开关告诉GCC"嘿,GCC,我知道我在做什么!"。这将允许GCC将A*A*A*A*A*A转换为另一段文本-它看起来与A*A*A*A*A*A不同-但仍然在A*A*A*A*A*A的错误间隔内计算数字。这是可以的,因为您已经知道您使用的是间隔,而不是理想的数字。


还没有海报提到浮动表达式的收缩(ISO C标准,6.5P8和7.12.2)。如果FP_CONTRACT杂注设置为ON,则编译器可以将表达式(如a*a*a*a*a*a)视为单个操作,就像使用单个舍入精确计算一样。例如,编译器可以用更快、更准确的内部幂函数替换它。这特别有趣,因为行为部分由程序员直接在源代码中控制,而最终用户提供的编译器选项有时可能被错误地使用。

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

如果编译器不支持此pragma,那么它必须避免任何此类优化,以防开发人员选择将其设置为OFF

GCC不支持这种实用主义,但在默认选项下,它假定它是ON;因此,对于具有硬件fma的目标,如果要防止a*b+c转换为fma(a、b、c),需要提供一个选项,例如-ffp-contract=off(明确地将实用主义设置为OFF-std=c99(告诉GCC混淆M到一些C标准版本,这里是C99,因此遵循上面的段落)。过去,后一个选项并没有阻止转换,这意味着GCC不符合这一点:https://gcc.gnu.org/bugzilla/show_bug.cgi?ID=37845


正如lambdageek所指出的,浮点乘法不具有关联性,您可以获得较少的精度,但当获得更好的精度时,您可以反对优化,因为您需要确定性应用程序。例如,在游戏模拟客户机/服务器中,每个客户机都必须模拟您希望浮点计算具有确定性的相同世界。


我根本没想到这个案例会被优化。在表达式包含可以重新分组以删除整个操作的子表达式的情况下,这种情况不太常见。我希望编译器编写人员将他们的时间投入到更可能导致显著改进的领域,而不是覆盖很少遇到的边缘情况。

我很惊讶地从其他答案中得知,这个表达式确实可以通过适当的编译器开关进行优化。要么优化是微不足道的,要么它是一个更常见的优化的边缘案例,要么编译器编写人员非常彻底。

像您在这里所做的那样,向编译器提供提示并没有错。重新排列语句和表达式,看看它们会带来什么样的差异,这是微优化过程中一个正常的和预期的部分。

虽然编译器可能有理由考虑这两个表达式来传递不一致的结果(没有适当的开关),但您不需要受该限制的约束。这种差异会非常微小——如此之大以至于如果差异对你很重要,你不应该首先使用标准的浮点运算。


像"pow"这样的库函数通常是经过精心设计的,以产生尽可能小的错误(在一般情况下)。这通常是用样条曲线来逼近函数(根据Pascal的评论,最常见的实现似乎是使用remez算法)。

基本上是以下操作:

1
pow(x,y);

与任何一个乘法或除法中的误差大小近似相同的固有误差。

同时进行以下操作:

1
2
float a=someValue;
float b=a*a*a*a*a*a;

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

编译器应该非常小心地进行优化:

  • 如果将pow(a,6)优化到a*a*a*a*a*a,可能会提高性能,但会大大降低浮点数的精度。
  • 如果将a*a*a*a*a*a优化为pow(a,6),实际上可能会降低精度,因为"a"是一个特殊值,允许无误差乘法(2的幂或一些小整数)。
  • 如果将pow(a,6)优化到(a*a*a)*(a*a*a)(a*a)*(a*a)*(a*a)的话,与pow函数相比,仍然会有精度损失。
  • 一般来说,您知道对于任意的浮点值,"pow"比您最终可以编写的任何函数都具有更好的准确性,但是在某些特殊情况下,多重乘法可能具有更好的准确性和性能,这取决于开发人员选择更合适的值,最终对代码进行注释,以便其他任何人都不会"优化""那个代码"。

    唯一有意义的事情(个人观点,以及在GCC中选择任何特定的优化或编译器标志)应该是用"a*a"替换"pow(a,2)"。这将是编译器供应商应该做的唯一明智的事情。


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


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

    1
    2
    3
    double foo(double a) {
      return a*a*a*a*a*a;
    }

    变成

    1
    2
    3
    4
    5
    6
    foo(double):
        mulsd   %xmm0, %xmm0
        movapd  %xmm0, %xmm1
        mulsd   %xmm0, %xmm1
        mulsd   %xmm1, %xmm0
        ret

    -O -funsafe-math-optimizations一起。但是,这种重新排序违反了IEEE-754,因此需要标记。

    正如PeterCordes在评论中指出的那样,有符号整数可以在不使用-funsafe-math-optimizations的情况下进行优化,因为它在没有溢出的情况下保持不变,如果有溢出,则会得到未定义的行为。所以你得到

    1
    2
    3
    4
    5
    6
    foo(long):
        movq    %rdi, %rax
        imulq   %rdi, %rax
        imulq   %rdi, %rax
        imulq   %rax, %rax
        ret

    只带着-O。对于无符号整数,因为它们的mod幂为2,所以即使在溢出的情况下也可以自由地重新排序,所以更容易。