【问题标题】:Why is this iterative Collatz method 30% slower than its recursive version in Python?为什么这种迭代 Collat​​z 方法比 Python 中的递归版本慢 30%?
【发布时间】:2014-03-08 21:00:52
【问题描述】:

前奏

对于一个特定问题,我有两种实现方式,一种是递归的,一种是迭代的,我想知道是什么导致迭代解决方案比递归解决方案慢约 30%。

鉴于递归解决方案,我编写了一个迭代解决方案,使堆栈显式。
显然,我只是简单地模仿递归正在做的事情,因此 Python 引擎当然可以更好地优化以处理簿记。但是我们能不能写出类似性能的迭代方法呢?

我的案例研究是 Problem #14 欧拉项目。

找到起始数低于 100 万的最长 Collat​​z 链。

代码

这是一个简洁的递归解决方案(归功于问题线程中的 veritas 以及来自 jJjjJ 的优化):

def solve_PE14_recursive(ub=10**6):
    def collatz_r(n):
        if not n in table:
            if n % 2 == 0:
                table[n] = collatz_r(n // 2) + 1
            elif n % 4 == 3:
                table[n] = collatz_r((3 * n + 1) // 2) + 2
            else:
                table[n] = collatz_r((3 * n + 1) // 4) + 3
        return table[n]
    table = {1: 1}
    return max(xrange(ub // 2 + 1, ub, 2), key=collatz_r)

这是我的迭代版本:

def solve_PE14_iterative(ub=10**6):
    def collatz_i(n):
        stack = []
        while not n in table:
            if n % 2 == 0:
                x, y = n // 2, 1
            elif n % 4 == 3:
                x, y = (3 * n + 1) // 2, 2
            else:
                x, y = (3 * n + 1) // 4, 3
            stack.append((n, y))
            n = x
        ysum = table[n]
        for x, y in reversed(stack):
            ysum += y
            table[x] = ysum
        return ysum
    table = {1: 1}
    return max(xrange(ub // 2 + 1, ub, 2), key=collatz_i)

以及使用 IPython 在我的机器(具有大量内存的 i7 机器)上的计时:

In [3]: %timeit solve_PE14_recursive()
1 loops, best of 3: 942 ms per loop
In [4]: %timeit solve_PE14_iterative()
1 loops, best of 3: 1.35 s per loop

评论

递归解决方案很棒:

  • 经过优化,可根据两个最低有效位跳过一两步。
    我最初的解决方案没有跳过任何 Collat​​z 步骤,耗时约 1.86 秒
  • 很难达到 Python 的默认递归限制 1000。
    collatz_r(9780657630) 返回 1133,但需要少于 1000 次递归调用。
  • 记忆避免回溯
  • collatz_rmax 按需计算长度

玩弄它,时间似乎精确到 +/- 5 毫秒。
像 C 和 Haskell 这样具有静态类型的语言可以获得低于 100 毫秒的时间。
我将 memoization table 的初始化放在这个问题的设计方法中,以便时间反映每次调用时表值的“重新发现”。

collatz_r(2**1002) 提升 RuntimeError: maximum recursion depth exceeded
collatz_i(2**1002) 高兴地返回 1003

我熟悉生成器、协程和装饰器。
我正在使用 Python 2.7。我也很高兴使用 Numpy(我的机器上是 1.8)。

我在寻找什么

  • 缩小性能差距的迭代解决方案
  • 讨论 Python 如何处理递归
  • 与显式堆栈相关的性能损失的详细信息

我主要寻找第一个,尽管第二个和第三个对这个问题非常重要,并且会增加我对 Python 的理解。

【问题讨论】:

  • 好问题。我在探索节点树时观察到了同样的行为。
  • 我怀疑递归方法中的隐式堆栈(即调用堆栈)正在使用非常高效的 c,并且比使用 python 列表的显式堆栈提供更多的性能(它必须做更多工作不仅仅是对堆栈/帧指针进行几次操作)。
  • 您是否尝试过使用deque 作为您的堆栈?
  • @cmh 好电话,该注释实际上是在我的第一次修订中,但在我随后的修改中失败了。 @nmclean 实际上是的。 reversed(stack) 告诉我。我不记得结果是什么,但我稍后会报告。

标签: python performance recursion iteration


【解决方案1】:

这是我在运行一些基准测试后的(部分)解释,它证实了您的数据。

虽然在 CPython 中递归函数调用很昂贵,但它们并不像使用列表模拟调用堆栈那么昂贵。递归调用的堆栈是用 C 实现的紧凑结构(参见源代码中的Eli Bendersky's explanation 和文件Python/ceval.c)。

相比之下,您的模拟堆栈是一个 Python 列表对象,即一个堆分配的、动态增长的 array of pointers 到元组对象,而这些对象又指向实际值;再见,参考位置,你好缓存未命中。然后,您可以在这些对象上使用 Python 众所周知的缓慢迭代。使用kernprof 逐行分析确认迭代和列表处理需要大量时间:

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                               @profile
    17                                               def collatz_i(n):
    18    750000       339195      0.5      2.4          stack = []
    19   3702825      1996913      0.5     14.2          while not n in table:
    20   2952825      1329819      0.5      9.5              if n % 2 == 0:
    21    864633       416307      0.5      3.0                  x, y = n // 2, 1
    22   2088192       906202      0.4      6.4              elif n % 4 == 3:
    23   1043583       617536      0.6      4.4                  x, y = (3 * n + 1) // 2, 2
    24                                                       else:
    25   1044609       601008      0.6      4.3                  x, y = (3 * n + 1) // 4, 3
    26   2952825      1543300      0.5     11.0              stack.append((n, y))
    27   2952825      1150867      0.4      8.2              n = x
    28    750000       352395      0.5      2.5          ysum = table[n]
    29   3702825      1693252      0.5     12.0          for x, y in reversed(stack):
    30   2952825      1254553      0.4      8.9              ysum += y
    31   2952825      1560177      0.5     11.1              table[x] = ysum
    32    750000       305911      0.4      2.2          return ysum

有趣的是,即使n = x 也需要大约 8% 的总运行时间。

(不幸的是,我无法让kernprof 为递归版本生成类似的东西。)

【讨论】:

  • +1 “再见,参考位置,你好缓存未命中”对我来说很清楚。我知道 Python 列表是在堆上分配的,就像 C++ 中的 std::vector 一样,但并没有明确说明这是一个问题。太糟糕了,如果我有两个列表并推送数字,我就无法恢复位置。也喜欢数字。在写它的时候,我确实觉得n = x 本身花费了大量的时间,你量化了它!太糟糕了,我们确实没有得到递归版本的kernprof
【解决方案2】:

迭代代码有时比递归代码更快,因为它避免了函数调用开销。但是,stack.append 也是一个函数调用(以及顶部的属性查找)并增加了类似的开销。计算append 调用,迭代版本的函数调用与递归版本一样多。

这里比较前两个和后两个时间......

$ python -m timeit pass
10000000 loops, best of 3: 0.0242 usec per loop
$ python -m timeit -s "def f(n): pass" "f(1)"
10000000 loops, best of 3: 0.188 usec per loop
$ python -m timeit -s "def f(n): x=[]" "f(1)"
1000000 loops, best of 3: 0.234 usec per loop
$ python -m timeit -s "def f(n): x=[]; x.append" "f(1)"
1000000 loops, best of 3: 0.336 usec per loop
$ python -m timeit -s "def f(n): x=[]; x.append(1)" "f(1)"
1000000 loops, best of 3: 0.499 usec per loop

...确认不包括属性查找的 append 调用与调用最小的纯 Python 函数所用的时间大致相同,约为 170 ns。


从上面我得出结论,迭代版本没有固有的优势。下一个要考虑的问题是哪一个做得更多。为了获得(非常)粗略的估计,我们可以查看每个版本中执行的行数。我做了一个快速的实验来发现:

  • collatz_r 被调用了 1234275 次,if 块的主体执行了 984275 次。
  • collatz_i 被调用 250000 次,while 循环运行 984275 次

现在,假设collatz_rif 之外有 2 行,在内部有 4 行(在最坏的情况下执行,当else 被命中时)。总共需要执行 640 万行代码。 collatz_i 的可比数字可能是 5 和 9,加起来是 1000 万。

即使这只是一个粗略的估计,也足够符合实际的时间安排。

【讨论】:

    猜你喜欢
    • 2021-01-07
    • 2014-07-07
    • 2016-05-29
    • 2011-01-18
    • 2012-01-26
    • 2012-11-12
    • 2013-12-12
    • 2014-03-24
    • 2019-05-03
    相关资源
    最近更新 更多