Почему оптимизация хвостовой рекурсии быстрее, чем обычная рекурсия в Python?

Хотя я понимаю, что оптимизация хвостовой рекурсии не является Pythonic, я придумал быстрый взлом на вопрос, который был удален, как только я был готов опубликовать сообщение.

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

Что мне показалось интересным, так это то, что я ожидал дополнительных накладных расходов на передачу функции туда и обратно, чтобы сделать это медленнее, чем обычная рекурсия. Во время моего грубого тестирования я нашел, что это займет 30-50% времени обычной рекурсии. (С дополнительным бонусом, позволяющим рекурсии LONG.)

Вот код, который я запускаю:

from contextlib import contextmanager
import time

# Timing code from StackOverflow most likely.
@contextmanager
def time_block(label):
    start = time.clock()
    try:
        yield
    finally:
        end = time.clock()
        print ('{} : {}'.format(label, end - start))


# Purely Recursive Function
def find_zero(num):
    if num == 0:
        return num
    return find_zero(num - 1)


# Function that returns tuple of [method], [call value]
def find_zero_tail(num):
    if num == 0:
        return None, num
    return find_zero_tail, num - 1


# Iterative recurser
def tail_optimize(method, val):
    while method:
        method, val = method(val)
    return val


with time_block('Pure recursion: 998'):
    find_zero(998)

with time_block('Tail Optimize Hack: 998'):
    tail_optimize(find_zero_tail, 998)

with time_block('Tail Optimize Hack: 1000000'):
    tail_optimize(find_zero_tail, 10000000)

# One Run Result:
# Pure recursion: 998 : 0.000372791020758
# Tail Optimize Hack: 998 : 0.000163852100569
# Tail Optimize Hack: 1000000 : 1.51006975627

Почему второй стиль быстрее?

Мое предположение - накладные расходы на создание записей в стеке, но я не уверен, как это узнать.

Edit:

При игре с подсчетами звонков, я сделал цикл, чтобы попробовать оба с различными значениями num. Рекурсивный был намного ближе к паритету, когда я перебирал и вызывал несколько раз.

Итак, я добавляю это перед временем, которое находится find_zero под новым именем:

def unrelated_recursion(num):
    if num == 0:
        return num
    return unrelated_recursion(num - 1)

unrelated_recursion(998)

Теперь оптимизированный по хвосту вызов составляет 85% времени полной рекурсии.

Итак, моя теория заключается в том, что штраф в 15% - это накладные расходы для большего стека, против одного стека.

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

Поскольку мой алгоритм прост, распределение структуры памяти - большая часть времени выполнения.

Когда я вырезал запрос на загрузку стека на unrelated_recursion(499), я получаю примерно половину пути между полностью загруженным и не загруженным стеком в find_zero(998) времени выполнения. Это имеет смысл с теорией.

Ответ 1

В качестве комментария, надеюсь, меня уволили, я действительно не отвечал на этот вопрос, так что вот мои чувства:

В вашей оптимизации вы выделяете, распаковываете и освобождаете кортежи, поэтому я пробовал без них:

# Function that returns tuple of [method], [call value]
def find_zero_tail(num):
    if num == 0:
        return None
    return num - 1


# Iterative recurser
def tail_optimize(method, val):
    while val:
        val = method(val)
    return val

для 1000 попыток, каждый начиная со значения = 998:

  • эта версия принимает 0,16 с
  • Ваша "оптимизированная" версия заняла 0.22s
  • "неоптимизированный" взял 0,29 с

(Обратите внимание, что для меня ваша оптимизированная версия быстрее, чем не оптимизированная... но мы не выполняем тот же тест.)

Но я не думаю, что это полезно для получения этих характеристик: стоимость больше на стороне Python (вызовы методов, распределения кортежей,...), что ваш код делает реальные вещи. В реальном приложении вы не сможете измерить стоимость 1000 кортежей, но стоимость вашей фактической реализации.

Но просто не делайте этого: это просто трудно прочитать почти ничего, вы пишете для читателя, а не для машины:

# Function that returns tuple of [method], [call value]
def find_zero_tail(num):
    if num == 0:
        return None, num
    return find_zero_tail, num - 1


# Iterative recurser
def tail_optimize(method, val):
    while method:
        method, val = method(val)
    return val

Я не буду пытаться реализовать более читаемую версию, потому что, вероятно, в итоге получим:

def find_zero(val):
    return 0

Но я думаю, что в реальных случаях есть несколько хороших способов справиться с ограничениями рекурсии (как по размеру памяти, так и по глубине):

Чтобы справиться с памятью (а не с глубиной), lru_cache из functools, как правило, может помочь много:

>>> from functools import lru_cache
>>> @lru_cache()
... def fib(x):
...     return fib(x - 1) + fib(x - 2) if x > 2 else 1
... 
>>> fib(100)
354224848179261915075

И для размера стека вы можете использовать list или deque, в зависимости от вашего контекста и использования, вместо использования языкового стека. В зависимости от точной реализации (когда вы фактически сохраняете простой подкомпьютер в своем стеке, чтобы повторно использовать их), он называл динамическое программирование :

>>> def fib(x):
...     stack = [1, 1]
...     while len(stack) < x:
...         stack.append(stack[-1] + stack[-2])
...     return stack[-1]
... 
>>> fib(100)
354224848179261915075

Но и здесь идет приятная часть использования вашей собственной структуры вместо стека вызовов, вам не всегда нужно держать весь стек для продолжения вычислений:

>>> def fib(x):
...     stack = (1, 1)
...     for _ in range(x - 2):
...         stack = stack[1], stack[0] + stack[1]
...     return stack[1]
... 
>>> fib(100)
354224848179261915075

Но в заключение с приятным прикосновением "узнайте проблему, прежде чем пытаться ее реализовать" (нечитабельно, трудно отлаживать, трудно визуально прописать, это плохой код, но это весело):

>>> def fib(n):
...     return (4 << n*(3+n)) // ((4 << 2*n) - (2 << n) - 1) & ((2 << n) - 1)
... 
>>> 
>>> fib(99)
354224848179261915075

Если вы спросите меня, лучшая реализация будет более читаемой (для примера Fibonacci, возможно, с кешем LRU, но, изменив ... if ... else ... на более читаемый оператор if, в другом примере a deque может быть более читаемым, а для других примеров динамическое программирование может быть лучше...

"Вы пишете для человека, читающего ваш код, а не для машины".