GCC 编译器优化的不利性能影响的解释?

Explanation for GCC compiler optimisation's adverse performance effect?

请注意:这个问题既不关于代码质量,也不是关于改进代码的方法,也不 关于运行时差异的(不)重要性。 关于 GCC 以及为什么哪个编译器优化会降低性能。

程序

以下代码计算斐波那契素数的个数,最多为m:

int main() {
  unsigned int m = 500000000u;
  unsigned int i = 0u;
  unsigned int a = 1u; 
  unsigned int b = 1u; 
  unsigned int c = 1u;
  unsigned int count = 0u;

  while (a + b <= m) {
    for (i = 2u; i < a + b; ++i) {
      c = (a + b) % i;

      if (c == 0u) {
        i = a + b;
        // break;
      }
    }
    
    if (c != 0u) {
      count = count + 1u;
    } 
  
    a = a + b;
    b = a - b;
  }

  return count; // Just to "output" (and thus use) count
}

当使用 g++.exe (Rev2, Built by MSYS2 project) 9.2.0 编译且没有优化 (-O0) 时,生成的二进制文件(在我的机器上)执行时间为 1.9 秒。使用 -O1-O3 分别需要 3.3 秒和 1.7 秒。

我试图通过查看汇编代码 (godbolt.org) and the corresponding control-flow graph (hex-rays.com/products/ida) 来理解生成的二进制文件,但我的汇编技能还不够。

补充观察

  1. 最里面 if 中的显式 break 使 -O1 代码再次变快:

    if (c == 0u) {
      i = a + b; // Not actually needed any more
      break;
    }
    
  2. 与“内联”循环的进度表达式一样:

    for (i = 2u; i < a + b; ) { // No ++i any more
      c = (a + b) % i;
    
      if (c == 0u) {
        i = a + b;
        ++i;
      } else {
        ++i;
      }
    }
    

问题

  1. 哪个优化 does/could 解释了性能下降?

  2. 是否可以解释一下是什么触发了 C++ 代码方面的优化(即没有深入了解 GCC 的内部结构)?

  3. 同样,对于为什么备选方案(附加观察)明显阻止流氓优化,是否有高级解释?

我认为这是 由编译器在使用 -O1-O2 时生成的“条件移动”指令 (CMOVEcc) 引起的.

当使用-O0时,语句if (c == 0u)被编译为一个跳转:

    cmp     DWORD PTR [rbp-16], 0
    jne     .L4

-O1-O2:

    test    edx, edx
    cmove   ecx, esi

-O3 产生跳跃(类似于 -O0):

    test    edx, edx
    je      .L5

There is a known bug in gcc 其中“使用条件移动而不是比较和分支 导致代码速度几乎慢 2 倍

正如 rodrigo 在他的评论中建议的那样,使用标志 -fno-if-conversion 告诉 gcc 不要用条件移动替换分支,从而防止此性能问题。

我认为问题出在指示编译器执行 CMOV 而不是 TEST/JZ 进行某些比较的 -fif-conversion 中。 CMOV 以在一般情况下不太好而闻名。

据我所知,反汇编中有两点受此标志影响:

首先,第13行的if (c == 0u) { i = a + b; }被编译为:

test   edx,edx //edx is c
cmove  ecx,esi //esi is (a + b), ecx is i

其次,if (c != 0u) { count = count + 1u; }编译为

cmp    eax,0x1   //eax is c
sbb    r8d,0xffffffff //r8d is count, but what???

不错的把戏!它是将 -1 减去 count 但有进位,只有当 c 小于 1 时才设置进位,无符号意味着 0。因此,如果 eax 为 0,它会将 -1 减去 count,然后再次减去 1:它不会改变。如果 eax 不为 0,则减去 -1,即递增变量。

现在,这避免了分支,但代价是缺少明显的优化,如果 c == 0u 你可以直接跳到下一个 while 迭代。这个非常简单,甚至可以在 -O0.

中完成

这里重要的是 loop-carried 数据依赖性.

查看最内层循环的慢变体的机器代码。我在这里展示 -O2 程序集,-O1 优化程度较低,但总体上具有相似的数据依赖性:

.L4:
        xorl    %edx, %edx
        movl    %esi, %eax
        divl    %ecx
        testl   %edx, %edx
        cmove   %esi, %ecx
        addl    , %ecx
        cmpl    %ecx, %esi
        ja      .L4

看看%ecx中循环计数器的增量如何依赖于前面的指令(cmov),而这又取决于除法的结果,而除法的结果又取决于循环计数器的先前值。

实际上,在计算 %ecx 中的值时存在一个跨越整个循环的数据依赖链,并且由于执行循环的时间占主导地位,计算该链的时间决定了执行时间程序。

调整程序计算分割数显示它执行了 434044698 div 条指令。在我的例子中,程序占用的机器周期数除以这个数字得到 26 个周期,这与 div 指令的延迟加上链中其他指令的大约 3 或 4 个周期(链是div-test-cmov-add).

相比之下,-O3 代码没有这个依赖链,使其成为 throughput-bound 而不是 latency-bound:执行-O3变体的时间由计算434044698条独立div条指令的时间决定。

最后,对你的问题给出具体的回答:

1.哪个优化 does/could 解释了性能下降?

正如提到的另一个答案,这是 if-conversion 创建一个 loop-carried 数据依赖关系,其中最初有一个控制依赖关系。当控制依赖对应于不可预测的分支时,它们也可能是昂贵的,但在这种情况下,分支很容易预测。

2。是否可以解释是什么触发了 C++ 代码方面的优化(即没有深入了解 GCC 的内部结构)?

也许你可以想象优化将代码转换为

    for (i = 2u; i < a + b; ++i) {
      c = (a + b) % i;
      i = (c != 0) ? i : a + b; 
    }

在 CPU 上计算三元运算符,使得 i 的新值在计算 c 之前未知。

3。同样,是否有 high-level 解释为什么备选方案(附加观察)明显阻止流氓优化?

在这些变体中,代码不符合 if-conversion 的条件,因此不会引入有问题的数据依赖性。