为什么 Python 递归如此昂贵,我们能做些什么呢?

Why is Python recursion so expensive and what can we do about it?

假设我们要计算一些斐波那契数,模 997。

对于 C++ 中的 n=500 我们可以 运行

#include <iostream>
#include <array>

std::array<int, 2> fib(unsigned n) {
    if (!n)
        return {1, 1};
    auto x = fib(n - 1);
    return {(x[0] + x[1]) % 997, (x[0] + 2 * x[1]) % 997};
}

int main() {
    std::cout << fib(500)[0];
}

并在 Python

def fib(n):
    if n==1:
        return (1, 2)
    x=fib(n-1)
    return ((x[0]+x[1]) % 997, (x[0]+2*x[1]) % 997)

if __name__=='__main__':
    print(fib(500)[0])

两者都能毫无问题地找到答案 996。我们采用模数来保持输出大小合理,并使用对来避免指数分支。

对于n=5000,C++代码输出783,但是Python会报错

RecursionError: maximum recursion depth exceeded in comparison

如果我们添加几行

import sys

def fib(n):
    if n==1:
        return (1, 2)
    x=fib(n-1)
    return ((x[0]+x[1]) % 997, (x[0]+2*x[1]) % 997)

if __name__=='__main__':
    sys.setrecursionlimit(5000)
    print(fib(5000)[0])

那么Python也会给出正确答案。

对于 n=50000 C++ 在几毫秒内找到答案 151 而 Python 崩溃(至少在我的机器上)。

为什么递归调用在 C++ 中如此便宜?我们能否以某种方式修改 Python 编译器,使其更容易接受递归?

当然,一种解决方案是用迭代代替递归。对于斐波那契数列,这很容易做到。然而,这将交换初始条件和终止条件,而后者对于许多问题来说是棘手的(例如 alpha–beta p运行ing)。所以一般来说,这需要程序员付出很多努力。

您可以使用以下方式增加递归限制:

import sys

sys.setrecursionlimit(new_limit)

但请注意,此限制的存在是有原因的,并且纯 Python 并未针对递归(以及一般的计算密集型任务)进行优化。

问题是 Python 对递归函数调用的次数有内部限制。

该限制是可配置的,如 Quentin Coumes' answer 所示。但是,函数链太深会导致堆栈溢出。此潜在限制¹ 适用于 C++ 和 Python。此限制也适用于所有函数调用,而不仅仅是递归调用。

总的来说:您不应该编写具有线性复杂度或更差的递归深度增长的²算法。对数递归通常很好。尾递归函数很容易重写为迭代。其他递归可以使用外部数据结构(通常是动态堆栈)转换为迭代。

一个相关的经验法则是,您不应该拥有自动存储的大型对象。这是特定于 C++ 的,因为 Python 没有自动存储的概念。


¹ 潜在的限制是执行堆栈大小。默认大小因系统而异,不同的函数调用消耗不同的内存量,因此限制不是指定为调用次数,而是以字节为单位。这在某些系统上也是可配置的。由于可移植性问题,我通常不建议触及该限制。

² 此经验法则的例外是某些保证尾递归消除的函数式语言 - 例如 Haskell - 在递归保证被消除的情况下可以放宽该规则。 Python 不是这样的语言,所讨论的函数不是尾递归的。虽然 C++ 编译器可以执行消除作为优化,但不能保证,并且通常不会在调试版本中进行优化。因此,该异常通常也不适用于 C++。

免责声明:以下是我的假设;我实际上并不知道他们的理由:Python 限制可能是一个检测潜在无限递归的功能,防止可能不安全的堆栈溢出崩溃并替换更受控制的 RecursionError.

Why are recursive calls so much cheaper in C++?

C++ 是一种编译语言。 Python 被解释。 (几乎)在 C++ 中一切都更便宜,除了从源代码到可执行程序的转换。

让我先回答你的直接问题:

Why are recursive calls so much cheaper in C++?

因为C++对递归调用深度没有限制,除了栈的大小。作为一种完全编译的语言,循环(包括递归)在 C++ 中比在 Python 中快得多(这就是像 numpy/scipy 这样的特殊 Python 模块直接使用 C 例程的原因)。此外,大多数 C++ 实现使用称为 尾递归消除 的特殊功能(请参阅本 post 后面部分)并将一些递归代码优化为迭代等价物。这在这里很好,但标准不保证,因此其他编译可能会导致程序严重崩溃 - 但尾递归可能不涉及这里。

如果递归太深并耗尽可用堆栈,您将调用众所周知的未定义行为,任何事情都可能发生,从立即崩溃到程序出错结果(恕我直言,后者更糟糕,无法检测到...)

Can we somehow modify the Python compiler to make it more receptive to recursion?

没有。 Python 实现明确地从不使用尾递归消除。您可以增加递归限制,但这几乎总是一个坏主意(请参阅稍后的 post 为什么)。

现在真正解释基本原理。

深度递归是邪恶的,句号。你不应该使用它。递归是一个方便的工具当你可以确保深度将保持在合理的范围内。 Python 使用 soft 限制来警告程序员在系统崩溃之前出现了错误。另一方面,优化 C 和 C++ 编译器通常会在内部将 tail recursion 更改为迭代循环。但是依赖它是非常危险的,因为微小的改变可能会阻止优化并导致应用程序崩溃。

正如在此 other SO post 中所发现的,常见的 Python 实现不实现 尾递归消除 。所以你不应该在 5000 深度使用递归,而是使用迭代算法。

由于您的基础计算需要所有斐波那契数直到指定数,因此迭代计算它们并不难。而且效率会高很多!

一个解决方案是一个蹦床:递归函数,而不是调用另一个函数,returns一个使用适当参数进行调用的函数。有一个更高级别的循环,它在一个循环中调用所有这些函数,直到我们得到最终结果。我可能没有很好地解释它;您可以在线找到做得更好的资源。

关键是这将递归转换为迭代。我不认为这会更快,甚至可能更慢,但递归深度仍然很低。

一个实现可能如下所示。为了清楚起见,我将 x 对拆分为 ab。然后我将递归函数转换为跟踪 ab 作为参数的版本,使其成为尾递归的。

def fib_acc(n, a, b):
    if n == 1:
        return (a, b)
    return lambda: fib_acc(n - 1, (a+b) % 997, (a+2*b) % 997)

def fib(n):
    x = fib_acc(n, 1, 2)
    while callable(x):
        x = x()
    return x

if __name__=='__main__':
    print(fib(50000)[0])

“两者都能毫无问题地找到答案 996”

我确实看到至少一个问题:答案应该是 836,而不是 996。

您的两个函数似乎都计算 Fibonacci(2*n) % p,而不是 Fibonacci(n) % p

996Fibonacci(1000) % 997.

的结果

选择更高效的算法

低效算法始终是低效算法,无论它是用 C++ 还是 Python 编写的。

为了计算大的斐波那契数,有比使用 O(n) 调用的简单递归更快的方法(参见相关 article)。

对于大 n,这个递归 O(log n) Python 函数应该 运行 在你上面的 C++ 代码周围的圆圈中:

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci(n, p):
    "Calculate Fibonacci(n) modulo p"
    if n < 3:
        return [0, 1, 1][n]
    if n % 2 == 0:
        m = n // 2
        v1 = fibonacci(m - 1, p)
        v2 = fibonacci(m, p)
        return (2*v1 + v2) * v2 % p
    else:
        m = (n + 1) // 2
        v1 = fibonacci(m, p) ** 2
        v2 = fibonacci(m - 1, p) ** 2
        return (v1 + v2) % p


print(fibonacci(500, 997))
#=> 836
print(fibonacci(1000, 997))
#=> 996

Try it online!

它会愉快地计算fibonacci(10_000_000_000_000_000, 997)

可以添加递归级别作为参数,以查看递归需要进行多深,并以缩进显示。这是 n=500:

的示例
# Recursion tree:
500
  249
    124
      61
        30
          14
            6
              2
              3
                1
                2
            7
              4
          15
            8
        31
          16
      62
    125
      63
        32
  250

Try it online!

您的示例看起来就像很长的对角线:

500
  499
    498
      ...
        ...
           1

对于Windows 可执行文件,堆栈大小在可执行文件的header 中指定。对于 Python 3.7 x64 的 Windows 版本,该大小为 0x1E8480 或正好 2.000.000 字节。

该版本崩溃

Process finished with exit code -1073741571 (0xC00000FD)

如果我们 look that up 我们发现它是堆栈溢出。

我们可以在(本机)堆栈上使用像 WinDbg 这样的本机调试器(启用 child 进程调试)看到的是

[...]
fa 000000e9`6da1b680 00007fff`fb698a6e     python37!PyArg_UnpackStack+0x371
fb 000000e9`6da1b740 00007fff`fb68b841     python37!PyEval_EvalFrameDefault+0x73e
fc 000000e9`6da1b980 00007fff`fb698a6e     python37!PyArg_UnpackStack+0x371
fd 000000e9`6da1ba40 00007fff`fb68b841     python37!PyEval_EvalFrameDefault+0x73e
fe 000000e9`6da1bc80 00007fff`fb698a6e     python37!PyArg_UnpackStack+0x371
ff 000000e9`6da1bd40 00007fff`fb68b841     python37!PyEval_EvalFrameDefault+0x73e

2:011> ? 000000e9`6da1bd40 - 000000e9`6da1ba40 
Evaluate expression: 768 = 00000000`00000300

因此 Python 每次方法调用将使用 2 个堆栈帧,并且堆栈位置存在 768 字节的巨大差异。

如果您使用十六进制编辑器修改 EXE 中的值(制作备份副本),假设为 256 MB

你可以运行下面的代码

[...]
if __name__=='__main__':
    sys.setrecursionlimit(60000)
    print(fib(50000)[0])

它会给出 151 作为答案。


在 C++ 中,我们还可以强制堆栈溢出,例如通过传递 500.000 作为参数。调试时,我们得到

0:000> .exr -1
ExceptionAddress: 00961015 (RecursionCpp!fib+0x00000015)
ExceptionCode: c00000fd (Stack overflow)
[...]
0:000> k
[...]
fc 00604f90 00961045     RecursionCpp!fib+0x45 [C:\...\RecursionCpp.cpp @ 7] 
fd 00604fb0 00961045     RecursionCpp!fib+0x45 [C:\...\RecursionCpp.cpp @ 7] 
fe 00604fd0 00961045     RecursionCpp!fib+0x45 [C:\...\RecursionCpp.cpp @ 7] 
ff 00604ff0 00961045     RecursionCpp!fib+0x45 [C:\...\RecursionCpp.cpp @ 7] 

0:000> ? 00604ff0 - 00604fd0
Evaluate expression: 32 = 00000020

每个方法调用只有 1 个堆栈帧,堆栈上只有 32 个字节的差异。与 Python 相比,对于相同的堆栈大小,C++ 可以执行 768/32 = 24 倍以上的递归。

我的 Microsoft 编译器创建了默认堆栈大小为 1 MB 的可执行文件(发布版本,32 位):

64 位版本与 64 位(也是发布版本)的堆栈差异。


使用的工具:

蹦床的替代方法是使用 reduce。 如果你可以把递归函数改成尾递归,你可以用reduce来实现,这里有一个可能的实现。

reduce 是在内部迭代实现的,因此您可以在不破坏堆栈的情况下使用递归函数。

def inner_fib(acc, num):
    # acc is a list of two values 
    return [(acc[0]+acc[1]) % 997, (acc[0]+2*acc[1]) % 997]

def fib(n):
  return reduce(inner_fib, 
                range(2, n+1), # start from 2 since n=1 is covered in the base case
                [1,2])        # [1,2] is the base case