Почему этот итеративный метод Collatz на 30% медленнее, чем его рекурсивная версия в Python?

Прелюдия

У меня есть две реализации для конкретной проблемы, одна рекурсивная и одна итеративная, и я хочу знать, что заставляет итеративное решение быть на 30% медленнее, чем рекурсивное.

Учитывая рекурсивное решение, я пишу итерационное решение, делающее стек явным.
Ясно, что я просто имитирую то, что делает рекурсия, поэтому, конечно, движок Python лучше оптимизирован для обработки бухгалтерского учета. Но можно ли написать итерационный метод с аналогичной производительностью?

Мое тематическое исследование Проблема №14 в Project Euler.

Найдите самую длинную цепочку Collatz с начальным числом ниже миллиона.

Код

Вот рецессивное решение (кредит, связанный с веритами в нити проблемы плюс оптимизация от 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)

И тайминги на моей машине (машина i7 с большим объемом памяти) с использованием IPython:

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

Комментарии

Рекурсивное решение является удивительным:

  • Оптимизирован, чтобы пропустить шаг или два в зависимости от двух младших значащих бит.
    Мое первоначальное решение не пропустило никаких шагов Collatz и заняло ~ 1.86 с
  • Трудно попасть в предел рекурсии по умолчанию Python 1000.
    collatz_r( 9780657630 ) возвращает 1133, но требует менее 1000 рекурсивных вызовов.
  • Воспоминание позволяет избежать повторной проверки.
  • collatz_r длина, рассчитанная по запросу max

Играя с ним, тайминги кажутся точными до +/- 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.

Ответы

Ответ 1

Здесь мой снимок (частичное) объяснение после запуска некоторых тестов, которые подтверждают ваши цифры.

В то время как рекурсивные вызовы функций стоят дорого в CPython, они не так дороги, как эмуляция стека вызовов с использованием списков. Стек для рекурсивного вызова представляет собой компактную структуру, реализованную в C (см. объяснение Eli Bendersky и файл Python/ceval.c в исходном коде).

В отличие от этого, ваш эмулированный стек представляет собой объект списка Python, то есть массив с динамическим ростом , который содержит динамически растущий массив для привязки объектов, что, в свою очередь, указывает на фактические значения; до свидания, местность ссылок, промахи прошивки hello. Затем вы используете 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 для создания чего-то подобного для рекурсивной версии.)

Ответ 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 нс.


Из вышесказанного я пришел к выводу, что итеративная версия не обладает неотъемлемым преимуществом. Следующий вопрос, который следует рассмотреть, - это тот, который больше работает. Чтобы получить (очень) приблизительную оценку, мы можем посмотреть количество строк, выполняемых в каждой версии. Я сделал быстрый эксперимент, чтобы узнать, что:

  • collatz_r называется 1234275 раз, а тело блока if выполняется 984275 раз.
  • collatz_i называется 250000 раз, а цикл while выполняется 984275 раз

Теперь, скажем, collatz_r имеет 2 строки вне if и 4 строки внутри (которые выполняются в худшем случае, когда попадает else). Это позволяет выполнить до 6,4 миллиона строк. Сопоставимые цифры для collatz_i могут составлять 5 и 9, что составляет до 10 миллионов.

Даже если это была приблизительная оценка, она достаточно хорошо соответствует фактическим таймингам.