Почему python lru_cache работает лучше всего, когда maxsize является степенью двойки? - PullRequest
2 голосов
/ 01 мая 2020

Документация гласит:

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

Кто-нибудь случайно узнал, откуда взялась эта "степень двойки"? Я предполагаю, что это связано с реализацией.

Ответы [ 2 ]

3 голосов
/ 01 мая 2020

При возникновении эффекта размера

Код lru_cache () работает со своим базовым словарем нетипичным образом. При сохранении общего постоянного размера, в кеше пропускается удаление самого старого элемента и вставка нового элемента.

Предложение степени двойки является артефактом взаимодействия этого шаблона удаления-вставки с базовым реализация словаря .

Как работают словари

  • Размеры таблиц имеют степень двойки.
  • Удаленные ключи заменяются на dummy записи.
  • Новые ключи могут иногда повторно использовать слот dummy , иногда нет.
  • Повторные операции удаления и вставки с разными ключами заполнят таблицу фиктивные записей.
  • Операция изменения размера O (N) выполняется, когда таблица заполнена на две трети.
  • Поскольку число активных записей остается постоянным операция изменения размера фактически не меняет размер таблицы.
  • Единственный эффект изменения размера - очистка накопленных фиктивных записей.

Влияние на производительность

Дикт с 2**n записями имеет т Наиболее доступное место для фиктивных записей, поэтому изменения размера O (n) встречаются реже.

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

Когда это имеет значение

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

Гораздо важнее, чем придание maxsize степени двойки использует самый большой разумный maxsize . У больших кешей больше попаданий в кеш - вот откуда берутся крупные выигрыши.

Симуляция

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

steady_state_dict_size = 2 ** 7     # always a power of two

def simulate_lru_cache(lru_maxsize, events=1_000_000):
    'Count resize operations as dummy keys are added'
    resize_point = steady_state_dict_size * 2 // 3
    assert lru_maxsize < resize_point
    dummies = 0
    resizes = 0
    for i in range(events):
        dummies += 1
        filled = lru_maxsize + dummies
        if filled >= resize_point:
           dummies = 0
           resizes += 1
    work = resizes * lru_maxsize    # resizing is O(n)
    work_per_event = work / events
    print(lru_maxsize, '-->', resizes, work_per_event)

Вот выдержка из вывода:

for maxsize in range(42, 85):
    simulate_lru_cache(maxsize)

42 --> 23255 0.97671
43 --> 23809 1.023787
44 --> 24390 1.07316
45 --> 25000 1.125
46 --> 25641 1.179486
  ...
80 --> 200000 16.0
81 --> 250000 20.25
82 --> 333333 27.333306
83 --> 500000 41.5
84 --> 1000000 84.0

Что это показывает, что кеш работает значительно меньше, когда maxsize находится как можно дальше от resize_point .

History

Эффект был минимально в Python3 .2 , когда словари увеличиваются на 4 x active_entries при изменении размера.

Эффект стал катастрофическим c, когда скорость роста была снижена до 2 x active entries.

Позже был достигнут компромисс , установив скорость роста 3 x used. Это значительно уменьшило проблему, предоставив нам больший размер устойчивого состояния по умолчанию.

Степень двойки maxsize по-прежнему является оптимальной настройкой, дающей наименьшее количество работы для данного размера словаря в стационарном режиме, но она больше не имеет такого большого значения, как в Python3 .2.

Надеюсь, это поможет прояснить ваше понимание. : -)

2 голосов
/ 01 мая 2020

TL; DR - это оптимизация, которая не имеет большого эффекта при небольших размерах lru_cache, но (см. Ответ Раймонда) имеет больший эффект, когда ваш размер lru_cache становится больше.

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

Сначала я пошел и прочитал источник для LRU-кэша. Реализация для cpython находится здесь: https://github.com/python/cpython/blob/master/Lib/functools.py#L723, и я не видел ничего, что бросалось бы мне в голову, как что-то, что могло бы работать лучше на основе степеней двух.

Итак, я написал короткую python программу для создания кэшей LRU различных размеров, а затем несколько раз использовал эти кэши. Вот код:

from functools import lru_cache
from collections import defaultdict
from statistics import mean
import time

def run_test(i):
    # We create a new decorated perform_calc
    @lru_cache(maxsize=i)
    def perform_calc(input):
        return input * 3.1415

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        # Calculate the value for a range larger than our largest cache
        for k in range(2000):
            perform_calc(k)

for t in range(10):
    print (t)
    values = defaultdict(list)
    for i in range(1,1025):
        start = time.perf_counter()
        run_test(i)
        t = time.perf_counter() - start
        values[i].append(t)

for k,v in values.items():
    print(f"{k}\t{mean(v)}")

Я запустил это на MacBook Pro при небольшой нагрузке с python 3.7.7.

Вот результаты:

https://docs.google.com/spreadsheets/d/1LqZHbpEL_l704w-PjZvjJ7nzDI1lx8k39GRdm3YGS6c/preview?usp=sharing

LRU Cache size vs time. The graph is random and spiky.

Случайные пики, вероятно, вызваны паузами G C или системными прерываниями.

В В этот момент я понял, что мой код всегда генерирует пропуски кэша и никогда не приводит к попаданию в кеш. Что произойдет, если мы запустим одно и то же, но всегда попадем в кеш?

Я заменил внутренний l oop на:

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        # Only ever create cache hits
        for k in range(i):
            perform_calc(k)

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

Давайте посмотрим: Graph of LRU Cache size vs Time with only cache hits. The graph is random and spiky.

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

Что если мы запустим его всего за 2 ^ n 2 ^ n + 1 и 2 ^ n - 1. Поскольку это ускоряет процесс, мы усредним его по 100 тестам, а не по 10.

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

from functools import lru_cache
from collections import defaultdict
from statistics import mean
import time
import random

rands = list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128)) + list(range(128))
random.shuffle(rands)

def run_test(i):
    # We create a new decorated perform_calc
    @lru_cache(maxsize=i)
    def perform_calc(input):
        return input * 3.1415

    # let's run the test 5 times (so that we exercise the caching)
    for j in range(5):
        for k in rands:
            perform_calc(k)

for t in range(100):
    print (t)
    values = defaultdict(list)
    # Interesting numbers, and how many random elements to generate
    for i in [15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 255, 256, 257, 511, 512, 513, 1023, 1024, 1025]:
        start = time.perf_counter()
        run_test(i)
        t = time.perf_counter() - start
        values[i].append(t)

for k,v in values.items():
    print(f"{k}\t{mean(v)}")

Данные для этого находятся на третьей вкладке таблицы выше.

Вот график среднего времени на элемент / lru кеш size:

Bar chart of average time per element / lru cache size.

Время, конечно, уменьшается по мере увеличения размера нашего кэша, поскольку мы не тратим столько времени на выполнение вычислений. Интересно, что, похоже, наблюдается спад с 15 до 16, 17 и с 31 до 32, 33. Давайте увеличим старшие числа:

LRU cache timing for 127 elements and up.

Мало того, что мы теряем этот паттерн в старших числах, мы на самом деле видим, что производительность уменьшается для некоторых из двух степеней (от 511 до 512, 513).

Редактировать: примечание о степени двойки было добавлено в 2012 , но алгоритм для functools.lru_cache выглядит так же при этом коммите , так что, к сожалению, опровергает мою теорию, что алгоритм изменился и документы устарели.

Редактировать: Убрал мои гипотезы. Первоначальный автор ответил выше - проблема с моим кодом заключается в том, что я работал с «маленькими» кэшами - это означает, что изменение размера O (n) на участках было не очень дорогим. Было бы здорово поэкспериментировать с очень большими lru_caches и множеством промахов в кеше, чтобы посмотреть, сможем ли мы получить эффект.

...