gcc -O2 对递归斐波那契函数做了啥?

Posted

技术标签:

【中文标题】gcc -O2 对递归斐波那契函数做了啥?【英文标题】:What is gcc -O2 doing to the recursive Fibonacci function?gcc -O2 对递归斐波那契函数做了什么? 【发布时间】:2012-04-07 21:29:04 【问题描述】:

我正在学习 x86 汇编器以编写编译器。特别是,我采用了各种简单的递归函数并将它们提供给不同的编译器(OCaml、GCC 等),以便更好地了解不同编译器生成的汇编程序类型。

我有一个简单的递归整数斐波那契函数:

int fib(int x)  return (x < 2 ? x : fib(x-1)+fib(x-2)); 

我的手工编码程序集如下所示:

fib:
    cmp eax, 2
    jl  fin
    push    eax
    dec eax
    call    fib
    push    eax
    mov eax, [esp+4]
    add eax, -2
    call    fib
    add eax, [esp]
    add esp, 8
fin:
    ret

使用gcc -O2 将该函数编译为英特尔语法汇编器会产生这个神秘的代码:

_fib:
    push    edi
    push    esi
    push    ebx
    sub esp, 16
    mov edi, DWORD PTR [esp+32]
    cmp edi, 1
    jle L4
    mov ebx, edi
    xor esi, esi
L3:
    lea eax, [ebx-1]
    mov DWORD PTR [esp], eax
    call    _fib
    sub ebx, 2
    add esi, eax
    cmp ebx, 1
    jg  L3
    and edi, 1
L2:
    lea eax, [esi+edi]
    add esp, 16
    pop ebx
    pop esi
    pop edi
    ret
L4:
    xor esi, esi
    jmp L2

所以我猜调用约定是[esp+4] 的参数和eax 的返回值。它首先推送ediesiebx。然后它为堆栈帧声明另外 16 个字节,足以容纳 4 个临时整数。然后从[esp+32]中读取edi,也就是参数。如果参数是&lt;=1,那么它会跳转到L4,它会清零(?)esi,然后再跳转回L2,它会设置eax=esi+edi,这只是参数edi。如果参数是&gt;1,那么参数会被复制到ebx,并在进入L3 之前将esi 归零。在L3 中,它设置eax=ebx-1 并将结果(n-1)存储在堆栈帧中的esp,然后递归计算fib(n-1)。结果被添加到esiebx 被设置为n-2,如果ebx&gt;1,它循环回到L3,否则它提取edi 的低位,然后下降到L2

为什么这段代码如此复杂(例如,有没有我没有看到的已完成优化的名称?)?

递归调用fib(n-2) 似乎已替换为在esi 中累积的循环,但该调用不在尾部位置,那么这是如何完成的?

and edi, 1 的用途是什么?

mov DWORD PTR [esp], eax 的用途是什么?

为什么栈帧这么大?

你能把这个算法反汇编成 C 语言,以便更清楚地知道发生了什么吗?

我的初步印象是 GCC 生成的 x86 汇编器非常糟糕。在这种情况下,相同性能的代码要多 2 倍(这两个程序在这个 1.6GHz Atom 上的 fib(40) 需要 3.25 秒)。这公平吗?

【问题讨论】:

更长的 x86 代码并不一定意味着更糟糕的 x86 代码。一些短序列实际上在时间上比长序列效率低得多。在对两个版本进行概要分析之前,请勿敲击 GCC 版本。 你比较过运行时间吗? 其实 GCC 很擅长优化 ;) 代码看起来不太好,但这是 x86 “糟糕”实现的错误:P “你能把这个算法反汇编成 C [...] 吗?”那不就是组装吗? @OliCharlesworth 是的,我比较了运行时,它们是相同的(在这个 1.6GHz Intel Atom 上是 3.25s)。 【参考方案1】:

除了上面的 cmets,注意递归已经通过替换被展开为尾调用:

return x < 2 ? x : fib(x - 2) + fib(x - 1);

与:

if ((xprime = x) < 2) 
    acc = 0;
 else 
    /* at this point we know x >= 2 */
    acc = 0; /* start with 0 */
    while (x > 1) 
       acc += fib(x - 1); /* add fib(x-1) */
       x -= 2; /* now we'll add fib(x-2) */
    
    /* so at this point we know either x==1 or x==0 */
    xprime = x == 1 ? 1 : 0; /* ie, x & 1 */

return xprime + acc;

我怀疑这个相当棘手的循环是由多个优化步骤引起的,并不是说我从 gcc 2.3 开始就一直在摆弄 gcc 优化(现在内部完全不同了!)。

【讨论】:

【参考方案2】:

很简单,fib(x-2) 等于fib(x-3) + fib(x-4)fib(x-4) 等于fib(x-5) + fib(x-6) 等等,所以fib(x) 被计算为fib(x-1) + fib(x-3) + fib(x-5) + ... + fib(x&amp;1)fib(x&amp;1) 等于x&amp;1),即 gcc 有内联对fib(x-2) 的调用,这对递归函数来说是一件非常聪明的事情。

【讨论】:

【参考方案3】:

这第一部分是确保根据调用约定应保留的寄存器不会被丢弃。我猜这里使用的调用约定是cdecl

_fib:
    push    edi
    push    esi
    push    ebx
    sub esp, 16

DWORD PTR[esp+32] 是你的x

    mov edi, DWORD PTR [esp+32]
    cmp edi, 1
    jle L4

如果x 小于或等于1(这对应于您的x &lt; 2),则转到L4,即:

L4:
    xor esi, esi
    jmp L2

这会将esi 归零并分支到L2

L2:
    lea eax, [esi+edi]
    add esp, 16
    pop ebx
    pop esi
    pop edi
    ret

这会将eax(返回值)设置为esi+edi。由于esi 已经为0,所以edi 只是在0 和1 的情况下加载。这对应于x &lt; 2 ? x

现在我们看看x不是&lt; 2的情况:

    mov ebx, edi
    xor esi, esi
L3:
    lea eax, [ebx-1]
    mov DWORD PTR [esp], eax
    call    _fib

首先将x 复制到ebx,然后将esi 归零。

接下来,eax 设置为 x - 1。这个值被移到栈顶并调用_fib。这对应于fib(x-1)

    sub ebx, 2
    add esi, eax

这从ebx (x) 中减去 2。然后eax(来自_fib调用的返回值被添加到esi,之前设置为0)。因此esi 现在持有fib(x-1) 的结果。

    cmp ebx, 1
    jg  L3
    and edi, 1

ebx 与 1 进行比较。如果大于 1,则循环返回到 L3。否则(它是 0 或 1 的情况),我们执行and edi, 1 并下降到L2(我们之前已经分析了它的作用)。 and edi, 1 相当于对x 执行%2

从高层次来看,这就是代码的作用:

设置堆栈帧并保存寄存器 如果x &lt; 2,则返回x。 继续调用和求和 fib(x-...) 直到 x 小于 2。在这种情况下,转到 x &lt; 2 的情况。

优化之处在于 GCC 通过循环而不是递归来解开 x &gt;= 2 的情况。

【讨论】:

以上是关于gcc -O2 对递归斐波那契函数做了啥?的主要内容,如果未能解决你的问题,请参考以下文章

编写一递归函数求斐波那契数列的前40项

递归求斐波那契数列

利用递归函数求斐波那契值python版

python递归求斐波那契数列前10项

递归优化的斐波那契数列

08《算法入门教程》递归算法之斐波那契数列