gcc优化标志-O3使代码比-O2慢

gcc optimization flag -O3 makes code slower than -O2

本文关键字:-O2 代码 优化 标志 -O3 gcc      更新时间:2023-10-16

我找到了这个主题为什么处理排序数组比处理未排序数组更快。并尝试运行此代码。我发现了一些奇怪的行为。如果使用-O3优化标志编译此代码,则需要2.98605 sec才能运行。如果使用-O2进行编译,则需要1.98093 sec。我试着在同一环境中的同一台机器上运行这段代码好几次(5或6次),我关闭了所有其他软件(chrome、skype等)

gcc --version
gcc (Ubuntu 4.9.2-0ubuntu1~14.04) 4.9.2
Copyright (C) 2014 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

请你向我解释一下为什么会发生这种情况?我阅读了gcc手册,发现-O3包括-O2。谢谢你的帮助。

p.S.添加代码

#include <algorithm>
#include <ctime>
#include <iostream>
int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];
    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;
    // !!! With this, the next loop runs faster
    std::sort(data, data + arraySize);
    // Test
    clock_t start = clock();
    long long sum = 0;
    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }
    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

gcc -O3使用cmov作为条件,因此它延长了循环携带的依赖链,以包括cmov(根据Agner Fog的指令表,这是Intel Sandybridge CPU上的2个uop和2个延迟周期。另请参阅x86标记wiki)。这是cmov非常糟糕的情况之一。

如果数据是适度不可预测的,那么cmov可能是一个胜利,所以对于编译器来说,这是一个相当明智的选择。(但是,编译器有时可能会过多地使用无分支代码。)

我把你的代码放在Godbolt编译器资源管理器上查看asm(突出显示并过滤掉不相关的行。不过,你仍然需要向下滚动所有排序代码才能进入main())。

.L82:  # the inner loop from gcc -O3
    movsx   rcx, DWORD PTR [rdx]  # sign-extending load of data[c]
    mov     rsi, rcx
    add     rcx, rbx        # rcx = sum+data[c]
    cmp     esi, 127
    cmovg   rbx, rcx        # sum = data[c]>127 ? rcx : sum
    add     rdx, 4          # pointer-increment
    cmp     r12, rdx
    jne     .L82

gcc本可以通过使用LEA而不是ADD来保存MOV。

由于循环的一次迭代使用CMO写入rbx,而下一次迭代则使用ADD读取rbx,因此ADD->CMOV的延迟(3个周期)受到循环瓶颈的影响。

该循环仅包含8个融合域uop,因此它可以每2个循环发出一个。执行端口压力也不像sum dep链的延迟那样是一个严重的瓶颈,但它很接近(Sandybridge只有3个ALU端口,不像Haswell的4个)。

顺便说一句,将其写为sum += (data[c] >= 128 ? data[c] : 0);以将cmov从循环携带的dep链中取出可能是有用的。仍然有很多指令,但每次迭代中的cmov是独立的。这在gcc6.3 -O2及更早版本中按预期编译,但gcc7在关键路径上去优化为cmov(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82666)。(它还使用比if()更早的gcc版本进行自动矢量化。)

Clang使cmov偏离关键路径,即使使用原始源。


gcc -O2使用了一个分支(适用于gcc5.x及更旧版本),它可以很好地预测,因为您的数据是经过排序的。由于现代CPU使用分支预测来处理控制依赖关系,因此循环携带的依赖链更短:只有add(1个周期延迟)。

由于分支预测+推测执行,每次迭代中的比较和分支是独立的,这使得在确定分支方向之前可以继续执行。

.L83:   # The inner loop from gcc -O2
    movsx   rcx, DWORD PTR [rdx]  # load with sign-extension from int32 to int64
    cmp     ecx, 127
    jle     .L82        # conditional-jump over the next instruction 
    add     rbp, rcx    # sum+=data[c]
.L82:
    add     rdx, 4
    cmp     rbx, rdx
    jne     .L83

有两个循环携带的依赖链:sum和循环计数器。sum是0或1个周期长,并且循环计数器总是1个周期。然而,这个循环是Sandybridge上的5个融合域uop,所以它无论如何都不能以每次迭代1c的速度执行,所以延迟不是瓶颈。

它可能每2个周期运行一次迭代(分支指令吞吐量受到瓶颈限制),而-O3循环每3个循环运行一次。下一个瓶颈是ALU-uop吞吐量:4个ALU-uop(在未采用的情况下),但只有3个ALU端口。(ADD可以在任何端口上运行)。

此管道分析预测与-O3的约3秒时间与-O2的约2秒时间非常匹配。


Haswell/Skylake可以以每1.25个周期运行一次未采取情况,因为它可以在与采取分支相同的周期中执行未采取分支,并且具有4个ALU端口。(或者稍微少一点,因为在每个周期4个uop的情况下,5个uop循环不会很有问题)。

(刚刚测试:Skylake@3.9GHz在1.45s内运行整个程序的分支版本,或在1.68s内运行无分支版本。因此差异要小得多。)


g++6.3.1即使在-O2下也使用cmov,但g++5.4的行为仍然与4.9.2类似。

对于g++6.3.1和g++5.4,使用-fprofile-generate/-fprofile-use即使在-O3(使用-fno-tree-vectorize)时也会产生分支版本。

来自较新gcc的循环的CMOV版本使用add ecx,-128/cmovge rbx,rdx而不是CMP/CMOV。这有点奇怪,但可能不会减缓速度。ADD写入输出寄存器和标志,因此对物理寄存器的数量造成了更大的压力。但只要这不是一个瓶颈,它应该是平等的。


较新的gcc使用-O3自动向量化循环,即使只有SSE2,这也是一个显著的加速。(例如,我的i7-6700k Skylake运行矢量化版本在0.74s,所以大约是标量的两倍快。或0.35秒内的-O3 -march=native,使用AVX2 256b矢量)。

矢量化版本看起来像很多指令,但也不算太糟,而且它们中的大多数都不是循环携带的dep链的一部分。它只需要在接近末尾时解压缩到64位元素。不过,它做了两次pcmpgtd,因为它没有意识到,当条件已经将所有负整数归零时,它可以只进行零扩展,而不是符号扩展。