【问题标题】:Why g++ still optimizes tail recursion when the recursion function result is multiplied?为什么当递归函数结果相乘时,g++仍然优化尾递归?
【发布时间】:2016-03-22 09:27:43
【问题描述】:

他们说,尾递归优化仅在调用就在函数返回之前有效。因此,他们将此代码作为 C 编译器不应优化的示例:

long long f(long long n) {
    return n > 0 ? f(n - 1) * n : 1;
}

因为那里的递归函数调用乘以n,这意味着最后一个操作是乘法,而不是递归调用。然而,它甚至在-O1级别:

recursion`f:
    0x100000930 <+0>:  pushq  %rbp
    0x100000931 <+1>:  movq   %rsp, %rbp
    0x100000934 <+4>:  movl   $0x1, %eax
    0x100000939 <+9>:  testq  %rdi, %rdi
    0x10000093c <+12>: jle    0x10000094e               
    0x10000093e <+14>: nop    
    0x100000940 <+16>: imulq  %rdi, %rax
    0x100000944 <+20>: cmpq   $0x1, %rdi
    0x100000948 <+24>: leaq   -0x1(%rdi), %rdi
    0x10000094c <+28>: jg     0x100000940               
    0x10000094e <+30>: popq   %rbp
    0x10000094f <+31>: retq   

他们说:

因此,您的最终规则是足够正确的。但是,return n * fact(n - 1) 在尾部位置确实有操作!这是乘法*,这将是函数做的最后一件事 在它返回之前。在某些语言中,这实际上可能是 实现为函数调用,然后可以是尾调用 优化。

但是,正如我们从 ASM 列表中看到的,乘法仍然是 ASM 指令,而不是单独的函数。所以我真的很难看到累加器方法的区别:

int fac_times (int n, int acc) {
    return (n == 0) ? acc : fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times(n, 1);
}

这会产生

recursion`fac_times:
    0x1000008e0 <+0>:  pushq  %rbp
    0x1000008e1 <+1>:  movq   %rsp, %rbp
    0x1000008e4 <+4>:  testl  %edi, %edi
    0x1000008e6 <+6>:  je     0x1000008f7               
    0x1000008e8 <+8>:  nopl   (%rax,%rax)
    0x1000008f0 <+16>: imull  %edi, %esi
    0x1000008f3 <+19>: decl   %edi
    0x1000008f5 <+21>: jne    0x1000008f0               
    0x1000008f7 <+23>: movl   %esi, %eax
    0x1000008f9 <+25>: popq   %rbp
    0x1000008fa <+26>: retq   

我错过了什么吗?还是只是编译器变得更聪明了?

【问题讨论】:

  • 无论“他们”是谁,他们都错了。
  • 当然,f(n-1)*nn*f(n-1)。我不认为这是一个惊喜。
  • gcc 比“他们”更聪明
  • @MSalters 我对乘法交换性并不感到惊讶,因为它是基本的数学规则。我的问题是,当实际结果不是递归函数调用结果,而是乘法时,为什么要进行优化(因为您首先放置操作数,其中一个是递归调用,然后对它们进行乘法运算,即后缀符号中的 a f() * )。这对我的同事来说也是一个惊喜。但是,如果在函数调用和 constant 之间进行数学运算,这对优化器来说似乎是一件容易的事。
  • 引号的来源是什么,即“他们”是谁?对我来说,优化器似乎至少比你的源更聪明......

标签: c++ assembly optimization g++ tail-recursion


【解决方案1】:

正如你在汇编代码中看到的,编译器足够聪明,可以将你的代码变成一个循环,基本上等同于(忽略不同的数据类型):

int fac(int n)
{
    int result = n;
    while (--n)
        result *= n;
    return result;
}

GCC 足够聪明,知道每次调用原始 f 所需的状态可以在整个递归调用序列中保存在两个变量(nresult)中,因此不需要堆栈.它可以将f 转换为fac_times,并同时转换为fac,可以这么说。这很可能不仅是最严格意义上的尾调用优化的结果,而且是 GCC 用于优化的其他启发式方法的负载之一。

(我无法更详细地了解此处使用的特定启发式方法,因为我对它们了解不多。)

【讨论】:

  • 正如我上面所说的,我对乘法交换律并不感到惊讶,因为它是基本的数学规则。我的问题是,当实际结果不是递归函数调用结果而是乘法时,为什么要进行优化(因为您首先放置操作数,其中一个是递归调用,然后对它们进行乘法运算,即后缀符号中的a f() * )。这对我的同事来说也是一个惊喜。但是,如果在函数调用和 constant 之间进行数学运算,这对优化器来说似乎是一件容易的事。
  • @efpies 在阅读了programmers.SE 的帖子后,我想我明白了。重新措辞至少清楚地表明这可能不仅仅是尾调用优化的问题。
【解决方案2】:

非累加器 f 不是尾递归的。编译器的选项包括通过转换将其变为循环,或call / some insns / ret,但它们不包括jmp f 而没有其他转换。

尾调用优化适用于以下情况:

int ext(int a);
int foo(int x) { return ext(x); }

godbolt 的 asm 输出:

foo:                                    # @foo
        jmp     ext                     # TAILCALL

尾调用优化意味着使用jmp 而不是ret 保留函数(或递归)。其他任何东西都不是尾调用优化。不过,使用 jmp 优化的尾递归确实是一个循环。

一个好的编译器会做进一步的转换,尽可能将条件分支放在循环的底部,删除无条件分支。 (在 asm 中,do{}while() 循环样式是最自然的)。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2010-10-20
    • 2020-06-13
    • 2019-06-02
    • 2018-03-08
    • 2011-01-25
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多