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

49
我发现这个话题:为什么处理排序数组比未排序数组更快?,并尝试运行此代码。我发现了奇怪的行为。如果我使用-O3优化标志编译此代码,则需要2.98605秒才能运行。如果我使用-O2编译,那么需要1.98093秒。我在同一台机器上,在相同的环境中多次运行此代码(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。谢谢您的帮助。

附言:添加代码

#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;
}

1
你运行了每个程序一次吗?你应该尝试几次。同时确保在用于基准测试的计算机上没有运行任何其他程序。 - doctorlove
1
@BasileStarynkevitch 我添加了代码。我尝试了几次,结果都一样。我尝试使用-mtune=native编译 - 与之前相同的结果(没有此标志)。处理器 - Intel Core i5-2400。 - Mike Minaev
2
我进行了一些实验,并在O2中添加了额外的优化,这些优化与O3一次执行一个。O3为我添加的额外优化标志是:-fgcse-after-reload -finline-functions -fipa-cp-clone -fpredictive-commoning -ftree-loop-distribute-patterns -ftree-vectorize -funswitch-loops。我发现将-ftree-vectorize作为优化标志添加到O2中会产生负面影响。我使用的是Windows 7和mingw-gcc 4.7.2。 - halex
3
@doctorlove,我无法解释为什么使用循环自动向量化后速度会变慢,因此我认为这对于一个答案来说信息太少了 :) - halex
1
将变量sum从局部变量更改为全局或静态变量,可以消除O2和O3之间的差异。问题似乎与在循环内存储和检索局部变量sum时进行了大量堆栈操作有关。我的汇编知识太有限,无法完全理解gcc生成的代码 :) - halex
显示剩余3条评论
1个回答

69

gcc -O3 使用cmov进行条件判断,因此它会将循环中的依赖链长度增加到包括一个cmov(根据Agner Fog's instruction tables,在Intel Sandybridge CPU上,它有2个微操作和2个时钟周期的延迟。请参见标签wiki)。这是cmov表现糟糕的情况之一

如果数据甚至略微不可预测,那么cmov可能会胜出,因此对于编译器来说,这是一个相当明智的选择。(然而,编译器有时会过度使用无分支代码。)

在Godbolt编译器探索器上放置了您的代码以查看汇编代码(带有漂亮的高亮显示并过滤掉不相关的行。您仍然需要向下滚动过所有排序代码才能到达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。

循环的瓶颈在于ADD->CMOV的延迟(3个周期),因为循环的一个迭代将CMO写入rbx,下一个迭代将ADD读取rbx。

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

顺便说一下,将其编写为sum += (data[c] >= 128 ? data[c] : 0);以将cmov从循环传递的依赖链中去除可能是有用的。仍然有很多指令,但每次迭代中的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,因此无论如何都不能以每次迭代1个周期的速度执行,因此延迟不是瓶颈。
它可能以大约每2个周期的速度运行(受分支指令吞吐量限制),而-O3循环每3个周期运行一次。下一个瓶颈将是ALU uop吞吐量:4个ALU uops(在未取的情况下),但仅有3个ALU端口。(ADD可以在任何端口上运行)。
这个管道分析预测与您对-O3与-O2的时间的约3秒与约2秒几乎完全匹配。
Haswell/Skylake可以在每1.25个周期内运行未采取的情况,因为它可以在同一周期内执行未采取分支和已采取分支,并且具有4个ALU端口。(或略少一些,因为5个uop循环并不完全每个周期都发出4个uop。)
(经测试:Skylake @ 3.9GHz在1.45秒内运行整个程序的分支版本,或者在1.68秒内运行无分支版本。因此,差异在那里要小得多。)

g++6.3.1即使在-O2的情况下也使用cmov,但是g++5.4仍然像4.9.2一样表现。

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

新版gcc的循环CMOV版本使用add ecx,-128 / cmovge rbx,rdx而不是CMP / CMOV。这有点奇怪,但可能不会使其变慢。ADD还会写入输出寄存器以及标志,因此会增加物理寄存器的数量压力。但只要这不是瓶颈,它应该大致相等。


新版gcc使用-O3自动向量化循环,即使只使用SSE2也可以显著加速。(例如,我的i7-6700k Skylake在0.74秒内运行向量化版本,比标量快大约两倍。或者使用AVX2 256b向量的-O3 -march=native在0.35秒内运行)。
向量化版本看起来有很多指令,但并不太糟糕,大部分指令不是循环延迟依赖链的一部分。它只需要在末尾解包为64位元素。虽然条件已经将所有负整数清零,但它还是会执行两次pcmpgtd,因为它没有意识到可以通过零扩展而不是符号扩展。

1
顺便说一句,我很久以前就看到了这个问题,可能是在它第一次发布的时候,但我想我被其他事情分心了,直到现在才回答它(当我被提醒时)。 - Peter Cordes
2
在这种情况下,-fprofile-generate-fprofile-use 有帮助吗? - Marc Glisse
@MarcGlisse:刚测试了一下:是的,g++5.4和g++6.3.1使用-O3 -fno-tree-vectorize -fprofile-use生成相同的分支代码。(即使没有PGO,g++6.3.1在-O2时也会使用CMOV)。在3.9GHz Skylake上,CMOV版本运行时间为1.68秒,而分支版本运行时间为1.45秒,因此在使用高效CMOV时差异要小得多。 - Peter Cordes
@MarcGlisse:更新了答案并添加了更多内容。为什么新的gcc使用add ecx,-128而不是CMP?这只是为了代码大小的原因(因为-128适合符号扩展的imm8)吗?我猜这可能值得写ecx,没有理由,因为它在那一点上已经死了,OOO执行可以很快释放它。但我很惊讶它仍然没有使用LEA在不同的寄存器中计算sum+data[c]以避免MOV。 - Peter Cordes
很多看起来都是调整选择,玩 -mtune=... 改变添加到 cmp。对于 lea 没有什么头绪。在 Skylake 笔记本电脑上,-O3 代码比 -O2 代码快得多。 - Marc Glisse
显示剩余3条评论

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