【问题标题】:gcc optimization flag -O3 makes code slower than -O2gcc 优化标志 -O3 使代码比 -O2 慢
【发布时间】:2015-05-06 16:09:25
【问题描述】:

我找到了这个话题Why is it faster to process a sorted array than an unsorted array?。并尝试运行此代码。我发现奇怪的行为。如果我使用-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;
}

【问题讨论】:

  • 每个程序都运行一次吗?你应该多试几次。还要确保 nothing else 在您用于基准测试的机器上运行,
  • @BasileStarynkevitch 我添加了代码。我试了几次,结果都一样。我尝试使用-mtune=native 进行编译 - 结果与以前相同(没有此标志)。处理器 - Intel Core i5 -2400
  • 我只是做了一点实验,并在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。
  • @doctorlove 我无法解释为什么循环的自动矢量化会变慢,所以我认为答案信息太少:)
  • 将变量 sum 从局部变量更改为全局变量或静态变量会使 O2 和 O3 之间的差异消失。该问题似乎与大量堆栈操作有关,如果它是本地的,则在循环内存储和检索变量sum。我的汇编知识太有限,无法完全理解 gcc 生成的代码:)

标签: c++ gcc optimization


【解决方案1】:

gcc -O3 使用cmov 作为条件,因此它延长了循环携带的依赖链以包含cmov(根据@987654322,在您的Intel Sandybridge CPU 上这是2 微秒和2 个延迟周期@. 另见 标签 wiki)。这是one of the cases where cmov sucks

如果数据甚至是适度不可预测的,cmov 可能会是一个胜利,所以这对于编译器来说是一个相当明智的选择。 (不过,compilers may sometimes use branchless code too much。)

put your code on the Godbolt compiler explorer 来查看 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。

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

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

顺便说一句,将其写为 sum += (data[c] &gt;= 128 ? data[c] : 0); 以将 cmov 从循环承载的 dep 链中取出可能很有用。仍然有很多指令,但每次迭代中的cmov 是独立的。这是compiles as expected in gcc6.3 -O2 and earlier,但 gcc7 在关键路径 (https://gcc.gnu.org/bugzilla/show_bug.cgi?id=82666) 上将其优化为 cmov。 (它还使用比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 个融合域微指令,因此无论如何它不能以每次迭代 1c 的速度执行,因此延迟不是瓶颈。

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

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


Haswell/Skylake 可以每 1.25 个周期运行一次未采用的情况,因为它可以在与采用的分支相同的周期内执行未采用的分支,并且具有 4 个 ALU 端口。 (或自 a 5 uop loop doesn't quite issue at 4 uops every cycle 以来略少)。

(刚测试:Skylake @ 3.9GHz 运行整个程序的branchy 版本1.45s,或1.68s 的branchless 版本。所以那里的差异要小得多。)


g++6.3.1 使用 cmov,即使在 -O2,但 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.74 秒内,所以大约是标量的两倍。或 -O3 -march=native 在 0.35 秒内,使用 AVX2 256b 向量)。

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

【讨论】:

  • 顺便说一句,我很久以前就看到了这个问题,可能是在它第一次发布的时候,但我想从回答到现在(当我被提醒时)都被转移了。
  • 在这种情况下,-fprofile-generate-fprofile-use 有帮助吗?
  • @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.68s,而 branchy 版本运行时间为 1.45s,因此与高效 CMOV 的差异要小得多。
  • @MarcGlisse:用更多内容更新了答案。为什么较新的 gcc 使用 add ecx, -128 而不是 CMP?这仅仅是出于代码大小的原因(因为 -128 适合符号扩展的 imm8)?我想这可能值得无缘无故地编写 ecx,因为那时它已经死了,而 OOO 执行可以很快释放它。不过,我很惊讶它仍然没有使用 LEA 在不同的寄存器中计算 sum+data[c] 以避免 MOV。
  • 很多似乎是调整选项,玩 -mtune=... 更改添加到 cmp。不知道莉亚。在 Skylake 笔记本电脑上,-O3 代码明显快于 -O2 代码。
猜你喜欢
  • 2021-11-28
  • 2018-04-14
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-11-07
  • 1970-01-01
  • 1970-01-01
  • 2021-08-28
相关资源
最近更新 更多