为什么 icc 无法以合理的方式处理编译时分支提示?
Why does icc fail to handle compile-time branch hints in a reasonable way?
开发人员可以使用 __builtin_expect
builtin to help the compiler understand 分支 可能 的发展方向。
将来,我们可能会为此目的获得 ,但截至今天,至少所有 clang
、icc
和 gcc
都支持非-改为标准 __builtin_expect
。
但是,icc
在您使用它时似乎会生成奇怪的可怕代码1。也就是说,使用内置函数的代码严格来说比没有内置函数的代码差,无论预测的方向如何。
以下面的玩具函数为例:
int foo(int a, int b)
{
do {
a *= 77;
} while (b-- > 0);
return a * 77;
}
在三个编译器中,icc
是唯一将其编译为 3 条指令的 optimal scalar loop 的编译器:
foo(int, int):
..B1.2: # Preds ..B1.2 ..B1.1
imul edi, edi, 77 #4.6
dec esi #5.12
jns ..B1.2 # Prob 82% #5.18
imul eax, edi, 77 #6.14
ret
两者 gcc and Clang 管理错过简单的解决方案并使用 5 条指令。
另一方面,当您在循环条件中使用 likely
或 unlikely
宏时,icc
会完全脑残:
#define likely(x) __builtin_expect((x), 1)
#define unlikely(x) __builtin_expect((x), 0)
int foo(int a, int b)
{
do {
a *= 77;
} while (likely(b-- > 0));
return a * 77;
}
这个循环在功能上等同于前一个循环(因为 __builtin_expect
只是 returns 它的第一个参数),但是 icc produces some awful code:
foo(int, int):
mov eax, 1 #9.12
..B1.2: # Preds ..B1.2 ..B1.1
xor edx, edx #9.12
test esi, esi #9.12
cmovg edx, eax #9.12
dec esi #9.12
imul edi, edi, 77 #8.6
test edx, edx #9.12
jne ..B1.2 # Prob 95% #9.12
imul eax, edi, 77 #11.15
ret #11.15
该函数的大小增加了一倍,达到 10 条指令,并且(更糟糕的是!)关键循环增加了一倍多,达到 7 条指令,并且有一个涉及 cmov
和其他奇怪东西的长关键依赖链。
如果您使用 unlikely
hint 并且跨 godbolt 支持的所有 icc 版本(13、14、17)也是如此。因此,无论提示如何,也无论实际的运行时行为如何,代码生成都非常糟糕。
使用提示时,gcc
和 clang
都不会出现任何降级。
这是怎么回事?
1 至少在我尝试的第一个和随后的示例中。
不要让说明欺骗了您。重要的是性能。
考虑这个相当粗略的测试:
#include "stdafx.h"
#include <windows.h>
#include <iostream>
int foo(int a, int b) {
do { a *= 7; } while (b-- > 0);
return a * 7;
}
int fooA(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, a
B1:
imul edi, edi, 7
dec esi
jns B1
imul eax, edi, 7
}
}
int fooB(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, 1
B1:
xor edx, edx
test esi, esi
cmovg edx, eax
dec esi
imul edi, edi, 7
test edx, edx
jne B1
imul eax, edi, 7
}
}
int main() {
DWORD start = GetTickCount();
int j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += foo(aa, bb);
}
}
std::cout << "foo compiled (/Od)\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooA(aa, bb);
}
}
std::cout << "optimal scalar\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooB(aa, bb);
}
}
std::cout << "use likely \n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
std::cin.get();
return 0;
}
产生输出:
foo compiled (/Od)
j = -961623752
4422ms
optimal scalar
j = -961623752
1656ms
use likely
j = -961623752
1641ms
这自然完全 CPU 相关(此处在 Haswell i7 上测试),但是在对一系列输入进行测试时,两个 asm 循环的性能通常几乎相同。这在很大程度上与指令的选择和排序有关,有助于在 CPU 中利用指令流水线(延迟)、分支预测和其他硬件优化。
优化时的真正教训是您需要分析 - 通过检查原始装配来做到这一点非常困难。
即使进行具有挑战性的测试,其中 likely(b-- >0)
在三分之一的时间里都不成立 :
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -3; bb < 9; bb++) {
j += fooX(aa, bb);
}
}
结果:
foo compiled (/Od) : 1844ms
optimal scalar : 906ms
use likely : 1187ms
还不错。您必须记住的是,编译器通常会在没有您干预的情况下尽力而为。使用 __builtin_expect
等应该真正限于您拥有已分析的现有代码并且您已明确将热点 和 确定为具有管道或预测的情况问题。这个简单的例子是一个理想的例子,编译器几乎肯定会在没有你帮助的情况下做正确的事情。
通过包含 __builtin_expect
,您要求编译器必须以不同的方式进行编译 - 一种更复杂的方式,就纯粹的指令数量而言,但更智能的方式是构建程序集以帮助 CPU 做出更好的分支预测的方式。在这种纯寄存器播放的情况下(如本例所示),风险不大,但如果它在更复杂的循环中改进预测,也许可以避免严重的错误预测、缓存未命中和相关的附带损害,那么它可能值得使用.
我认为这里很清楚,至少,当分支 实际上 可能时,我们几乎恢复了最佳循环的全部性能(我认为这令人印象深刻).在 "optimal loop" 相当复杂且不那么琐碎的情况下,我们可以预期代码生成确实会提高分支预测率(这就是真正的意义所在)。我认为这确实是一个如果你不需要它,就不要使用它。
关于 likely
与 unlikely
生成相同程序集的主题,这并不意味着编译器已损坏 - 它只是意味着相同的代码生成器无论分支是否有效是大部分还是大部分不被——只要是大部分的东西就好(在这种情况下)。 codegen 旨在优化指令流水线的使用并协助分支预测,它确实这样做了。虽然我们看到上述混合情况的性能有所下降,但将循环推到大部分 unlikely
可以恢复性能。
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -30; bb < 1; bb++) {
j += fooX(aa, bb);
}
}
foo compiled (/Od) : 2453ms
optimal scalar : 1968ms
use likely : 2094ms
对我来说,这似乎是一个 ICC 错误。此代码 (available on godbolt)
int c;
do
{
a *= 77;
c = b--;
}
while (likely(c > 0));
仅使用辅助局部变量 c
,产生没有 edx = !!(esi > 0)
模式的输出
foo(int, int):
..B1.2:
mov eax, esi
dec esi
imul edi, edi, 77
test eax, eax
jg ..B1.2
仍然不是最优的(没有 eax
也可以),不过。
不知道官方有没有ICC policy about __builtin_expect
is full support or just compatibility support.
这个问题似乎更适合Official ICC forum。
我试过 posting this topic there 但我不确定我做得好(我被 SO 宠坏了)。
如果他们回答我,我会更新这个答案。
编辑
我在英特尔论坛上得到了答案,他们在他们的跟踪系统中记录了这个问题。
就像今天一样,这似乎是一个错误。
开发人员可以使用 __builtin_expect
builtin to help the compiler understand 分支 可能 的发展方向。
将来,我们可能会为此目的获得 clang
、icc
和 gcc
都支持非-改为标准 __builtin_expect
。
但是,icc
在您使用它时似乎会生成奇怪的可怕代码1。也就是说,使用内置函数的代码严格来说比没有内置函数的代码差,无论预测的方向如何。
以下面的玩具函数为例:
int foo(int a, int b)
{
do {
a *= 77;
} while (b-- > 0);
return a * 77;
}
在三个编译器中,icc
是唯一将其编译为 3 条指令的 optimal scalar loop 的编译器:
foo(int, int):
..B1.2: # Preds ..B1.2 ..B1.1
imul edi, edi, 77 #4.6
dec esi #5.12
jns ..B1.2 # Prob 82% #5.18
imul eax, edi, 77 #6.14
ret
两者 gcc and Clang 管理错过简单的解决方案并使用 5 条指令。
另一方面,当您在循环条件中使用 likely
或 unlikely
宏时,icc
会完全脑残:
#define likely(x) __builtin_expect((x), 1)
#define unlikely(x) __builtin_expect((x), 0)
int foo(int a, int b)
{
do {
a *= 77;
} while (likely(b-- > 0));
return a * 77;
}
这个循环在功能上等同于前一个循环(因为 __builtin_expect
只是 returns 它的第一个参数),但是 icc produces some awful code:
foo(int, int):
mov eax, 1 #9.12
..B1.2: # Preds ..B1.2 ..B1.1
xor edx, edx #9.12
test esi, esi #9.12
cmovg edx, eax #9.12
dec esi #9.12
imul edi, edi, 77 #8.6
test edx, edx #9.12
jne ..B1.2 # Prob 95% #9.12
imul eax, edi, 77 #11.15
ret #11.15
该函数的大小增加了一倍,达到 10 条指令,并且(更糟糕的是!)关键循环增加了一倍多,达到 7 条指令,并且有一个涉及 cmov
和其他奇怪东西的长关键依赖链。
如果您使用 unlikely
hint 并且跨 godbolt 支持的所有 icc 版本(13、14、17)也是如此。因此,无论提示如何,也无论实际的运行时行为如何,代码生成都非常糟糕。
使用提示时,gcc
和 clang
都不会出现任何降级。
这是怎么回事?
1 至少在我尝试的第一个和随后的示例中。
不要让说明欺骗了您。重要的是性能。
考虑这个相当粗略的测试:
#include "stdafx.h"
#include <windows.h>
#include <iostream>
int foo(int a, int b) {
do { a *= 7; } while (b-- > 0);
return a * 7;
}
int fooA(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, a
B1:
imul edi, edi, 7
dec esi
jns B1
imul eax, edi, 7
}
}
int fooB(int a, int b) {
__asm {
mov esi, b
mov edi, a
mov eax, 1
B1:
xor edx, edx
test esi, esi
cmovg edx, eax
dec esi
imul edi, edi, 7
test edx, edx
jne B1
imul eax, edi, 7
}
}
int main() {
DWORD start = GetTickCount();
int j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += foo(aa, bb);
}
}
std::cout << "foo compiled (/Od)\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooA(aa, bb);
}
}
std::cout << "optimal scalar\n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
start = GetTickCount();
j = 0;
for (int aa = -10; aa < 10; aa++) {
for (int bb = -500; bb < 15000; bb++) {
j += fooB(aa, bb);
}
}
std::cout << "use likely \n" << "j = " << j << "\n"
<< GetTickCount() - start << "ms\n\n";
std::cin.get();
return 0;
}
产生输出:
foo compiled (/Od)
j = -961623752
4422msoptimal scalar
j = -961623752
1656msuse likely
j = -961623752
1641ms
这自然完全 CPU 相关(此处在 Haswell i7 上测试),但是在对一系列输入进行测试时,两个 asm 循环的性能通常几乎相同。这在很大程度上与指令的选择和排序有关,有助于在 CPU 中利用指令流水线(延迟)、分支预测和其他硬件优化。
优化时的真正教训是您需要分析 - 通过检查原始装配来做到这一点非常困难。
即使进行具有挑战性的测试,其中 likely(b-- >0)
在三分之一的时间里都不成立 :
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -3; bb < 9; bb++) {
j += fooX(aa, bb);
}
}
结果:
foo compiled (/Od) : 1844ms
optimal scalar : 906ms
use likely : 1187ms
还不错。您必须记住的是,编译器通常会在没有您干预的情况下尽力而为。使用 __builtin_expect
等应该真正限于您拥有已分析的现有代码并且您已明确将热点 和 确定为具有管道或预测的情况问题。这个简单的例子是一个理想的例子,编译器几乎肯定会在没有你帮助的情况下做正确的事情。
通过包含 __builtin_expect
,您要求编译器必须以不同的方式进行编译 - 一种更复杂的方式,就纯粹的指令数量而言,但更智能的方式是构建程序集以帮助 CPU 做出更好的分支预测的方式。在这种纯寄存器播放的情况下(如本例所示),风险不大,但如果它在更复杂的循环中改进预测,也许可以避免严重的错误预测、缓存未命中和相关的附带损害,那么它可能值得使用.
我认为这里很清楚,至少,当分支 实际上 可能时,我们几乎恢复了最佳循环的全部性能(我认为这令人印象深刻).在 "optimal loop" 相当复杂且不那么琐碎的情况下,我们可以预期代码生成确实会提高分支预测率(这就是真正的意义所在)。我认为这确实是一个如果你不需要它,就不要使用它。
关于 likely
与 unlikely
生成相同程序集的主题,这并不意味着编译器已损坏 - 它只是意味着相同的代码生成器无论分支是否有效是大部分还是大部分不被——只要是大部分的东西就好(在这种情况下)。 codegen 旨在优化指令流水线的使用并协助分支预测,它确实这样做了。虽然我们看到上述混合情况的性能有所下降,但将循环推到大部分 unlikely
可以恢复性能。
for (int aa = -10000000; aa < 10000000; aa++) {
for (int bb = -30; bb < 1; bb++) {
j += fooX(aa, bb);
}
}
foo compiled (/Od) : 2453ms
optimal scalar : 1968ms
use likely : 2094ms
对我来说,这似乎是一个 ICC 错误。此代码 (available on godbolt)
int c;
do
{
a *= 77;
c = b--;
}
while (likely(c > 0));
仅使用辅助局部变量 c
,产生没有 edx = !!(esi > 0)
模式的输出
foo(int, int):
..B1.2:
mov eax, esi
dec esi
imul edi, edi, 77
test eax, eax
jg ..B1.2
仍然不是最优的(没有 eax
也可以),不过。
不知道官方有没有ICC policy about __builtin_expect
is full support or just compatibility support.
这个问题似乎更适合Official ICC forum。
我试过 posting this topic there 但我不确定我做得好(我被 SO 宠坏了)。
如果他们回答我,我会更新这个答案。
编辑
我在英特尔论坛上得到了答案,他们在他们的跟踪系统中记录了这个问题。
就像今天一样,这似乎是一个错误。