在编写优化的 ftol
函数时,我在 GCC 4.6.1
中发现了一些非常奇怪的行为。让我先向您展示代码(为清楚起见,我标记了差异):
快速截断,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
这是一个极端的区别。这实际上也显示在配置文件中,fast_trunc_one
比 fast_trunc_two
快 30% 左右。现在我的问题是:是什么原因造成的?
-S -O3 -da -fdump-tree-all
编译它们。这将创建中间表示的许多快照。并排浏览它们(它们已编号),您应该能够在第一种情况下找到缺失的优化。
int
更改为unsigned int
,看看差异是否消失。
(r + shifted) ^ sign
与 r + (shifted ^ sign)
不同。我想这让优化器感到困惑? FWIW、MSVC 2010 (16.00.40219.01) 生成的列表几乎相同:gist.github.com/2430454
更新以与 OP 的编辑同步
通过修改代码,我设法了解 GCC 如何优化第一种情况。
在了解它们为何如此不同之前,我们首先必须了解 GCC 如何优化 fast_trunc_one()
。
信不信由你,fast_trunc_one()
正在为此进行优化:
int fast_trunc_one(int i) {
int mantissa, exponent;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
if (exponent < 0) {
return (mantissa << -exponent); /* diff */
} else {
return (mantissa >> exponent); /* diff */
}
}
这将产生与原始 fast_trunc_one()
完全相同的程序集 - 注册名称和所有内容。
请注意,fast_trunc_one()
的程序集中没有 xor
。这就是给我的。
怎么会这样?
第 1 步: sign = -sign
首先,让我们看一下 sign
变量。由于 sign = i & 0x80000000;
,sign
只能采用两个可能的值:
符号 = 0
符号 = 0x80000000
现在认识到在这两种情况下,sign == -sign
。因此,当我将原始代码更改为:
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;
} else {
r = mantissa >> exponent;
}
return (r ^ sign) + sign;
}
它生成与原始 fast_trunc_one()
完全相同的程序集。我会省去你的程序集,但它是相同的 - 注册名称和所有。
第 2 步: 数学简化:x + (y ^ x) = y
sign
只能采用 0
或 0x80000000
这两个值之一。
当 x = 0 时,x + (y ^ x) = y 那么平凡成立。
通过 0x80000000 添加和异或是相同的。它翻转符号位。因此,当 x = 0x80000000 时,x + (y ^ x) = y 也成立。
因此,x + (y ^ x)
减少为 y
。代码简化为:
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);
} else {
r = (mantissa >> exponent);
}
return r;
}
同样,这将编译为完全相同的程序集 - 注册名称和所有。
上述版本最终简化为:
int fast_trunc_one(int i) {
int mantissa, exponent;
mantissa = (i & 0x07fffff) | 0x800000;
exponent = 150 - ((i >> 23) & 0xff);
if (exponent < 0) {
return (mantissa << -exponent); /* diff */
} else {
return (mantissa >> exponent); /* diff */
}
}
这几乎正是 GCC 在程序集中生成的。
那么为什么编译器不优化 fast_trunc_two()
到同样的事情呢?
fast_trunc_one()
中的关键部分是 x + (y ^ x) = y
优化。在 fast_trunc_two()
中,x + (y ^ x)
表达式被拆分到分支中。
我怀疑这可能足以让 GCC 不进行这种优化。 (它需要将 ^ -sign
提升出分支并将其合并到最后的 r + sign
中。)
例如,这会生成与 fast_trunc_one()
相同的程序集:
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) + sign; /* diff */
} else {
r = ((mantissa >> exponent) ^ -sign) + sign; /* diff */
}
return r; /* diff */
}
这是编译器的本质。假设他们将采取最快或最好的路径,这是完全错误的。任何暗示您不需要对代码做任何事情来优化的人,因为“现代编译器”填补了空白,做最好的工作,制作最快的代码等等。实际上我看到 gcc 从 3.x 到4.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 位与 64 位等?然后 llvm (clang) 用于不同的目标?
Mystical 在解决分析/优化代码问题所需的思考过程中做得非常出色,期望编译器能够提出任何“现代编译器”所不期望的任何问题。
无需进入数学属性,这种形式的代码
if (exponent < 0) {
r = mantissa << -exponent; /* diff */
} else {
r = mantissa >> exponent; /* diff */
}
return (r ^ -sign) + sign; /* diff */
将引导编译器 A:以那种形式实现它,执行 if-then-else 然后收敛于公共代码以完成并返回。或 B:保存一个分支,因为这是函数的结尾。也不用担心使用或保存 r。
if (exponent < 0) {
return((mantissa << -exponent)^-sign)+sign;
} else {
return((mantissa << -exponent)^-sign)+sign;
}
然后你可以进入 Mystical 指出的符号变量一起消失的代码所写的代码。我不希望编译器看到符号变量消失,所以你应该自己做,而不是强迫编译器试图弄清楚。
这是深入研究 gcc 源代码的绝佳机会。您似乎发现了一种情况,优化器在一种情况下看到一件事,然后在另一种情况下看到另一件事。然后进行下一步,看看能不能让gcc看到那个case。每个优化都在那里,因为一些个人或团体认识到优化并故意将其放在那里。每次有人必须将它放在那里(然后对其进行测试,然后将其维护到未来)时,这种优化就在那里并起作用。
绝对不要假设代码越少越快,代码越多越慢,很容易创建和找到不正确的示例。通常情况下,更少的代码比更多的代码更快。正如我从一开始就展示的那样,尽管您可以创建更多代码以在这种情况下保存分支或循环等,并让最终结果是更快的代码。
底线是您为编译器提供了不同的源并期望得到相同的结果。问题不在于编译器输出,而在于用户的期望。为特定的编译器和处理器演示是相当容易的,添加一行代码会使整个函数显着变慢。例如为什么改变 a = b + 2;到 a = b + c + 2;导致_fill_in_the_blank_compiler_name_ 生成完全不同且速度较慢的代码?答案当然是编译器在输入上输入了不同的代码,因此编译器生成不同的输出是完全有效的。 (更好的是当您交换两行不相关的代码并导致输出发生巨大变化时)输入的复杂性和大小与输出的复杂性和大小之间没有预期的关系。将这样的东西输入到 clang 中:
for(ra=0;ra<20;ra++) dummy(ra);
它产生了 60-100 行汇编程序。它展开了循环。我没有数行数,你想想,它必须添加,复制结果到输入到函数调用,进行函数调用,最少三个操作。所以取决于目标,可能至少有 60 条指令,如果每个循环有 4 条指令,则为 80 条,如果每个循环有 5 条,则为 100 条,等等。
Mysticial 已经给出了很好的解释,但我想我要补充一点,FWIW,关于为什么编译器会针对其中一个而不是另一个进行优化,实际上并没有什么基本原理。
例如,LLVM 的 clang
编译器为两个函数提供相同的代码(函数名称除外),给出:
_fast_trunc_two: ## @fast_trunc_one
movl %edi, %edx
andl $-2147483648, %edx ## imm = 0xFFFFFFFF80000000
movl %edi, %esi
andl $8388607, %esi ## imm = 0x7FFFFF
orl $8388608, %esi ## imm = 0x800000
shrl $23, %edi
movzbl %dil, %eax
movl $150, %ecx
subl %eax, %ecx
js LBB0_1
shrl %cl, %esi
jmp LBB0_3
LBB0_1: ## %if.then
negl %ecx
shll %cl, %esi
LBB0_3: ## %if.end
movl %edx, %eax
negl %eax
xorl %esi, %eax
addl %edx, %eax
ret
这段代码不像 OP 的第一个 gcc 版本那么短,但没有第二个那么长。
来自另一个编译器(我不会命名)的代码,为 x86_64 编译,为这两个函数生成:
fast_trunc_one:
movl %edi, %ecx
shrl $23, %ecx
movl %edi, %eax
movzbl %cl, %edx
andl $8388607, %eax
negl %edx
orl $8388608, %eax
addl $150, %edx
movl %eax, %esi
movl %edx, %ecx
andl $-2147483648, %edi
negl %ecx
movl %edi, %r8d
shll %cl, %esi
negl %r8d
movl %edx, %ecx
shrl %cl, %eax
testl %edx, %edx
cmovl %esi, %eax
xorl %r8d, %eax
addl %edi, %eax
ret
有趣的是,它计算 if
的两边,然后在末尾使用条件移动来选择正确的移动。
Open64 编译器生成以下内容:
fast_trunc_one:
movl %edi,%r9d
sarl $23,%r9d
movzbl %r9b,%r9d
addl $-150,%r9d
movl %edi,%eax
movl %r9d,%r8d
andl $8388607,%eax
negl %r8d
orl $8388608,%eax
testl %r8d,%r8d
jl .LBB2_fast_trunc_one
movl %r8d,%ecx
movl %eax,%edx
sarl %cl,%edx
.Lt_0_1538:
andl $-2147483648,%edi
movl %edi,%eax
negl %eax
xorl %edx,%eax
addl %edi,%eax
ret
.p2align 5,,31
.LBB2_fast_trunc_one:
movl %r9d,%ecx
movl %eax,%edx
shll %cl,%edx
jmp .Lt_0_1538
和 fast_trunc_two
的类似但不相同的代码。
无论如何,当谈到优化时,它就像一个彩票——它就是这样......要知道为什么你的代码会以任何特定的方式编译并不总是那么容易。
icc
。我只有 32 位变体,但它产生的代码与此非常相似。
不定期副业成功案例分享