为什么 gcc 使用 __builtin_unreachable 发出更糟糕的代码?
Why is gcc emitting worse code with __builtin_unreachable?
与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();
}
假设参数 a
在程序中始终为正,编译器应该为 f1
生成更优化的代码,因为在 f0
中,a
可以通过if-else 为负时会阻塞,因此编译器应该生成默认的“什么都不做和 return”代码。但是在 f1
中,a
的可能范围用 __builtin_unreachable
明确说明,这样编译器就不必考虑何时 a
超出范围。
然而,f1
实际上运行速度较慢,所以我看了一下反汇编。这是 f0
.
的控制流程部分
jne .L2
addq , b(%rip)
ret
.p2align 4,,10
.p2align 3
.L2:
cmpl , %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 , b(%rip)
.L1:
ret
.p2align 4,,10
.p2align 3
.L6:
addq , b(%rip)
ret
.p2align 4,,10
.p2align 3
...
gcc 巧妙地将 if-else 块变成跳转 table 并将默认大小写 L1
放在 L4
中以节省 space.
现在看看f1
反汇编的整个控制流程。
jne .L42
movq b(%rip), %rax
addq , %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 , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L48:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L49:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L50:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L51:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L52:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L53:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L54:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L45:
movq b(%rip), %rax
addq , %rax
jmp .L43
是的,gcc确实捕获了__builtin_unreachable
,但是由于某些原因,在每个return之前都有一个不必要的跳转,并且跳转table有一个重复的条目L45
].此外,它不是简单地 addq $N, b(%rip)
,而是在 return.
之前继续写 movq b(%rip), %rax
、addq $N, %rax
,然后是 movq %rax, b(%rip)
是什么让 gcc 产生了看似愚蠢的代码?
二进制文件是在 Fedora Linux 下用 -O3
编译的,我使用的 gcc 版本是 11.2.1 20211203
这是我能想到的最好的解释。
编译器显然可以(至少在某种程度上)进行优化,其中 if/else 树的所有分支共有的代码可以被提取出来(根据需要提升或下沉)。但在 f0
版本中,无法应用此优化,因为“默认”情况根本没有代码,特别是既不加载也不存储 b
。所以编译器只是尽可能地单独优化案例,将每个案例作为单个 RMW 添加内存指令。
在 f1
版本中,您的 __builtin_unreachable
已删除默认分支。所以现在每个分支在概念上都包含 b
的加载、一些常量的添加和返回 b
的存储。编译器似乎注意到它们都有共同的存储,因此将其下沉——存储指令只出现一次,每个案例都会跳转到它。不幸的是,这实际上导致整体代码更糟糕,因为现在个别情况不能使用 RMW add;他们必须按照单独的说明进行加载和添加。而且,案例不能再单独ret
;他们都必须跳到分解的商店。并且编译器不知何故 没有 意识到负载可以被提升,所以它在所有情况下都是不必要的重复。
我猜部分问题是 hoisting/sinking 是在目标独立的传递中完成的,该传递将加载、添加和存储视为独立操作。如果它们保持在一起,那么稍后一些特定于目标的窥视孔通道可能会将它们组合到单个添加内存指令中;但是前面的pass似乎没有考虑到把它们放在一起可能会有好处,认为任何吊装都一定是好的。在 RISC 类型的 load/store 机器上,RMW 总是必须是三个指令,仅下沉存储仍然会有一些帮助,也许,但对于 x86 绝对不是。
所以这可能是两个独立的未优化问题。第一个是没有注意到负载可以被提升(或者可能注意到但决定不这样做),这似乎是一个明显的错误。第二个是没有正确评估下沉商店是否值得额外跳跃的成本,这可能更多地是应用在这种情况下恰好是错误的启发式方法的问题。
与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();
}
假设参数 a
在程序中始终为正,编译器应该为 f1
生成更优化的代码,因为在 f0
中,a
可以通过if-else 为负时会阻塞,因此编译器应该生成默认的“什么都不做和 return”代码。但是在 f1
中,a
的可能范围用 __builtin_unreachable
明确说明,这样编译器就不必考虑何时 a
超出范围。
然而,f1
实际上运行速度较慢,所以我看了一下反汇编。这是 f0
.
jne .L2
addq , b(%rip)
ret
.p2align 4,,10
.p2align 3
.L2:
cmpl , %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 , b(%rip)
.L1:
ret
.p2align 4,,10
.p2align 3
.L6:
addq , b(%rip)
ret
.p2align 4,,10
.p2align 3
...
gcc 巧妙地将 if-else 块变成跳转 table 并将默认大小写 L1
放在 L4
中以节省 space.
现在看看f1
反汇编的整个控制流程。
jne .L42
movq b(%rip), %rax
addq , %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 , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L48:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L49:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L50:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L51:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L52:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L53:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L54:
movq b(%rip), %rax
addq , %rax
jmp .L43
.p2align 4,,10
.p2align 3
.L45:
movq b(%rip), %rax
addq , %rax
jmp .L43
是的,gcc确实捕获了__builtin_unreachable
,但是由于某些原因,在每个return之前都有一个不必要的跳转,并且跳转table有一个重复的条目L45
].此外,它不是简单地 addq $N, b(%rip)
,而是在 return.
movq b(%rip), %rax
、addq $N, %rax
,然后是 movq %rax, b(%rip)
是什么让 gcc 产生了看似愚蠢的代码?
二进制文件是在 Fedora Linux 下用 -O3
编译的,我使用的 gcc 版本是 11.2.1 20211203
这是我能想到的最好的解释。
编译器显然可以(至少在某种程度上)进行优化,其中 if/else 树的所有分支共有的代码可以被提取出来(根据需要提升或下沉)。但在 f0
版本中,无法应用此优化,因为“默认”情况根本没有代码,特别是既不加载也不存储 b
。所以编译器只是尽可能地单独优化案例,将每个案例作为单个 RMW 添加内存指令。
在 f1
版本中,您的 __builtin_unreachable
已删除默认分支。所以现在每个分支在概念上都包含 b
的加载、一些常量的添加和返回 b
的存储。编译器似乎注意到它们都有共同的存储,因此将其下沉——存储指令只出现一次,每个案例都会跳转到它。不幸的是,这实际上导致整体代码更糟糕,因为现在个别情况不能使用 RMW add;他们必须按照单独的说明进行加载和添加。而且,案例不能再单独ret
;他们都必须跳到分解的商店。并且编译器不知何故 没有 意识到负载可以被提升,所以它在所有情况下都是不必要的重复。
我猜部分问题是 hoisting/sinking 是在目标独立的传递中完成的,该传递将加载、添加和存储视为独立操作。如果它们保持在一起,那么稍后一些特定于目标的窥视孔通道可能会将它们组合到单个添加内存指令中;但是前面的pass似乎没有考虑到把它们放在一起可能会有好处,认为任何吊装都一定是好的。在 RISC 类型的 load/store 机器上,RMW 总是必须是三个指令,仅下沉存储仍然会有一些帮助,也许,但对于 x86 绝对不是。
所以这可能是两个独立的未优化问题。第一个是没有注意到负载可以被提升(或者可能注意到但决定不这样做),这似乎是一个明显的错误。第二个是没有正确评估下沉商店是否值得额外跳跃的成本,这可能更多地是应用在这种情况下恰好是错误的启发式方法的问题。