5 c assembly gcc compiler-optimization unreachable-code
f0如下所示f1,
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();
}
Run Code Online (Sandbox Code Playgroud)
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
...
Run Code Online (Sandbox Code Playgroud)
gcc 巧妙地将 if-else 块变成跳转表,并将 default case 放在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
Run Code Online (Sandbox Code Playgroud)
是的,gcc 确实捕获了__builtin_unreachable,但由于某种原因,每次返回之前都有不必要的跳转,并且跳转表中有一个重复的条目L45. 而且它不是简单地addq $N, b(%rip)写,而是在返回之前不断写movq b(%rip), %rax, addq $N, %rax, then movq %rax, b(%rip)。
是什么让 gcc 产生了明显愚蠢的代码?
该二进制文件是在 Fedora Linux 下编译的-O3,我使用的 gcc 版本是11.2.1 20211203
这是我能想到的最好的解释。
编译器显然可以(至少在某种程度上)进行优化,其中可以分解出 if/else 树的所有分支所共有的代码(根据需要提升或下沉)。但在该f0版本中,无法应用此优化,因为“默认”情况根本没有代码,特别是既不加载也不存储b。因此,编译器只是尽可能地单独优化案例,将每个案例保留为单个 RMW 添加内存指令。
在该f1版本中,您__builtin_unreachable已删除默认分支。因此,现在每个分支从概念上讲都包含 的加载b、一些常量的添加以及返回到 的存储b。编译器似乎注意到它们都有共同的存储,因此将其沉没 - 存储指令只出现一次,并且每个案例都会跳转到它。不幸的是,这实际上会导致整体代码更糟糕,因为现在个别情况无法使用 RMW 添加;他们必须作为单独的指令进行加载和添加。而且,案件不能再单独ret存在;他们都必须跳到分解的商店。并且编译器不知何故没有意识到负载可以被提升,因此在所有情况下都不必要地重复它。
我猜问题的一部分是提升/下沉是在与目标无关的过程中完成的,该过程将加载、添加和存储视为独立的操作。如果它们保持在一起,那么稍后一些特定于目标的窥视孔通道可能会将它们组合成单个添加内存指令;但早期的通行证似乎并不认为将它们放在一起可能是有利的,并且认为任何提升都必须是好的。在 RISC 类型的加载/存储机器上,RMW 始终必须是三个指令,仅下沉存储可能仍然会有所帮助,但对于 x86 来说绝对不是。
所以这可能是两个独立的优化失败问题。第一个是没有注意到负载可以被提升(或者可能注意到但决定不这样做),这似乎是一个明显的错误。第二个是没有正确评估沉没商店是否值得额外跳跃的成本,这可能更多地是应用启发式的问题,而在这种情况下碰巧是错误的。