【问题标题】:Does GCC generate suboptimal code for static branch prediction?GCC 是否为静态分支预测生成次优代码?
【发布时间】:2017-06-12 08:33:01
【问题描述】:

从我的大学课程中,我听说,按照惯例,最好将更可能的条件放在 if 中而不是 else 中,这可能有助于 static 分支预测器。例如:

if (check_collision(player, enemy)) { // very unlikely to be true
    doA();
} else {
    doB();
}

可以改写为:

if (!check_collision(player, enemy)) {
    doB();
} else {
    doA();
}

我找到了一篇博文Branch Patterns, Using GCC,对这个现象进行了更详细的解释:

为 if 语句生成前向分支。理由 使它们不太可能被采取的是处理器可以采取 分支之后的指令的优势 指令可能已经放置在指令缓冲区内 指令单元。

在它旁边,它说(强调我的):

在编写 if-else 语句时,总是使“then”块更多 比 else 块更可能被执行,所以处理器可以采取 已经放置在取指中的指令的优势 缓冲区。

最后,有一篇文章,Intel 写的,Branch and Loop Reorganization to Prevent Mispredicts,总结了两条规则:

当没有收集到数据时使用静态分支预测 微处理器遇到分支时,通常是 第一次遇到分支。规则很简单:

  • 前向分支默认为不采用
  • 后向分支默认为采用

为了有效地编写代码以利用这些 规则,在编写 if-elseswitch 语句时,检查最 首先是常见情况,然后逐步处理到最不常见的情况。

据我了解,这个想法是流水线 CPU 可以遵循指令缓存中的指令,而不会通过跳转到代码段中的另一个地址来破坏它。不过,我知道,在现代 CPU 微架构的情况下,这可能会被过度简化。

但是,GCC 似乎不遵守这些规则。给定代码:

extern void foo();
extern void bar();

int some_func(int n)
{
    if (n) {
        foo();
    }
    else {
        bar();
    }
    return 0;
}

它生成(带有-O3 -mtune=intel的版本6.3.0):

some_func:
        lea     rsp, [rsp-8]
        xor     eax, eax
        test    edi, edi
        jne     .L6            ; here, forward branch if (n) is (conditionally) taken
        call    bar
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret
.L6:
        call    foo
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret

我发现强制执行所需行为的唯一方法是使用__builtin_expect 重写if 条件,如下所示:

if (__builtin_expect(n, 1)) { // force n condition to be treated as true

所以汇编代码会变成:

some_func:
        lea     rsp, [rsp-8]
        xor     eax, eax
        test    edi, edi
        je      .L2             ; here, backward branch is (conditionally) taken
        call    foo
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret
.L2:
        call    bar
        xor     eax, eax
        lea     rsp, [rsp+8]
        ret

【问题讨论】:

  • stackoverflow.com/q/109710/905902linux内核使用宏(都是__builtin_expect)来使用条件分支的先验知识。
  • 现代 Intel CPU 不使用静态分支预测。我也不认为 GCC 在任何地方都承诺将 if/else 语句的“真实”子句视为最有可能的替代方案。你应该使用__builtin_expect,就像提到的wildplasser,告诉它哪个更有可能。或者更好的是,配置文件引导优化。
  • 参见 Anger Fog 的微架构手册。第 3.16 节“PM 和核心 2 中的静态预测”:“这些处理器不使用静态预测。预测器仅在第一次看到分支时进行随机预测,具体取决于分配给的 BTB 条目中发生的情况新的分支。”。 agner.org/optimize
  • 即使在一个完整的程序中,它也不太重要。除非您使用仅具有静态预测功能的处理器,否则大多数跳跃将被动态预测。
  • 出于某种原因,gcc 的 profile_estimate pass 猜测 n 有 54% 的机会为 0...(请参阅 -fdump-tree-all-all)通常它有一个启发式 == 更有可能是错误的,但它没有'似乎没有在这里使用。你可以把它提交到 gcc 的 bugzilla 上询问它。请注意,如果您使用-fprofile-generate 编译,然后运行您的程序,然后使用-fprofile-use 重新编译,gcc 将可以访问真实的统计数据并做出更好的决策。

标签: c gcc assembly x86 branch-prediction


【解决方案1】:

简短的回答:不,不是。

GCC 进行了大量非平凡的优化,其中之一是通过控制流图来猜测分支概率。

根据GCC manual

fno-guess-branch-probability

不要使用猜测分支概率 启发式。

GCC 使用启发式方法来猜测分支概率(如果不是) 由分析反馈 (-fprofile-arcs) 提供。这些启发式是 基于控制流图。如果某些分支概率是 由__builtin_expect指定,然后使用启发式猜测 控制流图其余部分的分支概率,取 __builtin_expect 信息考虑在内。之间的相互作用 heuristics 和 __builtin_expect 可能很复杂,在某些情况下,它 禁用启发式可能很有用,以便 __builtin_expect 更容易理解。

-freorder-blocks 也可以交换分支。

此外,正如 OP 所述,该行为可能会被 __builtin_expect 覆盖。

证明

请看下面的清单。

void doA() { printf("A\n"); }
void doB() { printf("B\n"); }
int check_collision(void* a, void* b)
{ return a == b; }

void some_func (void* player, void* enemy) {
    if (check_collision(player, enemy)) {
        doA();
    } else {
        doB();
    }
}

int main() {
    // warming up gcc statistic
    some_func((void*)0x1, NULL);
    some_func((void*)0x2, NULL);
    some_func((void*)0x3, NULL);
    some_func((void*)0x4, NULL);
    some_func((void*)0x5, NULL);
    some_func(NULL, NULL);
    return 0;
}

很明显check_collision 在大多数情况下都会返回0。所以,doB() 分支很可能,GCC 可以猜到这一点:

gcc -O main.c -o opt.a
objdump -d opt.a

some_func 的汇编是:

sub    $0x8,%rsp
cmp    %rsi,%rdi
je     6c6 <some_func+0x18>
mov    $0x0,%eax
callq  68f <doB>
add    $0x8,%rsp
retq   
mov    $0x0,%eax
callq  67a <doA>
jmp    6c1 <some_func+0x13>

但可以肯定的是,我们可以强制 GCC 不要过于聪明:

gcc -fno-guess-branch-probability main.c -o non-opt.a
objdump -d non-opt.a

我们会得到:

push   %rbp
mov    %rsp,%rbp
sub    $0x10,%rsp
mov    %rdi,-0x8(%rbp)
mov    %rsi,-0x10(%rbp)
mov    -0x10(%rbp),%rdx
mov    -0x8(%rbp),%rax
mov    %rdx,%rsi
mov    %rax,%rdi
callq  6a0 <check_collision>
test   %eax,%eax
je     6ef <some_func+0x33>
mov    $0x0,%eax
callq  67a <doA>
jmp    6f9 <some_func+0x3d>
mov    $0x0,%eax
callq  68d <doB>
nop
leaveq 
retq  

所以 GCC 会按照源码顺序留下分支。

我使用 gcc 7.1.1 进行这些测试。

【讨论】:

  • 公平地说,您应该使用相同的-O 标志编译两个版本,然后在第二个中包含-fno-guess-branch-probability。没有优化的代码与第一个带有-O 的代码完全不同,你不能真正断定它只是-fno-guess-branch-probability 标志改变了块顺序,因为有许多其他标志和优化应用于前者而不是后者。
【解决方案2】:

我认为你发现了一个“错误”

有趣的是 space 的优化和 no 优化是 生成“最佳”指令代码的情况:gcc -S [-O0 | -Os] source.c

some_func:
FB0:
       pushl   %ebp
       movl    %esp, %ebp
       subl    $8, %esp
       cmpl    $0, 8(%ebp)
       je      L2
       call    _foo
       jmp     L3
2:
       call    _bar
3:
       movl    $0, %eax
       # Or, for -Os:
       # xorl    %eax, %eax
       leave
       ret

我的意思是……


some_func:
FB0:
       pushl   %ebp
       movl    %esp, %ebp
       subl    $8, %esp
       cmpl    $0, 8(%ebp)
       je      L2
       call    _foo

...直到和通过对foo 的调用,在传统意义上,无论退出策略如何,一切都是“最佳的”。

当然,最优性最终由处理器决定。

【讨论】:

  • 真的吗?另一个没有解释的否决票?这对任何人有什么帮助?这是确切的汇编输出(减去指令),它 传统 CPU 的最佳选择——它跳转到 else,而不是真正的条件。 gcc 的相应选项是违反直觉的。在这种情况下,对于现代 x86 CPU,没有最佳代码。
  • 您展示的整个功能绝对不是任何 CPU 的最佳选择。它使用mov $0, %eax 将返回值归零,而不是xor %eax,%eax。 (这必须是-O0 输出,而不是-Os 输出。)另外,尾部复制将避免call foo 路径上的jmp L3。此外,没有必要设置帧指针。不反对,因为看看 gcc 如何在这里布置分支至少很有趣。
  • 我们专门讨论的是分支预测。因此,在某些时候需要jmp 指令。关键是有没有 jmp 可以调用foo...jmp 是调用bar。而-O0-Os 之间的唯一区别在于,如何将零返回到调用环境。 --对于使用传统分支预测的(旧)x86 CPU,代码最优的(即在采用then 的情况下没有jmp)。
  • 我说的是jmp,而不是je。当然你需要一个je(除非你在两个函数指针之间进行无分支选择),但是je后面的代码可以是call _foo/xor %eax,%eax/leave/ret以避免jmp .尾部复制增加了代码大小,但减少了动态指令数,并减少了跳转的数量。对于call _foo 案例,没有采取jmpjcc 指令。
  • 分支预测,在这种情况下,关注的是 jump before call。据我所知,jmp L3 只是一个必要的副作用;到达退出代码。
猜你喜欢
  • 2013-09-04
  • 2018-07-02
  • 2012-07-02
  • 2015-11-24
  • 1970-01-01
  • 2015-05-11
  • 1970-01-01
  • 1970-01-01
  • 2018-07-03
相关资源
最近更新 更多