【问题标题】:Why is gcc emitting worse code with __builtin_unreachable?为什么 gcc 会使用 __builtin_unreachable 发出更糟糕的代码?
【发布时间】:2022-01-14 04:11:44
【问题描述】:

f0f1 如下,

long long b;

void f0(int a) {
    a %= 10;
    if (a == 0) b += 11;
    else if (a == 1) b += 13;
    else if (a == 2) b += 17;
    else if (a == 3) b += 19;
    else if (a == 4) b += 23;
    else if (a == 5) b += 29;
    else if (a == 6) b += 31;
    else if (a == 7) b += 37;
    else if (a == 8) b += 41;
    else if (a == 9) b += 43;
}

void f1(int a) {
    a %= 10;
    if (a == 0) b += 11;
    else if (a == 1) b += 13;
    else if (a == 2) b += 17;
    else if (a == 3) b += 19;
    else if (a == 4) b += 23;
    else if (a == 5) b += 29;
    else if (a == 6) b += 31;
    else if (a == 7) b += 37;
    else if (a == 8) b += 41;
    else if (a == 9) b += 43;
    else __builtin_unreachable();
}

假设参数a 在程序中始终为正,编译器应该为f1 生成更优化的代码,因为在f0 中,a 在为负时可能会通过 if-else 块,所以编译器应该生成默认的“什么都不做并返回”代码。但是在f1 中,a 的可能范围用__builtin_unreachable 明确说明,这样编译器就不必考虑a 何时超出范围。

但是,f1 实际上运行速度较慢,所以我查看了反汇编。这是f0的控制流部分。

    jne .L2
    addq    $11, b(%rip)
    ret
    .p2align 4,,10
    .p2align 3
.L2:
    cmpl    $9, %eax
    ja  .L1
    movl    %eax, %eax
    jmp *.L5(,%rax,8)
    .section    .rodata
    .align 8
    .align 4
.L5:
    .quad   .L1
    .quad   .L13
    .quad   .L12
    .quad   .L11
    .quad   .L10
    .quad   .L9
    .quad   .L8
    .quad   .L7
    .quad   .L6
    .quad   .L4
    .text
    .p2align 4,,10
    .p2align 3
.L4:
    addq    $43, b(%rip)
.L1:
    ret
    .p2align 4,,10
    .p2align 3
.L6:
    addq    $41, b(%rip)
    ret
    .p2align 4,,10
    .p2align 3
...

gcc 巧妙地将 if-else 块转换为跳转表,并将默认大小写 L1 放在 L4 内以节省空间。

现在来看看f1拆解后的整个控制流程。

    jne .L42
    movq    b(%rip), %rax
    addq    $11, %rax
.L43:
    movq    %rax, b(%rip)
    ret
    .p2align 4,,10
    .p2align 3
.L42:
    movl    %eax, %eax
    jmp *.L46(,%rax,8)
    .section    .rodata
    .align 8
    .align 4
.L46:
    .quad   .L45
    .quad   .L54
    .quad   .L53
    .quad   .L52
    .quad   .L51
    .quad   .L50
    .quad   .L49
    .quad   .L48
    .quad   .L47
    .quad   .L45
    .text
    .p2align 4,,10
    .p2align 3
.L47:
    movq    b(%rip), %rax
    addq    $41, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L48:
    movq    b(%rip), %rax
    addq    $37, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L49:
    movq    b(%rip), %rax
    addq    $31, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L50:
    movq    b(%rip), %rax
    addq    $29, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L51:
    movq    b(%rip), %rax
    addq    $23, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L52:
    movq    b(%rip), %rax
    addq    $19, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L53:
    movq    b(%rip), %rax
    addq    $17, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L54:
    movq    b(%rip), %rax
    addq    $13, %rax
    jmp .L43
    .p2align 4,,10
    .p2align 3
.L45:
    movq    b(%rip), %rax
    addq    $43, %rax
    jmp .L43

是的,gcc 确实捕获了__builtin_unreachable,但是由于某种原因,每次返回之前都有一个不必要的跳转,并且跳转表有一个重复的条目L45。此外,它不是简单的addq $N, b(%rip),而是在返回之前一直写movq b(%rip), %raxaddq $N, %rax,然后是movq %rax, b(%rip)

是什么让 gcc 产生了明显愚蠢的代码?

二进制是在Fedora Linux下用-O3编译的,我用的gcc版本是11.2.1 20211203

【问题讨论】:

  • 或许应该在 GCC 的 bugzilla 上报告这个问题,gcc.gnu.org/bugzilla,使用关键字“missed-optimization”
  • 如果将else if (a == 9) 更改为简单的else 会发生什么?
  • 1.代码不一样。 2. 这种行为在 11.xx 版本中有所改变。在 11 之前,生成的代码完全相同。 3. 你真的测试了执行时间吗?指令数量不一定是最佳性能指标。 -Os 使生成的代码相同
  • @0___________ 我确实检查了执行时间,f1 始终较慢。 -Os 不会使生成的代码在我的 gcc 版本中相同。不必要的jmpretmovqaddqmovq 序列仍然存在。
  • clang 能够发现它可以使用查找表作为加数,并避免效率较低的跳转表:godbolt.org/z/vbv7vdWc9 尽管即使使用__builtin_unreachable(),它也没有注意到它可以跳过范围检查。 (如果它能够确定表条目可以是 8 位而不是 64 位并加载零扩展名,那就太好了 - 将节省 70 个字节。)无论如何,如果你真的需要优化这样的东西,你可能想要首先将其写为查找表。

标签: c assembly gcc compiler-optimization unreachable-code


【解决方案1】:

这是我能想到的最佳解释。

编译器显然可以(至少在某种程度上)进行优化,其中 if/else 树的所有分支共有的代码可以被分解(根据需要提升或沉没)。但在f0 版本中,无法应用此优化,因为“默认”案例根本没有代码,特别是既不加载也不存储b。因此,编译器只是尽可能地单独优化案例,将每个案例保留为单个 RMW 添加内存指令。

f1 版本中,您的__builtin_unreachable 已删除默认分支。因此,现在每个分支在概念上都包含 b 的负载、一些常量的添加以及返回到 b 的存储。编译器似乎注意到它们都有共同的存储,因此将其下沉 - 存储指令只出现一次,并且每个案例都跳转到它。不幸的是,这实际上导致整体代码更差,因为现在个别情况不能使用 RMW 添加;他们必须进行加载并作为单独的说明添加。而且,案件不能再只是ret自己了;他们都必须跳到分解后的商店。并且编译器不知何故没有意识到负载可以被提升,所以它在所有情况下都不必要地重复。

我猜问题的一部分是提升/下沉是在一个独立于目标的传递中完成的,它将加载、添加和存储视为独立的操作。如果它们保持在一起,那么稍后一些特定于目标的窥视孔通道可能会将它们组合成单个添加内存指令;但较早的传球似乎没有考虑将它们放在一起可能是有利的,并认为任何吊装都必须是好的。在 RISC 类型的加载/存储机器上,RMW 总是必须是三个指令,只存储存储仍然会有所帮助,也许,但对于 x86 绝对不是。

所以这可能是两个单独的错过优化问题。第一个是没有注意到负载可以被提升(或者可能注意到但决定不这样做),这似乎是一个明显的错误。第二个是没有正确评估沉没商店是否值得额外跳跃的成本,这可能更多的是应用在这种情况下碰巧错误的启发式方法。

【讨论】:

  • 这个问题是 11.x 版本中的新问题,之前的版本为这两个版本发出了相同的代码。 .他们搞砸了。
  • @0___________:10.3 的代码有点意思。首先,使用跳转表根本不够聪明,取而代之的是一个由七个cmp / je 组成的链(a == 0 出于某种原因得到特殊处理,并且 8 和 9 组合成一个条件移动和加法。)所以11.x 在这方面有所改进。如果您将a 更改为unsigned,那么10.3 决定提升负载而不是存储,这至少比相反的方式好一点——我们仍然可以直接从每个案例中获取ret,而不是采取额外的跳跃。
  • 术语:商店在分支之后,因此将其拉出是“下沉”它,而不是“提升”它。即使在这种情况下,GCC 碰巧将那个基本块放在了添加块之前。
  • @PeterCordes:谢谢,我认为“提升”听起来是错误的,但我无法确定它应该是什么。将编辑。
猜你喜欢
  • 2011-03-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-09-22
  • 1970-01-01
  • 2011-08-02
  • 2015-11-23
相关资源
最近更新 更多