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) 来理解生成的二进制文件,但我的汇编技能还不够。
补充观察
最里面 if
中的显式 break
使 -O1
代码再次变快:
if (c == 0u) {
i = a + b; // Not actually needed any more
break;
}
与“内联”循环的进度表达式一样:
for (i = 2u; i < a + b; ) { // No ++i any more
c = (a + b) % i;
if (c == 0u) {
i = a + b;
++i;
} else {
++i;
}
}
问题
哪个优化 does/could 解释了性能下降?
是否可以解释一下是什么触发了 C++ 代码方面的优化(即没有深入了解 GCC 的内部结构)?
同样,对于为什么备选方案(附加观察)明显阻止流氓优化,是否有高级解释?
我认为这是 由编译器在使用 -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 的条件,因此不会引入有问题的数据依赖性。
请注意:这个问题既不关于代码质量,也不是关于改进代码的方法,也不 关于运行时差异的(不)重要性。 关于 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) 来理解生成的二进制文件,但我的汇编技能还不够。
补充观察
最里面
if
中的显式break
使-O1
代码再次变快:if (c == 0u) { i = a + b; // Not actually needed any more break; }
与“内联”循环的进度表达式一样:
for (i = 2u; i < a + b; ) { // No ++i any more c = (a + b) % i; if (c == 0u) { i = a + b; ++i; } else { ++i; } }
问题
哪个优化 does/could 解释了性能下降?
是否可以解释一下是什么触发了 C++ 代码方面的优化(即没有深入了解 GCC 的内部结构)?
同样,对于为什么备选方案(附加观察)明显阻止流氓优化,是否有高级解释?
我认为这是 由编译器在使用 -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 的条件,因此不会引入有问题的数据依赖性。