【问题标题】:Why does icc fail to handle compile-time branch hints in a reasonable way?为什么 icc 无法以合理的方式处理编译时分支提示?
【发布时间】:2017-01-19 00:07:50
【问题描述】:

开发人员可以使用__builtin_expect builtin 来帮助编译器understand 分支可能去向。

将来,我们可能会为此目的获得standard attribute,但截至今天,至少所有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          

gccClang 都可以轻松解决问题并使用 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 只返回它的第一个参数),但是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 至少在我尝试的第一个和后续示例中。

【问题讨论】:

    标签: c optimization x86 icc built-in


    【解决方案1】:

    对我来说,这似乎是一个 ICC 错误。此代码(available on godbolt

    int c;
    
    do 
    {
        a *= 77;
        c = b--;
    } 
    while (likely(c > 0));  
    

    仅使用辅助本地 var 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 宠坏了)。
    如果他们回答我,我会更新这个答案。

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

    【讨论】:

    • 谢谢,在产品论坛帖子上祈祷。我通常对此类帖子有不好的体验(与 SO 查询相比),但至少英特尔论坛的某些部分优于平均水平(即各种帖子上的实际产品管理眼球)。
    • @BeeOnRope 到目前为止他们还没有回答。我会在我的浏览器中保留标签直到星期二,在那之后我想我们永远不会得到答案。我发现论坛的发帖选项令人困惑,也许我做错了什么。
    • 对我来说看起来不错。我从未在icc 论坛上发过帖子,但其他一些还不错。
    • @BeeOnRope 英特尔在他们的跟踪系统中记录了这个问题。这可能是一个错误。我们会看到:)
    • 太棒了!我还发现问题主要发生在传递给likely 的表达式比简单比较更复杂时——尤其是当它涉及突变时。例如,您可以编写 like thisb-- 像您的示例一样移动到循环中,但取消 c 并恢复最佳循环。
    【解决方案2】:

    不要让说明欺骗您。重要的是性能。

    考虑一下这个相当粗略的测试:

    #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 已编译 (/Od)
    j = -961623752
    4422毫秒

    最优标量
    j = -961623752
    1656毫秒

    可能使用
    j = -961623752
    1641毫秒

    这自然完全取决于 CPU(在 Haswell i7 上进行了测试),但是在对一系列输入进行测试时,两个 asm 循环的性能通常几乎相同。这在很大程度上与指令的选择和排序有关,这些指令有利于利用 CPU 中的指令流水线(延迟)、分支预测和其他硬件优化。

    优化的真正教训是您需要进行分析 - 通过检查原始装配来做到这一点非常困难。

    即使在三分之一的时间里likely(b-- &gt;0) 不正确的情况下进行具有挑战性的测试:

    for (int aa = -10000000; aa < 10000000; aa++) {
        for (int bb = -3; bb < 9; bb++) {
            j += fooX(aa, bb);
        }
    }
    

    结果:

    foo 编译 (/Od) : 1844ms

    最佳标量:906ms

    使用可能:1187ms

    这还不错。您必须记住的是,编译器通常会在不受您干扰的情况下尽力而为。使用__builtin_expect 等应该仅限于您拥有已分析的现有代码并且您已明确识别为热点具有管道或预测问题的情况。这个简单的例子是一个理想的例子,编译器几乎肯定会在没有你帮助的情况下做正确的事情。

    通过包含__builtin_expect,您要求编译器必须以不同的方式进行编译 - 就纯指令数量而言,这是一种更复杂的方式,但更智能的方式是它以以下方式构建程序集:帮助 CPU 做出更好的分支预测。在这种纯粹的寄存器播放的情况下(如本例所示),风险不大,但如果它在更复杂的循环中改进预测,可能会为您避免错误预测、缓存未命中和相关的附带损害,那么它可能值得使用.

    我认为至少在这里很清楚,当分支实际上很可能时,我们几乎可以恢复最佳循环的全部性能(我认为这令人印象深刻)。在“最佳循环”相当复杂且不那么琐碎的情况下,我们可以预期代码生成确实会提高分支预测率(这就是真正的意义所在)。我认为这确实是一种情况,如果你不需要它,就不要使用它。


    关于likelyunlikely 生成相同程序集的话题,这并不意味着编译器已损坏 - 它只是意味着相同的代码生成是有效的,无论分支是否主要采用mostly not taken - 只要它是 mostly 的东西,它就很好(在这种情况下)。代码生成旨在优化指令流水线的使用并协助分支预测,它确实如此。虽然我们看到上述混合情况下的性能有所下降,但将循环推到大部分 unlikely 可以恢复性能。

    for (int aa = -10000000; aa < 10000000; aa++) {
        for (int bb = -30; bb < 1; bb++) {
            j += fooX(aa, bb);
        }
    }
    

    foo 编译 (/Od) : 2453ms

    最佳标量:1968ms

    使用可能:2094ms

    【讨论】:

    • 公平地说,我没有被愚弄。 碰巧,在我的任意示例中,循环携带 mul 依赖关系压倒了循环的其余部分,但从任何指标来看,它都是糟糕的代码生成(例如,代码大小很糟糕,它仍然会很烂在实践中小b)。让我修正一下这个例子,这样它实际上对你的“大循环计数”测试很糟糕。
    • @BeeOnRope 好吧......你告诉它likely(b-- &gt;0)。为什么代码生成器并不理想,但事实并非如此,这会让您感到惊讶?我选择了我的bb 变量范围以适应b-- &gt;0 确实可能的情况。
    • 好吧,codegen 在任何情况下都不理想:对于b == 0 情况,小b 情况更糟 ,中号b 案例和大号b 案例(在这种情况下很难测量差异)。此外,正如我所指出的,当 likely 被替换为 unlikely 时,它会产生 相同 代码 - 所以它实际上根本没有使用启发式算法。
    • @BeeOnRope 在这种情况中,是的,确实更糟,但是你明确地告诉编译器为代码生成添加一些额外的花哨。对于一个可笑的微不足道的循环,我并不感到惊讶它不能很好地工作。
    • @BeeOnRope 至于likelyunlikely 生成相同的代码,这并不意味着缺乏启发式。只是在这种情况下,分支是否可能无关紧要,代码生成有助于指令流水线和预测,无论跳转最终是否大部分被采用。只要它主要是采取未采取,它就会变得有效。
    猜你喜欢
    • 2014-08-02
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-07-19
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多