为什么 icc 无法以合理的方式处理编译时分支提示?

Why does icc fail to handle compile-time branch hints in a reasonable way?

开发人员可以使用 __builtin_expect builtin to help the compiler understand 分支 可能 的发展方向。

将来,我们可能会为此目的获得 ,但截至今天,至少所有 clangiccgcc 都支持非-改为标准 __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 条指令。

另一方面,当您在循环条件中使用 likelyunlikely 宏时,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)也是如此。因此,无论提示如何,也无论实际的运行时行为如何,代码生成都非常糟糕。

使用提示时,gccclang 都不会出现任何降级。

这是怎么回事?


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" 相当复杂且不那么琐碎的情况下,我们可以预期代码生成确实会提高分支预测率(这就是真正的意义所在)。我认为这确实是一个如果你不需要它,就不要使用它。


关于 likelyunlikely 生成相同程序集的主题,这并不意味着编译器已损坏 - 它只是意味着相同的代码生成器无论分支是否有效是大部分还是大部分不被——只要是大部分的东西就好(在这种情况下)。 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 宠坏了)。
如果他们回答我,我会更新这个答案。

编辑
我在英特尔论坛上得到了答案,他们在他们的跟踪系统中记录了这个问题。
就像今天一样,这似乎是一个错误。