Как работает Lru_cache (из functools)?

Особенно при использовании рекурсивного кода lru_cache дает значительные улучшения. Я понимаю, что кеш — это пространство, в котором хранятся данные, которые должны быть быстро обработаны, и избавляет компьютер от повторных вычислений.

Как внутри работает Python lru_cache от functools?

Я ищу конкретный ответ, использует ли он словари, как и остальная часть Python? Он хранит только значение return?

Я знаю, что Python в значительной степени основан на словарях, однако я не смог найти конкретного ответа на этот вопрос. Надеюсь, кто-нибудь сможет упростить этот ответ для всех пользователей StackOverflow.


person elvirmuslic    schedule 17.04.2018    source источник


Ответы (3)


Исходный код functools доступен здесь: https://github.com/python/cpython/blob/master/Lib/functools.py

lru_cache использует декоратор _lru_cache_wrapper (декоратор Python с шаблоном аргументов), который имеет словарь cache в контексте, в котором он сохраняет возвращаемое значение вызванной функции (каждая декорированная функция будет иметь свой собственный кэш-словарь). Ключ словаря генерируется функцией _make_key из аргументов. Добавлены некоторые смелые комментарии ниже:

# ACCORDING TO PASSED maxsize ARGUMENT _lru_cache_wrapper
# DEFINES AND RETURNS ONE OF wrapper DECORATORS

def _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo):
    # Constants shared by all lru cache instances:
    sentinel = object()      # unique object used to signal cache misses

    cache = {}                                # RESULTS SAVES HERE
    cache_get = cache.get    # bound method to lookup a key or return None

    # ... maxsize is None:

    def wrapper(*args, **kwds):
        # Simple caching without ordering or size limit
        nonlocal hits, misses
        key = make_key(args, kwds, typed)     # BUILD A KEY FROM ARGUMENTS
        result = cache_get(key, sentinel)     # TRYING TO GET PREVIOUS CALLS RESULT
        if result is not sentinel:            # ALREADY CALLED WITH PASSED ARGS
            hits += 1
            return result                     # RETURN SAVED RESULT
                                              # WITHOUT ACTUALLY CALLING FUNCTION
        misses += 1
        result = user_function(*args, **kwds) # FUNCTION CALL - if cache[key] empty
        cache[key] = result                   # SAVE RESULT

        return result
    # ...

    return wrapper
person ndpu    schedule 17.04.2018
comment
fib.cache_info() выводит что-то вроде этого: CacheInfo(hits=28, misses=16, maxsize=None, currsize=16) Что здесь означает промах? - person Ambareesh; 08.04.2019
comment
@Ambareesh в начале декоратора эти счетчики создаются в контексте, и оба равны нулю: hits = misses = 0. Каждый вызов декорированной функции увеличивает любой из них — hits += 1, если в кеше уже есть данные, и misses += 1, если данных не было в кеше. Проверьте источник. Вы можете использовать эти счетчики для анализа полезности кэширования. - person ndpu; 08.04.2019
comment
То есть, если число просматривается в первый раз, количество промахов увеличивается на 1, а при последующих просмотрах того же числа число попаданий увеличивается? - person Ambareesh; 09.04.2019
comment
Из моего тестового кода кажется, что @lru_cache работает быстрее по сравнению с использованием моего собственного dict для кеша, используя cache.get(args, my_function(args) и сохраняя cache[args] = my_res в конце. Почему это так? - person onepiece; 29.07.2019
comment
@onepiece lru_cache() реализован в терминах _lru_cache_wrapper(), который имеет реализацию C, которая, вероятно, будет быстрее, чем любая реализация Python. - person David Foster; 29.08.2019
comment
Привет, @npdu, я знаю, что уже давно не задавал этот вопрос. Я получил много голосов, и если бы вы могли расширить ответ с помощью примера использования. Я видел, что даже в Google, если вы наберете lru_cache, вы получите результат 2 или 3, поэтому многие люди могут сразу перейти к этому сообщению, и они могут извлечь пользу из вашего сообщения. - person elvirmuslic; 06.04.2020

Исходный код Python 3.9 для кэша LRU: https://github.com/python/cpython/blob/3.9/Lib/functools.py#L429

Пример кода Фибоначчи

@lru_cache(maxsize=2)
def fib(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    return fib(n - 1) + fib(n - 2)

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

def lru_cache(maxsize=128, typed=False):
...
    if isinstance(maxsize, int):
        # Negative maxsize is treated as 0
        if maxsize < 0:
            maxsize = 0
    elif callable(maxsize) and isinstance(typed, bool):
        # The user_function was passed in directly via the maxsize argument
        user_function, maxsize = maxsize, 128
        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
        return update_wrapper(wrapper, user_function)
    elif maxsize is not None:
        raise TypeError(
         'Expected first argument to be an integer, a callable, or None')

    def decorating_function(user_function):
        wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
        wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
        return update_wrapper(wrapper, user_function)

    return decorating_function

lru_cache нормализует maxsize(when negative), добавляет детали CacheInfo и, наконец, добавляет оболочку и обновляет документы декоратора и другие детали.

lru_cache_wrapper

  • Оболочка Lru Cache имеет несколько переменных бухгалтерского учета.

     sentinel = object()          # unique object used to signal cache misses
     make_key = _make_key         # build a key from the function arguments
     PREV, NEXT, KEY, RESULT = 0, 1, 2, 3   # names for the link fields
    
     cache = {}
     hits = misses = 0
     full = False
     cache_get = cache.get    # bound method to lookup a key or return None
     cache_len = cache.__len__  # get cache size without calling len()
     lock = RLock()           # because linkedlist updates aren't threadsafe
     root = []                # root of the circular doubly linked list
     root[:] = [root, root, None, None]     # initialize by pointing to self
    
  • Оболочка получает блокировку перед выполнением любой операции.

  • Несколько важных переменных - корневой список содержит все элементы, соответствующие значению maxsize. Важно помнить, что корень ссылается на себя (root[:] = [root, root, None, None]) в предыдущей (0) и следующей позиции (1)

Три проверки высокого уровня

  • В первом случае, когда maxsize равно 0, это означает отсутствие функциональности кэширования, оболочка оборачивает пользовательскую функцию без какой-либо возможности кэширования. Оболочка увеличивает счетчик промахов кеша и возвращает результат.

     def wrapper(*args, **kwds):
         # No caching -- just a statistics update
         nonlocal misses
         misses += 1
         result = user_function(*args, **kwds)
         return result
    
  • Второй случай. когда maxsize равно None. В разделе нет ограничения на количество элементов, которые нужно хранить в кеше. Таким образом, оболочка проверяет наличие ключа в кеше (словаре). Когда ключ присутствует, оболочка возвращает значение и обновляет информацию о попадании в кэш. А когда ключ отсутствует, оболочка вызывает пользовательскую функцию с аргументами, переданными пользователем, обновляет кеш, обновляет информацию о промахе кеша и возвращает результат.

     def wrapper(*args, **kwds):
         # Simple caching without ordering or size limit
         nonlocal hits, misses
         key = make_key(args, kwds, typed)
         result = cache_get(key, sentinel)
         if result is not sentinel:
             hits += 1
             return result
         misses += 1
         result = user_function(*args, **kwds)
         cache[key] = result
         return result
    
  • Третий случай, когда maxsize является значением по умолчанию (128) или переданным пользователем целочисленным значением. Вот фактическая реализация кэша LRU. Весь код в обертке потокобезопасным способом. Перед выполнением любой операции прочитайте/запишите/удалите из кеша оболочка получает RLock.

LRU-кэш

  • Значение в кеше хранится в виде списка из четырех элементов (помните корень). Первый элемент — это ссылка на предыдущий элемент, второй элемент — это ссылка на следующий элемент, третий элемент — это ключ для вызова конкретной функции, четвертый элемент — это результат. Вот фактическое значение аргумента функции Фибоначчи 1 [[[...], [...], 1, 1], [[...], [...], 1, 1], None, None] . [...] означает ссылку на себя (список).

  • Первая проверка — на попадание в кеш. Если да, значение в кеше представляет собой список из четырех значений.

     nonlocal root, hits, misses, full
     key = make_key(args, kwds, typed)
     with lock:
         link = cache_get(key)
          if link is not None:
              # Move the link to the front of the circular queue
              print(f'Cache hit for {key}, {root}')
              link_prev, link_next, _key, result = link
              link_prev[NEXT] = link_next
              link_next[PREV] = link_prev
              last = root[PREV]
              last[NEXT] = root[PREV] = link
              link[PREV] = last
              link[NEXT] = root
              hits += 1
              return result
    

    Когда элемент уже находится в кеше, нет необходимости проверять, заполнена ли циклическая очередь или извлекать элемент из кеша. Скорее измените позиции элементов в круговой очереди. Поскольку недавно использованный элемент всегда находится вверху, код перемещается к недавнему значению в начало очереди, а предыдущий верхний элемент становится следующим за текущим элементом last[NEXT] = root[PREV] = link, link[PREV] = last и link[NEXT] = root. NEXT и PREV инициализируются вверху, что указывает на соответствующие позиции в списке PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields. Наконец, увеличьте информацию о попадании в кэш и верните результат.

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

Когда кеш не заполнен

    ...
    else:
        # Put result in a new link at the front of the queue.
        last = root[PREV]
        link = [last, root, key, result]
        last[NEXT] = root[PREV] = cache[key] = link
        # Use the cache_len bound method instead of the len() function
        # which could potentially be wrapped in an lru_cache itself.
        full = (cache_len() >= maxsize)
  • Если кэш не заполнен, подготовьте последние result(link = [last, root, key, result]), чтобы они содержали предыдущую ссылку корня, корень, ключ и вычисленный результат.

  • Затем укажите недавний результат (ссылку) на вершину циклической очереди (root[PREV] = link), предыдущий элемент root рядом, чтобы указать на недавний результат (last[NEXT]=link), и добавьте последний результат в кеш (cache[key] = link).

  • Наконец, проверьте, заполнен ли кеш (cache_len() >= maxsize and cache_len = cache.__len__ is declared in the top) и установите статус на полный.

  • Для примера fib, когда функция получает первое значение 1, корень пуст, а корневое значение равно [[...], [...], None, None], а после добавления результата в циклическую очередь корневое значение равно [[[...], [...], 1, 1], [[...], [...], 1, 1], None, None]. И предыдущий, и следующий указывают на результат ключа 1. А для следующего значения 0 после вставки корневое значение равно

    [[[[...], [...], 1, 1], [...], 0, 0], [[...], [[...], [...], 0, 0], 1, 1], None, None]. Предыдущий [[[[...], [...], None, None], [...], 1, 1], [[...], [[...], [...], 1, 1], None, None], 0, 0], следующий [[[[...], [...], 0, 0], [...], None, None], [[...], [[...], [...], None, None], 0, 0], 1, 1]

Когда кэш заполнен

    ...
    elif full:
        # Use the old root to store the new key and result.
        oldroot = root
        oldroot[KEY] = key
        oldroot[RESULT] = result
        # Empty the oldest link and make it the new root.
        # Keep a reference to the old key and old result to
        # prevent their ref counts from going to zero during the
        # update. That will prevent potentially arbitrary object
        # clean-up code (i.e. __del__) from running while we're
        # still adjusting the links.
        root = oldroot[NEXT]
        oldkey = root[KEY]
        oldresult = root[RESULT]
        root[KEY] = root[RESULT] = None
        # Now update the cache dictionary.
        del cache[oldkey]
        # Save the potentially reentrant cache[key] assignment
        # for last, after the root and links have been put in
        # a consistent state.
        cache[key] = oldroot
  • Когда кеш заполнен, используйте корень как oldroot(oldroot=root) и обновите ключ и результат.
  • Затем сделайте следующий элемент oldroot новым корнем (root=oldroot[NEXT]), скопируйте новый корневой ключ и результат (oldkey = root[KEY] and oldresult = root[RESULT]).
  • Установите для нового корневого ключа и результата значение None(root[KEY] = root[RESULT] = None).
  • Удалите элемент старого ключа из кеша (del cache[oldkey]) и добавьте вычисленный результат в кеш (cache[key] = oldroot).
  • Для примера Фибоначчи, когда кеш заполнен и ключ равен 2, корневое значение равно [[[[...], [...], 1, 1], [...], 0, 0], [[...], [[...], [...], 0, 0], 1, 1], None, None], а новый корень в конце блока равен [[[[...], [...], 0, 0], [...], 2, 1], [[...], [[...], [...], 2, 1], 0, 0], None, None]. Как видите, ключ 1 удален и заменен ключом 2.

Когда ключ появляется в кеше после получения блокировки.

    if key in cache:
        # Getting here means that this same key was added to the
        # cache while the lock was released.  Since the link
        # update is already done, we need only return the
        # computed result and update the count of misses.
        pass

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

Наконец, код возвращает результат. Перед выполнением части промаха кеша кеш обновления кода пропускает информацию и вызывает функцию make_key.

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

person Kracekumar    schedule 09.07.2020

Вы можете ознакомиться с исходным кодом здесь.

По сути, он использует две структуры данных: словарь, отображающий параметры функции в ее результат, и связанный список для отслеживания истории вызовов вашей функции.

Кэш в основном реализуется с использованием следующего, что говорит само за себя.

cache = {}
cache_get = cache.get
....
make_key = _make_key         # build a key from the function arguments
key = make_key(args, kwds, typed)
result = cache_get(key, sentinel)

Суть обновления связанного списка:

elif full:

    oldroot = root
    oldroot[KEY] = key
    oldroot[RESULT] = result

    # update the linked list to pop out the least recent function call information        
    root = oldroot[NEXT]
    oldkey = root[KEY]
    oldresult = root[RESULT]
    root[KEY] = root[RESULT] = None
    ......                    
person Bubble Bubble Bubble Gut    schedule 17.04.2018