为什么这个递归比等价迭代快这么多

Why is this recursion so much faster than equivalent iteration?

本文关键字:迭代 递归 为什么      更新时间:2023-10-16

我多次被告知,由于函数调用,递归很慢,但在这段代码中,它似乎比迭代解决方案快得多。在最好的情况下,我通常期望编译器将递归优化为迭代(查看程序集,似乎确实发生了)。

#include <iostream>
bool isDivisable(int x, int y)
{
    for (int i = y; i != 1; --i)
        if (x % i != 0)
            return false;
    return true;
}
bool isDivisableRec(int x, int y)
{
    if (y == 1)
        return true;
    return x % y == 0 && isDivisableRec(x, y-1);
}
int findSmallest()
{
    int x = 20;
    for (; !isDivisable(x,20); ++x);
    return x;
}
int main()
{
    std::cout << findSmallest() << std::endl;
}

这里的汇编:https://gist.github.com/PatrickAupperle/2b56e16e9e5a6a9b251e

我很想知道这里发生了什么。我确信这是一些棘手的编译器优化,我可以惊讶地了解。

编辑:我刚意识到我忘了说,如果我使用递归版本,它运行大约0.25秒,迭代,大约0.6。

编辑2:我正在使用

编译-O3
$ g++ --version
g++ (Ubuntu 4.8.4-2ubuntu1~14.04) 4.8.4

虽然,我不确定这有什么关系。

编辑3:
更好的基准:
来源:http://gist.github.com/PatrickAupperle/ee8241ac51417437d012
输出:http://gist.github.com/PatrickAupperle/5870136a5552b83fd0f1
运行100次迭代显示非常相似的结果

编辑4:
根据Roman的建议,我在编译标志中添加了-fno-inline-functions -fno-inline-small-functions。这种效果对我来说非常奇怪。代码运行速度大约快了15倍,但递归版本和迭代版本之间的比率仍然相似。https://gist.github.com/PatrickAupperle/3a87eb53a9f11c1f0bec

使用这段代码,我还看到Cygwin中的GCC 4.9.3在时间上有很大的差异(倾向于递归版本)。我得到了

13.411 seconds for iterative
4.29101 seconds for recursive

查看-O3生成的汇编代码,我看到了两件事

  • 编译器将isDivisableRec中的尾递归替换为循环,然后展开循环:机器代码中循环的每次迭代覆盖原递归的两个级别。

    _Z14isDivisableRecii:
    .LFB1467:
        .seh_endprologue
        movl    %edx, %r8d
    .L15:
        cmpl    $1, %r8d
        je  .L18
        movl    %ecx, %eax          ; First unrolled divisibility check
        cltd
        idivl   %r8d
        testl   %edx, %edx
        je  .L20
    .L19:
        xorl    %eax, %eax
        ret
        .p2align 4,,10
    .L20:
        leal    -1(%r8), %r9d
        cmpl    $1, %r9d
        jne .L21
        .p2align 4,,10
    .L18:
        movl    $1, %eax
        ret
        .p2align 4,,10
    .L21:
        movl    %ecx, %eax          ; Second unrolled divisibility check
        cltd
        idivl   %r9d
        testl   %edx, %edx
        jne .L19
        subl    $2, %r8d
        jmp .L15
        .seh_endproc
    
  • 编译器通过将提升到findSmallestRec来内联几个isDivisableRec的迭代。由于isDivisableRecy参数的值被硬编码为20,编译器成功地替换了20, 19,…15和一些"神奇的"代码直接内联到findSmallestRec。对isDivisableRec的实际调用只发生在y参数值为14的情况下(如果发生的话)。

    以下是findSmallestRec 中的内联代码
        movl    $20, %ebx
        movl    $1717986919, %esi      ; Magic constants
        movl    $1808407283, %edi      ; for divisibility tests
        movl    $954437177, %ebp       ;
        movl    $2021161081, %r12d     ;
        movl    $-2004318071, %r13d    ;
        jmp .L28
        .p2align 4,,10
    .L29:                              ; The main cycle
        addl    $1, %ebx
    .L28:
        movl    %ebx, %eax             ; Divisibility by 20 test
        movl    %ebx, %ecx
        imull   %esi
        sarl    $31, %ecx
        sarl    $3, %edx
        subl    %ecx, %edx
        leal    (%rdx,%rdx,4), %eax
        sall    $2, %eax
        cmpl    %eax, %ebx
        jne .L29
        movl    %ebx, %eax             ; Divisibility by 19 test
        imull   %edi
        sarl    $3, %edx
        subl    %ecx, %edx
        leal    (%rdx,%rdx,8), %eax
        leal    (%rdx,%rax,2), %eax
        cmpl    %eax, %ebx
        jne .L29
        movl    %ebx, %eax             ; Divisibility by 18 test
        imull   %ebp
        sarl    $2, %edx
        subl    %ecx, %edx
        leal    (%rdx,%rdx,8), %eax
        addl    %eax, %eax
        cmpl    %eax, %ebx
        jne .L29
        movl    %ebx, %eax             ; Divisibility by 17 test
        imull   %r12d
        sarl    $3, %edx
        subl    %ecx, %edx
        movl    %edx, %eax
        sall    $4, %eax
        addl    %eax, %edx
        cmpl    %edx, %ebx
        jne .L29
        testb   $15, %bl               ; Divisibility by 16 test
        jne .L29
        movl    %ebx, %eax             ; Divisibility by 15 test
        imull   %r13d
        leal    (%rdx,%rbx), %eax
        sarl    $3, %eax
        subl    %ecx, %eax
        movl    %eax, %edx
        sall    $4, %edx
        subl    %eax, %edx
        cmpl    %edx, %ebx
        jne .L29
        movl    $14, %edx
        movl    %ebx, %ecx
        call    _Z14isDivisableRecii   ; call isDivisableRecii(x, 14)
        ...
    

以上每次jne .L29跳转前的机器指令块是20, 19…的可整除性测试。15直接升入findSmallestRec。显然,对于运行时值y,它们比isDivisableRec内部使用的测试更有效。如您所见,可被16整除测试被简单地实现为testb $15, %bl。正因为如此,xy的高值所不能整除的情况被上述高度优化的代码提前捕获。

这些都不会发生在isDivisablefindSmallest中——它们基本上是按字面翻译的。连循环都没有展开。

我相信这是第二个优化,造成了最大的不同。编译器使用高度优化的方法来检查较高的y值的可除性,这些值恰好在编译时已知。

如果您将isDivisableRec的第二个参数替换为"不可预测的"运行时值20(而不是硬编码的编译时常数20),它应该禁用此优化并使计时一致。我刚刚尝试了这个,最后以

结束
12.9 seconds for iterative
13.26 seconds for recursive