Быстрое погружение в Python «__slots__»

В статье мы рассмотрим быстрый и простой способ ускорить ваш код Python (и пройти эти надоедливые тесты HackerRank, когда у вас немного не хватает времени!), А также некоторые из подробности технической реализации для любопытных.

__slots__ - это атрибут, который вы можете добавить к классу Python при его определении. Вы определяете слоты с возможными атрибутами, которыми может обладать экземпляр объекта. Вот как вы используете __slots__:

class WithSlots:
    __slots__ = ('x', 'y')

    def __init__(self, x, y):
        self.x, self.y = x, y

Основными причинами использования __slots__ на более высоком уровне являются:

  1. Более быстрое получение и установка атрибутов из-за оптимизации структуры данных
  2. Уменьшение использования памяти для экземпляров классов.

Некоторые причины, по которым вы не хотели бы его использовать, заключаются в том, что у вашего класса есть атрибуты, которые меняются во время выполнения (динамические атрибуты), или если есть сложное дерево наследования объектов. Для экземпляров этого класса вы можете использовать self.x и self.y так же, как и обычный экземпляр класса. Однако одно ключевое различие между этим и экземпляром из обычного класса состоит в том, что вы не можете добавлять или удалять атрибуты из экземпляров этого класса. Скажем, экземпляр был назван w: нельзя написать w.z = 2, не вызвав ошибки.


Тестирование

Давайте сначала проведем несколько тестов, чтобы увидеть, когда __slots__ будет быстрее, начиная с массового создания экземпляров. Используя модуль Python timeit и этот фрагмент кода, мы получаем следующие результаты:

class WithoutSlots:
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

class WithSlots:
    __slots__ = ('x', 'y', 'z')

    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

def instance_fn(cls):
    def instance():
        x = cls(1, 2, 3)
    return instance
Without Slots: 0.3909880230203271
With Slots: 0.31494391383603215
(averaged over 100000 iterations)

В этом случае создание экземпляров со слотами происходит немного быстрее. Это имеет смысл, поскольку мы запрещаем создание __dict__ для новых экземпляров данного объекта. Словари обычно имеют больше накладных расходов, чем кортежи или списки. Давайте попробуем это с классом, у которого гораздо больше атрибутов, связанных с экземпляром! (В этом примере 26 атрибутов):

Without Slots: 1.5249411426484585
With Slots: 1.52750033326447
(averaged over 100000 iterations)

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

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

def get_set_fn(cls):
    x = cls(list(range(26)))
    def get_set():
        x.y = x.z + 1
        x.a = x.b - 1
        x.d = x.q + 3
        x.i = x.j - 1
        x.z = x.y / 2
    return get_set
Without Slots: 11.59717286285013
With Slots: 9.243316248897463
(averaged over 100000 iterations)

Это увеличение скорости более чем на 20%! Я уверен, что если бы тест был более обширным (и не всегда имел доступ к одним и тем же атрибутам, а также имел атрибуты, которые были длиннее одного символа), могло бы быть более существенное ускорение.

Использование памяти

Во-первых, давайте проверим разницу между ростом в памяти кортежей и словарей. Поскольку использование __slots__ знает, какие атрибуты могут существовать для данного экземпляра, оно может выделять дескрипторы, связанные с экземпляром (вместо того, чтобы добавлять __dict__ для каждого нового объекта). В Python немного сложно профилировать точный объем памяти, используемый экземпляром объекта: sys.getsizeof хорошо работает только для примитивов и встроенных модулей. Вместо этого мы будем использовать функцию asizeof в библиотеке Pympler.

>>> asizeof(('a', 'b', 'c', 'd'))
304
>>> asizeof({'a': 'b', 'c': 'd'})
512
>>> asizeof(tuple(string.ascii_lowercase))
1712
>>> dictionary
{'e': 'f', 'k': 'l', 'c': 'd', 'g': 'h', 'o': 'p', 'i': 'j', 's': 't', 'm': 'n', 'q': 'r', 'a': 'b', 'y': 'z', 'w': 'x', 'u': 'v'}
>>> asizeof(dictionary)
2320

Здесь мы опустили детали реализации для примера __slots__: вместо того, чтобы иметь один кортеж для дескрипторов и один для значений, мы просто поместили их все в один список. Однако мы увидим, что разница в размерах не такая большая по сравнению с разницей между tuple и dict:

>>> asizeof(('a', 'b')) + asizeof(('c', 'd'))
352

И на всякий случай вот что происходит, когда мы на самом деле запускаем asizeof в нашем предыдущем примере с выделенным классом:

>>> w1 = WithoutSlots(1, 2, 3)
>>> asizeof(w1)
416
>>> w2 = WithSlots(4, 5, 6)
>>> asizeof(w2)
160

Детали реализации CPython

Итак, сначала давайте проясним некоторые вещи о том, что такое CPython. Существует стандартная реализация языка Python, а его ядро написано на C. Вероятно, это то, что установлено на вашем компьютере (и что запускается), когда вы вводите python3.

Мне было любопытно узнать, что на самом деле изменилось при определении класса с помощью __slots__, а также мне просто хотелось найти повод для обсуждения выпуска CPython 3.7.1. Я также укажу, какой файл проверить, если вы читаете, в конце каждого пункта. Вот несколько важных вещей, которые я усвоил:

  • Когда __slots__ находится в классе, который создается (он является частью классов по умолчанию __dict__), __dict__ не создается для нового экземпляра. Однако словарь будет создан, если вы добавите __dict__ к __slots__, что означает, что вы можете получить лучшее из обоих миров, если знаете, что делаете. Файлы: typeobject.c type_new.
  • Создание экземпляров классов с __slots__ кажется немного большей работой, чем просто создание __dict__. По сути, вы перебираете все значения, определенные в словарной статье класса __slots__, и должны выделить дескрипторы для каждой отдельной записи. Проверьте type_new в typeobject.c для получения дополнительной информации. Файлы: typeobject.c type_new.
  • Байт-код, сгенерированный для классов со слотами и без них, одинаков. Это означает, что различия в поиске связаны с тем, как выполняется код операции LOAD_ATTR. Посмотрите «dis.dis», встроенный дизассемблер байт-кода Python.
  • Как и ожидалось, отсутствие __slots__ приводит к поиску в словаре: если вас интересуют подробности, посмотрите PyDict_GetItem. В итоге он получает указатель на PyObject, который содержит значение, просматривая словарь. Однако, если у вас есть __slots__, дескриптор кэшируется (который содержит смещение для прямого доступа к PyObject без поиска по словарю). В PyMember_GetOne он использует смещение дескриптора для прямого перехода туда, где указатель на объект хранится в памяти. Это немного улучшит согласованность кеша, поскольку указатели на объекты хранятся в 8-байтовых блоках рядом друг с другом (я использую 64-битную версию Python 3.7.1). Однако это все еще указатель PyObject, а это значит, что он может храниться где угодно в памяти. Файлы: ceval.c, object.c, descrobject.c

Некоторые указатели GDB

Если вы хотите покопаться в CPython, как это сделал я, вам потребуется некоторая настройка, прежде чем вы сможете начать пошаговое выполнение кода, чтобы определить, какие функции выполняются. После загрузки исходного кода и установки необходимых пакетов (я бы проверил инструкции по сборке для вашей машины в официальном репозитории) вместо того, чтобы просто ./configure, запустите ./configure --with-pydebug. Это создает отладочную сборку Python вместо обычной, которая позволяет вам присоединить GDB к процессу. Затем вы запускаете make, чтобы создать двоичный файл и отладить его с помощью GDB, запустив gdb python.

Кроме того, если я хотел отладить свой настоящий код Python, у меня было две стратегии. Либо а) создать условную точку останова, где я хотел бы остановиться в GDB, используя текущую строку type->tp_name (и назвав мой класс чем-то странным), или б) фактически записав оператор if в код и поместив точку останова в оператор. В итоге я стал чаще использовать последнюю стратегию, потому что обнаружил, что вставка длинного условного оператора точки останова в gdb каждый раз, когда я повторно открывал отладчик, была довольно раздражающей (и я запомнил b object.c: 869 после достаточного количества прогонов) .

От автора

В целом, эта статья была для меня своего рода предлогом изучить CPython в свободное время. В итоге я многому научился, загрузив и построив Python самостоятельно и вставив операторы printf в случайные места, а также используя gdb. Кроме того, я слышал более высокоуровневые причины использования __slots__ и на самом деле хотел проверить утверждения на себе более эмпирическим способом. Надеюсь, вы узнали что-то новое во время чтения!

https://blog.usejournal.com/a-quick-dive-into-pythons-slots-72cdc2d334e

Вернуться на верх