Ускорение часто вызываемых функций в Python

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

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

Кэширование в двух словах

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

Кэширование может быть реализовано с использованием различных структур данных, таких как массивы, хэш-таблицы и связанные списки, а также может управляться с помощью различных политик, таких как «наименее недавно использованные» (LRU) и «первым пришел — первым обслужен» (FIFO).

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

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

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

Создание наименее использовавшегося кеша с помощью декоратора lru_cache

Декоратор lru_cache является частью стандартной библиотеки языка и может быть импортирован из модуля functools.

Декоратор использует стратегию под названием Наименее недавно использовавшиеся (LRU) для приоритизации объектов, которые использовались последними. Всякий раз, когда доступ к объекту осуществляется с использованием кэша, использующего стратегию LRU, кэш помещает его вверх, а все остальные объекты сдвигает на одну позицию вниз. Однако, если кеш достигает своего максимального размера, последний использовавшийся объект удаляется, чтобы освободить место для новых объектов. Это гарантирует, что часто используемые объекты остаются в кэше, а редко используемые постепенно удаляются.

.. часто используемые объекты остаются в кеше, а редко используемые постепенно вытесняются

Кэши, реализующие LRU, пригодятся при работе с ресурсоемкими функциями или функциями, связанными с вводом-выводом, которые многократно вызываются с одними и теми же аргументами.

Декоратор lru_cache в Python поддерживает поточно-ориентированный кеш в форме словаря. При вызове декорированной функции в кэше создается новая запись, где аргументы вызова функции соответствуют ключу словаря, а возвращаемое значение — значению словаря. Последующие вызовы функции с теми же аргументами не будут вычислять новый результат; вместо этого будет извлечено кэшированное значение. Эта функция позволяет избежать избыточных вычислений, тем самым повышая общую производительность.

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

- Документация по Python

Размер lru_cache можно настроить с помощью аргумента maxsize, который по умолчанию равен 128. Это значение указывает максимальное количество записей, которые кэш может хранить в любой момент времени. Как только кеш достигает своего максимального размера и выполняется новый вызов, декоратор отбрасывает наименее использовавшуюся запись, чтобы освободить место для самой последней. Это гарантирует, что размер кеша не превысит указанный лимит, что предотвратит чрезмерное потребление памяти и повысит производительность. Если для maxsize установлено значение None, функция LRU отключается, что означает, что кеш может расти бесконечно.

Важно отметить, что когда два вызова функций имеют одни и те же аргументы ключевого слова, но в другом порядке, будут созданы две отдельные записи кэша. Например, вызов func(x=10, y=5) и func(y=5, x=10) приведет к появлению в кэше двух разных записей, даже если оба вызова возвращают одно и то же значение.

Кроме того, декоратор lru_cache принимает еще один аргумент с именем typed, который по умолчанию равен False.

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

Документация по Python

Например, вызов func(a=4) (a содержит целочисленное значение) и func(a=4.) (a теперь содержит значение с плавающей запятой), когда typed=True, в кэше будут созданы две разные записи. Однако обратите внимание, что некоторые типы, такие как str и int, могут кэшироваться в отдельных записях кэша, даже если для typed установлено значение False.

Теперь давайте предположим, что у нас есть очень простая функция Python, которая принимает два аргумента и возвращает их сумму.

def add_numbers(a, b):
    return a + b

Теперь давайте предположим, что функция украшена lru_cache и вызывается несколько раз, как показано ниже:

from functools import lru_cache


@lru_cache(maxsize=3)
def add_numbers(a, b):
    return a + b


add_numbers(a=10, b=15)
add_numbers(a=10, b=10)
add_numbers(a=3, b=15)
add_numbers(a=20, b=21)
add_numbers(a=3, b=15) 

На изображении ниже показано, как кэш LRU с maxsize=3 поддерживается с течением времени, как он ведет себя при попадании или промахе кэша и как он обрабатывает случаи, когда текущий размер достигает указанного максимального размера.

  1. Изначально при вызове функции с аргументами a=10 и b=15 кэш пуст и происходит промах кеша. Функция выполняется, и как входные аргументы, так и результирующее значение сохраняются в кеше.
  2. При следующем вызове, когда функция вызывается с аргументами a=10 и b=10, снова происходит промах кеша, так как эта комбинация аргументов не найдена в кеше. Функция выполняется, и в кэше создается новая запись, которая размещается вверху.
  3. Впоследствии, когда функция вызывается с аргументами a=3 и b=15, происходит еще один промах кэша. Входные аргументы и результирующее значение сохраняются в кеше после выполнения функции.
  4. При следующем вызове, когда функция вызывается с аргументами a=20 и b=1, происходит промах кэша, и кэш достиг своего максимального размера. Это означает, что наименее использовавшаяся запись удаляется, а оставшиеся записи сдвигаются на одну позицию вниз. После выполнения функции последняя запись добавляется в кеш и помещается вверху.
  5. Наконец, когда функция снова вызывается с аргументами a=3 и b=15, происходит попадание в кеш, поскольку эта запись теперь хранится в кеше. Возвращаемое значение извлекается из кеша вместо выполнения функции. Затем запись кэша перемещается наверх.

Как и когда использовать декоратор lru_cache

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

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

В математике числа Фибоначчи, обычно обозначаемые Fn , образуют последовательность, последовательность Фибоначчи, в которой каждое число представляет собой сумму двух предшествующие. Последовательность обычно начинается с 0 и 1, хотя некоторые авторы начинают последовательность с 1 и 1 или иногда (как это делал Фибоначчи) с 1 и 2. Начиная с 0 и 1, первые несколько значений в последовательности:

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144.

- Википедия

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

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

def fibonacci(n):
    if n < 0:
        raise ValueError('Invalid input')

    # Base case
    if n <= 1:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)

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

[При измерении времени выполнения] Используйте min(), а не среднее значение таймингов. Это рекомендация от меня, Тима Питерса и Гвидо ван Россума. Самое быстрое время представляет собой лучшее, что алгоритм может выполнить, когда кэши загружены, а система не занята другими задачами. Все тайминги шумные — самое быстрое время наименее шумное. Легко показать, что самые быстрые тайминги являются наиболее воспроизводимыми и, следовательно, наиболее полезными при синхронизации двух разных реализаций.

— Рэймонд Хеттингер на StackOverflow

# module test.py

from timeit import repeat


setup = """
def fibonacci(n):
    if n < 0:
        raise ValueError('Invalid input')

    # Base case
    if n <= 1:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)
"""

min_timing = min(repeat(setup=setup, stmt='fibonacci(30)', number=5))
print(f'Min Timing: {round(min_timing, 2)}s')

По сути, наш скрипт будет измерять время, затраченное на выполнение 30-го числа Фибоначчи с fibonacci. Из 5 разных вызовов минимальное время, которое потребовалось на моей машине, составило 1,39 секунды.

$ python3 test.py 
Min Timing: 1.39s

Функция fibonacci является детерминированной, что означает, что она всегда будет выдавать один и тот же результат при одних и тех же входных данных. Поэтому мы можем воспользоваться концепцией кэширования. При добавлении декоратора @lru_cache вывод функции для данного ввода теперь кэшируется, и если функция вызывается снова с тем же вводом, она возвращает кэшированный результат вместо его повторного вычисления. Это может значительно ускорить время выполнения функции, особенно при повторном вызове с одними и теми же входными значениями.

from functools import lru_cache


@lru_cache
def fibonacci(n):
    if n < 0:
        raise ValueError('Invalid input')

    # Base case
    if n <= 1:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)

Теперь давайте засечем новую версию функции fibonacci:

# module test.py

from timeit import repeat

setup = """
from functools import lru_cache

@lru_cache
def fibonacci(n):
    if n < 0:
        raise ValueError('Invalid input')

    # Base case
    if n <= 1:
        return n

    return fibonacci(n - 1) + fibonacci(n - 2)
"""

min_timing = min(repeat(setup=setup, stmt='fibonacci(30)', number=5))
print(f'Min Timing: {min_timing}s')

На моей машине минимальный тайминг был близок к нулю. fibonacci(30) выполнялся только на первой итерации. Для последующих итераций извлекался кэшированный результат вызова этой функции, что является недорогой операцией.

$ python3 test.py 
Min Timing: 7.790978997945786e-06s

Последние мысли

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

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

lru_cache decorator — это эффективный инструмент для оптимизации производительности кода Python за счет кэширования результатов функций, требующих значительных вычислительных ресурсов или связанных с вводом-выводом.

Стать участником и читать все истории на Medium. Ваш членский взнос напрямую поддерживает меня и других писателей, которых вы читаете. Вы также получите полный доступ ко всем историям на Medium.



Статьи по теме, которые вам также могут понравиться