Быстрое погружение в Python «__slots__»
В статье мы рассмотрим быстрый и простой способ ускорить ваш код Python (и пройти эти надоедливые тесты HackerRank, когда у вас немного не хватает времени!), А также некоторые из подробности технической реализации для любопытных.
__slots__
- это атрибут, который вы можете добавить к классу Python при его определении. Вы определяете слоты с возможными атрибутами, которыми может обладать экземпляр объекта. Вот как вы используете __slots__
:
class WithSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x, self.y = x, y
Основными причинами использования __slots__
на более высоком уровне являются:
- Более быстрое получение и установка атрибутов из-за оптимизации структуры данных
- Уменьшение использования памяти для экземпляров классов.
Некоторые причины, по которым вы не хотели бы его использовать, заключаются в том, что у вашего класса есть атрибуты, которые меняются во время выполнения (динамические атрибуты), или если есть сложное дерево наследования объектов. Для экземпляров этого класса вы можете использовать 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