为什么将0.1f改为0会使性能减慢10倍?

1670

为什么这段代码会这样,

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0.1f; // <--
        y[i] = y[i] - 0.1f; // <--
    }
}

以下代码与下方 bit 相同(除了注释的部分),请问如何使其运行速度比 bit 快十倍以上?

const float x[16] = {  1.1,   1.2,   1.3,     1.4,   1.5,   1.6,   1.7,   1.8,
                       1.9,   2.0,   2.1,     2.2,   2.3,   2.4,   2.5,   2.6};
const float z[16] = {1.123, 1.234, 1.345, 156.467, 1.578, 1.689, 1.790, 1.812,
                     1.923, 2.034, 2.145,   2.256, 2.367, 2.478, 2.589, 2.690};
float y[16];
for (int i = 0; i < 16; i++)
{
    y[i] = x[i];
}

for (int j = 0; j < 9000000; j++)
{
    for (int i = 0; i < 16; i++)
    {
        y[i] *= x[i];
        y[i] /= z[i];
        y[i] = y[i] + 0; // <--
        y[i] = y[i] - 0; // <--
    }
}

在使用Visual Studio 2010 SP1编译时, 优化级别为-02且启用了sse2。 我没有测试过其他编译器。


12
你是如何衡量差异的?在编译时你使用了哪些选项? - James Kanze
169
为什么编译器不能在这种情况下删除+/- 0呢?!? - Michael Dorgan
129
编译器不会如此愚蠢。在LINQPad中分解一个简单的示例可以显示,当需要一个“double”类型时,无论你使用0、0f、0d甚至是(int)0,它都会生成相同的代码。 - millimoose
18
优化级别是什么? - Otto Allmendinger
17
为什么编译器没有优化掉+/-0,而是将其转换为非规格化浮点数? - Vorac
显示剩余8条评论
7个回答

1722

欢迎来到denormalized floating-point的世界! 它们可能会对性能造成极大的破坏!!!

Denormal(或subnormal)数是一种hack,用于从浮点表示中获取一些非常接近零的额外值。 与规范化的浮点数相比,对去规格化浮点数的操作可以慢十倍到数百倍不等。这是因为许多处理器无法直接处理它们,必须使用微码进行陷阱和解决。

如果您在10,000次迭代后打印出数字,您将看到它们收敛到不同的值,具体取决于是否使用00.1

以下是在x64上编译的测试代码:

int main() {

    double start = omp_get_wtime();

    const float x[16]={1.1,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.0,2.1,2.2,2.3,2.4,2.5,2.6};
    const float z[16]={1.123,1.234,1.345,156.467,1.578,1.689,1.790,1.812,1.923,2.034,2.145,2.256,2.367,2.478,2.589,2.690};
    float y[16];
    for(int i=0;i<16;i++)
    {
        y[i]=x[i];
    }
    for(int j=0;j<9000000;j++)
    {
        for(int i=0;i<16;i++)
        {
            y[i]*=x[i];
            y[i]/=z[i];
#ifdef FLOATING
            y[i]=y[i]+0.1f;
            y[i]=y[i]-0.1f;
#else
            y[i]=y[i]+0;
            y[i]=y[i]-0;
#endif

            if (j > 10000)
                cout << y[i] << "  ";
        }
        if (j > 10000)
            cout << endl;
    }

    double end = omp_get_wtime();
    cout << end - start << endl;

    system("pause");
    return 0;
}

输出:

#define FLOATING
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007
1.78814e-007  1.3411e-007  1.04308e-007  0  7.45058e-008  6.70552e-008  6.70552e-008  5.58794e-007  3.05474e-007  2.16067e-007  1.71363e-007  1.49012e-007  1.2666e-007  1.11759e-007  1.04308e-007  1.04308e-007

//#define FLOATING
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.46842e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044
6.30584e-044  3.92364e-044  3.08286e-044  0  1.82169e-044  1.54143e-044  2.10195e-044  2.45208e-029  7.56701e-044  4.06377e-044  3.92364e-044  3.22299e-044  3.08286e-044  2.66247e-044  2.66247e-044  2.24208e-044

注意在第二次运行中,数字非常接近零。
非规范化数通常很少出现,因此大多数处理器不会尝试高效地处理它们。
为了证明这与非规范化数有关,如果我们通过在代码开头添加以下内容将非规范化数舍入为零来进行演示:
_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

然后,带有0的版本不再慢10倍,实际上变得更快了。(这需要启用SSE编译代码。)
这意味着我们不再使用这些奇怪的低精度接近零的值,而是直接舍入为零。
计时:Core i7 920 @ 3.5 GHz:
//  Don't flush denormals to zero.
0.1f: 0.564067
0   : 26.7669

//  Flush denormals to zero.
0.1f: 0.587117
0   : 0.341406

在最后,这实际上与整数或浮点数无关。无论是 0 还是 0.1f 都被转换/储存在循环之外的寄存器中,因此对性能没有影响。

112
我觉得编译器默认没有完全优化掉 "+ 0" 还是有点奇怪。如果他换成 "+ 0.0f",会发生同样的情况吗? - s73v3r
55
@s73v3r 这是一个非常好的问题。现在我看着汇编代码,甚至 + 0.0f 都没有被优化掉。如果我猜的话,可能是如果 y[i] 恰好是一个信号性的“NaN”之类的东西,那么 + 0.0f 可能会产生副作用... 不过我可能猜错了。 - Mysticial
16
在许多情况下,双精度仍然会遇到同样的问题,只是数量级不同。对于音频应用(和其他可以容忍偶尔损失1e-38的应用程序)来说,向零舍入是可以接受的,但我认为不适用于x87。没有FTZ,音频应用程序的常规修复方法是注入一个非常低幅度(听不见的)直流或方波信号,以使数字远离非规格化数。 - Russell Borogove
18
由于当y[i]明显小于0.1时,加上它会导致精度损失,因为数字中最高有效位变得更高了。 - Dan Is Fiddling By Firelight
203
由于浮点数中存在负零,因此无法优化掉 +0.f。将 +0.f 加到 -.0f 中的结果为 +0.f,因此加上 0.f 不是一个恒等操作,也不能被优化掉。 - Eric Postpischil
显示剩余31条评论

433

使用gcc并对生成的汇编代码应用差异,只会产生以下这些差异:

73c68,69
<   movss   LCPI1_0(%rip), %xmm1
---
>   movabsq $0, %rcx
>   cvtsi2ssq   %rcx, %xmm1
81d76
<   subss   %xmm1, %xmm0

显然,float 版本使用从内存加载的 XMM 寄存器,而 int 版本则使用 cvtsi2ssq 指令将实际的 int 值 0 转换为 float,需要很长时间。传递 -O3 给 gcc 也没有帮助。(gcc 版本 4.2.1.)

(将 float 替换为 double 并不重要,除了将 cvtsi2ssq 更改为 cvtsi2sdq。)

一些额外的测试表明它并不一定是 cvtsi2ssq 指令造成的。一旦消除 (使用 int ai=0;float a=ai; 并使用 a 代替 0),速度差异仍然存在。所以 @Mysticial 是正确的,非规范化的浮点数使得区别。这可以通过测试介于 00.1f 之间的值来看到。在上面的代码中,转折点大约在 0.00000000000000000000000000000001,此时循环突然变慢了 10 倍。

更新

这一有趣现象的小可视化:

  • 第一列: 一个浮点数,每次迭代除以2
  • 第二列: 这个浮点数的二进制表示
  • 第三列: 将这个浮点数加1e7次所花费的时间

您可以清楚地看到指数 (最后9位) 变为其最低值时,非规范化设置开始。此时,简单的加法变得慢20倍。

0.000000000000000000000000000000000100000004670110: 10111100001101110010000011100000 45 ms
0.000000000000000000000000000000000050000002335055: 10111100001101110010000101100000 43 ms
0.000000000000000000000000000000000025000001167528: 10111100001101110010000001100000 43 ms
0.000000000000000000000000000000000012500000583764: 10111100001101110010000110100000 42 ms
0.000000000000000000000000000000000006250000291882: 10111100001101110010000010100000 48 ms
0.000000000000000000000000000000000003125000145941: 10111100001101110010000100100000 43 ms
0.000000000000000000000000000000000001562500072970: 10111100001101110010000000100000 42 ms
0.000000000000000000000000000000000000781250036485: 10111100001101110010000111000000 42 ms
0.000000000000000000000000000000000000390625018243: 10111100001101110010000011000000 42 ms
0.000000000000000000000000000000000000195312509121: 10111100001101110010000101000000 43 ms
0.000000000000000000000000000000000000097656254561: 10111100001101110010000001000000 42 ms
0.000000000000000000000000000000000000048828127280: 10111100001101110010000110000000 44 ms
0.000000000000000000000000000000000000024414063640: 10111100001101110010000010000000 42 ms
0.000000000000000000000000000000000000012207031820: 10111100001101110010000100000000 42 ms
0.000000000000000000000000000000000000006103515209: 01111000011011100100001000000000 789 ms
0.000000000000000000000000000000000000003051757605: 11110000110111001000010000000000 788 ms
0.000000000000000000000000000000000000001525879503: 00010001101110010000100000000000 788 ms
0.000000000000000000000000000000000000000762939751: 00100011011100100001000000000000 795 ms
0.000000000000000000000000000000000000000381469876: 01000110111001000010000000000000 896 ms
0.000000000000000000000000000000000000000190734938: 10001101110010000100000000000000 813 ms
0.000000000000000000000000000000000000000095366768: 00011011100100001000000000000000 798 ms
0.000000000000000000000000000000000000000047683384: 00110111001000010000000000000000 791 ms
0.000000000000000000000000000000000000000023841692: 01101110010000100000000000000000 802 ms
0.000000000000000000000000000000000000000011920846: 11011100100001000000000000000000 809 ms
0.000000000000000000000000000000000000000005961124: 01111001000010000000000000000000 795 ms
0.000000000000000000000000000000000000000002980562: 11110010000100000000000000000000 835 ms
0.000000000000000000000000000000000000000001490982: 00010100001000000000000000000000 864 ms
0.000000000000000000000000000000000000000000745491: 00101000010000000000000000000000 915 ms
0.000000000000000000000000000000000000000000372745: 01010000100000000000000000000000 918 ms
0.000000000000000000000000000000000000000000186373: 10100001000000000000000000000000 881 ms
0.000000000000000000000000000000000000000000092486: 01000010000000000000000000000000 857 ms
0.000000000000000000000000000000000000000000046243: 10000100000000000000000000000000 861 ms
0.000000000000000000000000000000000000000000022421: 00001000000000000000000000000000 855 ms
0.000000000000000000000000000000000000000000011210: 00010000000000000000000000000000 887 ms
0.000000000000000000000000000000000000000000005605: 00100000000000000000000000000000 799 ms
0.000000000000000000000000000000000000000000002803: 01000000000000000000000000000000 828 ms
0.000000000000000000000000000000000000000000001401: 10000000000000000000000000000000 815 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 42 ms
0.000000000000000000000000000000000000000000000000: 00000000000000000000000000000000 44 ms

在Stack Overflow的问题中可以找到有关ARM的等效讨论Denormalized floating point in Objective-C?


28
“-O”选项无法解决问题,但“-ffast-math”可以。我经常使用它,我认为如果程序设计得当,它会导致精度问题的边缘情况不会出现。 - leftaroundabout
使用gcc-4.6进行任何正优化级别的编译都不会进行转换。 - Jed
3
@leftaroundabout:使用-ffast-math编译可执行文件(而非库)会链接一些额外的启动代码,该代码会在MXCSR中设置FTZ(清零)和DAZ(非规格化数清零),因此CPU永远不必为非规格化数执行缓慢的微码辅助。 - Peter Cordes

40

这是由于使用了非规格化浮点数。如何消除其及性能损失呢?在网上搜寻了许多消除非规格化数的方法,似乎还没有“最佳”方法。我找到了以下三种方法,它们可能在不同的环境中发挥最佳作用:

  • 在某些GCC环境下可能无法工作:

// Requires #include <fenv.h>
fesetenv(FE_DFL_DISABLE_SSE_DENORMS_ENV);
  • 可能在某些Visual Studio环境下无法正常工作: 1

  • // Requires #include <xmmintrin.h>
    _mm_setcsr( _mm_getcsr() | (1<<15) | (1<<6) );
    // Does both FTZ and DAZ bits. You can also use just hex value 0x8040 to do both.
    // You might also want to use the underflow mask (1<<11)
    
  • 似乎在GCC和Visual Studio中都能工作:

  • // Requires #include <xmmintrin.h>
    // Requires #include <pmmintrin.h>
    _MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);
    _MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON);
    
  • 英特尔编译器有选项在现代英特尔CPU上默认禁用denormals。更多细节请查看这里

  • 编译器开关。-ffast-math-msse-mfpmath=sse将禁用denormals并使一些其他东西更快,但不幸的是还会进行许多其他近似操作,可能会破坏您的代码。请仔细测试!Visual Studio编译器的fast-math等效选项为/fp:fast,但我无法确认它是否也禁用了denormals。1


  • 4
    这听起来像是一个对另一个但相关问题(如何防止数值计算产生非规范化结果?)的合理回答,但它并没有回答这个问题。 - Ben Voigt
    1
    Windows X64在启动.exe时传递了一个突然下溢的设置,而32位Windows和Linux则没有。在Linux上,gcc -ffast-math应该设置突然下溢(但我认为在Windows上不行)。Intel编译器应该在main()中初始化,以便这些操作系统差异不会通过,但我已经受到影响,需要在程序中明确设置它。从Sandy Bridge开始的英特尔CPU应该有效地处理加/减中出现的次标准数(但不包括除法/乘法),因此有理由使用渐进式下溢。 - tim18
    2
    Microsoft /fp:fast(非默认设置)不会执行gcc -ffast-math或ICL(默认设置)/fp:fast中固有的任何激进操作。它更像是ICL /fp:source。因此,如果您希望比较这些编译器,则必须显式设置/fp:(在某些情况下还需要设置下溢模式)。 - tim18

    31

    丹·尼利的评论应该被扩展为一个答案:

    导致减速的不是零常数0.0f,而是在循环的每次迭代中接近于零的值。随着它们越来越接近于零,需要更多的精度来表示它们,并且它们变得非规格化。这些是y[i]的值。(它们接近于零是因为对于所有的ix[i]/z[i]都小于1.0。)

    代码快和慢的关键区别在于语句y[i] = y[i] + 0.1f;。一旦执行了这行代码,每次循环迭代时,浮点数中的额外精度就会丢失,并且不再需要表示该精度所需的非规格化。之后,在y[i]上进行的浮点运算保持快速,因为它们没有非规格化。

    为什么添加0.1f时会丢失额外精度?因为浮点数只有那么多的有效数字。假设你有足够的存储空间来存储三个有效数字,那么0.00001 = 1e-5,并且0.00001 + 0.1 = 0.1,至少对于此示例浮点格式是这样的,因为它没有空间来存储0.10001中的最低有效位。

    简而言之,y[i]=y[i]+0.1f; y[i]=y[i]-0.1f;并不是你想象中的空操作。 Mystical也这样说:浮点数的内容很重要,不仅仅是汇编代码。
    编辑:更明确地说,即使机器操作码相同,也不是每个浮点运算所需的时间都相同。对于某些操作数/输入,相同的指令将需要更长的时间才能运行。这对非规格化数字尤其真实。

    我认为这个答案是对实际问题最好的回答,而不是一般有趣和写得好看。 - shadowtalker

    20

    在gcc中,你可以通过以下命令启用 FTZ 和 DAZ:

    #include <xmmintrin.h>
    
    #define FTZ 1
    #define DAZ 1   
    
    void enableFtzDaz()
    {
        int mxcsr = _mm_getcsr ();
    
        if (FTZ) {
                mxcsr |= (1<<15) | (1<<11);
        }
    
        if (DAZ) {
                mxcsr |= (1<<6);
        }
    
        _mm_setcsr (mxcsr);
    }
    

    还可以使用gcc开关:-msse -mfpmath=sse

    (相关信用归于Carl Hetherington [1])

    [1] http://carlh.net/plugins/denormals.php


    另外,还可以查看fenv.h中定义的C99标准下的fesetround()函数(http://linux.die.net/man/3/fesetround),这是另一种更便携的舍入方式(但这会影响所有浮点运算,而不仅仅是次正常值)(http://gcc.gnu.org/ml/gcc-help/2011-12/msg00163.html)。 - German Garcia
    你确定在FTZ中需要1<<15和1<<11吗?我只在其他地方看到过1<<15的引用... - fig
    @fig: 1<<11 是用于下溢屏蔽的。更多信息请看这里:http://softpixel.com/~cwright/programming/simd/sse.php - German Garcia
    @GermanGarcia 这并没有回答问题; 问题是“为什么这段代码运行速度比...快10倍” - 在提供此解决方法之前,您应该尝试回答这个问题或在评论中提供此解决方法。 - user719662

    1

    2023年更新,使用Ryzen 3990x,gcc 10.2编译选项-O3 -mavx2 -march=native,两个版本之间的差异是:

    0.0f: 0.218s
    0.1f: 0.127s
    

    所以仍然比较慢,但不是10倍慢。


    -O3 可能会优化处理非规格化浮点数。 - GlassFish

    0

    CPU的性能只在处理非规格化数时稍微有些下降,长期来看影响不大。我的Zen2 CPU在处理非规格化输入输出需要五个时钟周期,而在处理规格化数时只需要四个时钟周期。

    这是使用Visual C++编写的一个小型基准测试,旨在展示非规格化数对性能的轻微影响:

    #include <iostream>
    #include <cstdint>
    #include <chrono>
    
    using namespace std;
    using namespace chrono;
    
    uint64_t denScale( uint64_t rounds, bool den );
    
    int main()
    {
        auto bench = []( bool den ) -> double
        {
            constexpr uint64_t ROUNDS = 25'000'000;
            auto start = high_resolution_clock::now();
            int64_t nScale = denScale( ROUNDS, den );
            return (double)duration_cast<nanoseconds>( high_resolution_clock::now() - start ).count() / nScale;
        };
        double
            tDen = bench( true ),
            tNorm = bench( false ),
            rel = tDen / tNorm - 1;
        cout << tDen << endl;
        cout << tNorm << endl;
        cout << trunc( 100 * 10 * rel + 0.5 ) / 10 << "%" << endl;
    }
    

    这是 MASM 汇编部分。

    PUBLIC ?denScale@@YA_K_K_N@Z
    
    CONST SEGMENT
    DEN DQ 00008000000000000h
    ONE DQ 03FF0000000000000h
    P5  DQ 03fe0000000000000h
    CONST ENDS
    
    _TEXT SEGMENT
    ?denScale@@YA_K_K_N@Z PROC
        xor     rax, rax
        test    rcx, rcx
        jz      byeBye
        mov     r8, ONE
        mov     r9, DEN
        test    dl, dl
        cmovnz  r8, r9
        movq    xmm1, P5
        mov     rax, rcx
    loopThis:
        movq    xmm0, r8
    REPT 52
        mulsd   xmm0, xmm1
    ENDM
        sub     rcx, 1
        jae     loopThis
        mov     rdx, 52
        mul     rdx
    byeBye:
        ret
    ?denScale@@YA_K_K_N@Z ENDP
    _TEXT ENDS
    END
    

    希望在评论中看到一些结果。


    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接