在编写优化的 ftol
函数时,我在 GCC 4.6.1
中发现了一些非常奇怪的行为 . 让我先向您展示代码(为清楚起见,我标记了差异):
fast_trunc_one,C:
int fast_trunc_one(int i) {
int mantissa, exponent, sign, r;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
sign = i & 0x80000000;
if (exponent < 0) {
r = mantissa << -exponent; /* diff */
} else {
r = mantissa >> exponent; /* diff */
}
return (r ^ -sign) + sign; /* diff */
}
fast_trunc_two,C:
int fast_trunc_two(int i) {
int mantissa, exponent, sign, r;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
sign = i & 0x80000000;
if (exponent < 0) {
r = (mantissa << -exponent) ^ -sign; /* diff */
} else {
r = (mantissa >> exponent) ^ -sign; /* diff */
}
return r + sign; /* diff */
}
好像是对的吗? GCC不同意 . 用 gcc -O3 -S -Wall -o test.s test.c
编译后,这是程序集输出:
fast_trunc_one,生成:
_fast_trunc_one:
LFB0:
.cfi_startproc
movl 4(%esp), %eax
movl $150, %ecx
movl %eax, %edx
andl $8388607, %edx
sarl $23, %eax
orl $8388608, %edx
andl $255, %eax
subl %eax, %ecx
movl %edx, %eax
sarl %cl, %eax
testl %ecx, %ecx
js L5
rep
ret
.p2align 4,,7
L5:
negl %ecx
movl %edx, %eax
sall %cl, %eax
ret
.cfi_endproc
fast_trunc_two,生成:
_fast_trunc_two:
LFB1:
.cfi_startproc
pushl %ebx
.cfi_def_cfa_offset 8
.cfi_offset 3, -8
movl 8(%esp), %eax
movl $150, %ecx
movl %eax, %ebx
movl %eax, %edx
sarl $23, %ebx
andl $8388607, %edx
andl $255, %ebx
orl $8388608, %edx
andl $-2147483648, %eax
subl %ebx, %ecx
js L9
sarl %cl, %edx
movl %eax, %ecx
negl %ecx
xorl %ecx, %edx
addl %edx, %eax
popl %ebx
.cfi_remember_state
.cfi_def_cfa_offset 4
.cfi_restore 3
ret
.p2align 4,,7
L9:
.cfi_restore_state
negl %ecx
sall %cl, %edx
movl %eax, %ecx
negl %ecx
xorl %ecx, %edx
addl %edx, %eax
popl %ebx
.cfi_restore 3
.cfi_def_cfa_offset 4
ret
.cfi_endproc
这是一个 extreme 差异 . 这实际上也显示在配置文件中, fast_trunc_one
比 fast_trunc_two
快约30% . 现在我的问题是:是什么导致这个?
3 回答
Updated to sync with the OP's edit
通过修改代码,我已经设法看到GCC如何优化第一种情况 .
Before we can understand why they are so different, first we must understand how GCC optimizes fast_trunc_one().
信不信由你,
fast_trunc_one()
正在优化:这将生成与原始
fast_trunc_one()
完全相同的程序集 - 注册名称和所有内容 .请注意,
fast_trunc_one()
的程序集中没有xor
. 这就是我给它带来的东西 .怎么样?
Step 1:
sign = -sign
首先,我们来看看
sign
变量 . 从sign = i & 0x80000000;
开始,sign
只能有两个可能的值:sign = 0
sign = 0x80000000
现在认识到在这两种情况下,
sign == -sign
. 因此,当我将原始代码更改为:它生成与原始
fast_trunc_one()
完全相同的装配 . 我会为你免费组装,但它是相同的 - 注册名称和所有 .Step 2: 数学简化:
x + (y ^ x) = y
sign
只能采用两个值中的一个,0
或0x80000000
.当
x = 0
时,那么x + (y ^ x) = y
然后是微不足道的 .0x80000000
的添加和xoring是相同的 . 它翻转了标志位 . 因此x + (y ^ x) = y
也在x = 0x80000000
时成立 .因此,
x + (y ^ x)
减少到y
. 代码简化为:再次,这编译成完全相同的程序集 - 寄存器名称和所有 .
以上版本最终简化为:
这几乎就是GCC在装配中生成的内容 .
那么为什么编译器不能优化
fast_trunc_two()
呢?fast_trunc_one()
中的关键部分是x + (y ^ x) = y
优化 . 在fast_trunc_two()
中,x + (y ^ x)
表达式正在分支中分割 .我怀疑这可能足以让GCC混淆不进行优化 . (它需要将
^ -sign
提升出分支并将其合并到r + sign
的末尾 . )例如,这会生成与
fast_trunc_one()
相同的程序集:这是编译器的本质 . 假设他们将采取最快或最好的路径,是非常错误的 . 任何暗示你不需要对你的代码做任何事情来优化的人,因为“现代编译器”填补空白,做最好的工作,制作最快的代码等 . 实际上我看到gcc从3.x变得更糟 . x至少在手臂上 . 到目前为止,4.x可能已达到3.x,但在早期它会产生较慢的代码 . 通过练习,您可以学习如何编写代码,这样编译器就不必努力工作,从而产生更一致和预期的结果 .
这里的错误是你对将要产生的东西的期望,而不是实际产生的东西 . 如果希望编译器生成相同的输出,请为其输入相同的输入 . 不是数学上相同,不是有点相同,但实际上是相同的,没有不同的路径,没有从一个版本到另一个版本的共享或分发操作 . 这是一个很好的练习,可以理解如何编写代码并查看编译器使用它做什么 . 不要错误地假设因为一天的一个处理器目标的一个版本的gcc产生了一定的结果,这是所有编译器和所有代码的规则 . 您必须使用许多编译器和许多目标来了解正在发生的事情 .
gcc非常讨厌,我邀请你看看幕后,看看gcc的胆量,尝试添加一个目标或自己修改一些东西 . 它几乎没有用胶带和捞丝固定在一起 . 在关键位置添加或删除了一行额外的代码,它崩溃了 . 事实上,它已经产生了可用的代码,这是令人高兴的事情,而不是担心为什么它没有满足其他期望 .
你看过gcc不同版本的产品吗? 3.x和4.x特别是4.5 vs 4.6 vs 4.7等?对于不同的目标处理器,x86,arm,mips等或x86的不同风格,如果这是你使用的本机编译器,32位vs 64位等?然后llvm(clang)针对不同的目标?
神秘在完成分析/优化代码问题所需的思考过程中做得非常出色,期望编译器能够提出任何一个,而不是任何“现代编译器” .
没有进入数学属性,这种形式的代码
将引导编译器到A:以该形式实现它,执行if-then-else然后收敛于公共代码以完成并返回 . 或者B:保存分支,因为这是函数的尾部 . 也不用担心使用或保存r .
然后你可以进入,因为Mystical指出sign符号变量一起消失所有代码所写的 . 我不希望编译器看到符号变量消失所以你应该自己做,而不是强迫编译器试图找出它 .
这是深入了解gcc源代码的绝佳机会 . 看来你已经找到了一个案例,优化器在一个案例中看到了一件事,在另一个案例中看到了另一件事 . 然后采取下一步,看看你是否不能让gcc看到这种情况 . 每个优化都在那里,因为一些个人或团体认可了优化并故意将其放在那里 . 每次有人必须将它放在那里(然后测试它,然后将其保存到将来),这种优化就可以在那里工作 .
绝对不要假设代码越少越快,代码越慢,就越容易创建并找到不正确的示例 . 通常情况下,更少的代码比更多的代码更快 . 正如我从一开始就证明的那样,你可以创建更多的代码来保存分支或者循环等,并且最终的结果是更快的代码 .
底线是您为编译器提供了不同的源,并期望得到相同的结果 . 问题不在于编译器输出,而在于用户的期望 . 对于特定的编译器和处理器来说,相当容易演示,添加一行代码会使整个函数显着变慢 . 例如,为什么改变a = b 2;到a = b c 2;原因_fill_in_the_blank_compiler_name_生成完全不同且速度较慢的代码?答案当然是编译器在输入上输入了不同的代码,因此编译器生成不同的输出是完全有效的 . (更好的是当您交换两条不相关的代码行并导致输出发生显着变化时)输入的复杂性和大小与输出的复杂性和大小之间没有预期的关系 . 把这样的东西喂成clang:
它产生了60-100行汇编程序 . 它展开了循环 . 我没有计算行,如果你考虑它,它必须添加,将结果复制到函数调用的输入,进行函数调用,最少三个操作 . 所以取决于至少60个指令的目标,如果每个循环四个,则为80,如果每个循环为5,则为100,等等 .
Mysticial已经给出了一个很好的解释,但是我想我会补充说,为什么编译器会为一个编译器而不是另一个编译器进行优化,实际上没有什么根本的 .
例如,LLVM的
clang
编译器为两个函数提供相同的代码(函数名除外),给出:此代码不像OP中的第一个gcc版本那么短,但不像第二个那样长 .
来自另一个编译器(我不会命名)的代码,为x86_64编译,为这两个函数生成:
这很有意思,因为它计算
if
的两侧,然后在末尾使用条件移动来选择正确的移动 .Open64编译器生成以下内容:
fast_trunc_two
的类似但不相同的代码 .无论如何,当涉及到优化时,它是一个乐透 - 它就是它......它并不总是很容易知道为什么你的代码以任何特定方式编译 .