__slots__: что меняет в объектной модели и сколько это стоит в байтах
Сначала механизм: перечисленные имена получают дескрипторы данных на классе и места по фиксированным смещениям, а __dict__ и __weakref__ перестают создаваться сами. Всё это — контракт языка, одинаковый на 3.11–3.14. И только потом цена, которая контрактом не является: 40 байт на экземпляр, 42 %, причём sys.getsizeof показывает обратное, basicsize молчит про __weakref__, а сумма двух getsizeof отвечает по-разному в начале программы и потом.
Полное техническое изложение
TL;DR
Что делает. __slots__ перечисляет имена атрибутов заранее. Каждое имя
получает дескриптор данных на классе и своё место в экземпляре, а __dict__ и
__weakref__ перестают создаваться сами. Это контракт языка: одинаково на
3.11–3.14.
Что из этого следует. Новых атрибутов не добавить; cached_property не
работает; слабая ссылка требует явного '__weakref__'; подкласс без своего
__slots__ получает словарь обратно. Но запрет не абсолютен: '__dict__'
можно назвать среди слотов, и динамические атрибуты вернутся.
Сколько экономит — не свойство языка, а свойство реализации. На классе с тремя атрибутами: 40 байт на экземпляр (42 %) на 3.11, 3.13 и 3.14 и 32 байта на 3.12.
Привычные числа это не показывают. sys.getsizeof с 3.12 отвечает с
обратным знаком, а сумма его с размером словаря даёт разное в начале программы
и потом. Мерить приходится партией.
Скорости слоты не дают. На измеренной сборке прогретые чтение и запись со
слотами и без различаются на проценты. Зато vars(obj) дорог сразу и по
памяти, и по времени.
Что меняет __slots__
Обычному экземпляру атрибуты можно добавлять когда угодно, поэтому ему нужно
место, где имена появляются во время работы, — словарь. __slots__ меняет
эту предпосылку: имена объявляются заранее, и место под них отводится заранее.
class Plain:
pass
p = Plain()
p.later = "что угодно, в любой момент"
class Slotted:
__slots__ = ("x", "y")
s = Slotted()
s.x = 1
s.z = 3 # AttributeErrorОтказ в последней строке — не отдельное правило, а следствие: под z места не
отведено, а словаря, куда можно положить что угодно, нет.
Слот — это дескриптор на классе
Объявление слота кладёт объект в словарь класса:
class User:
__slots__ = ("name",)
User.__dict__["name"] # <member 'name' of 'User' objects>У него есть и __get__, и __set__ — значит, это дескриптор данных, и он
сильнее словаря экземпляра. Точка ничего своего не делает: она вызывает этот
дескриптор. Отсюда же понятно, почему cached_property со слотами не работает:
он дескриптор не данных и рассчитывает, что словарь экземпляра его
перекроет, а перекрывать нечем.
Три конфигурации
| фиксированные слоты | __dict__ | новые атрибуты | |
|---|---|---|---|
| обычный класс | нет | да | да |
__slots__ = (...) | да | нет | нет |
__slots__ = (..., "__dict__") | да | да | да |
Третья строка — та, о которой обычно не говорят. У неё своя ловушка: vars(obj)
покажет только динамические имена, значения слотов в словарь не попадают.
Наследование
Правило одно: словарь заводится тому, кто не объявил __slots__.
class Child(Base): # словарь вернулся
pass
class ChildOk(Base): # экономия сохранена
__slots__ = ()Пустой кортеж — не «слотов нет», а «своих слотов нет и словарь заводить не надо».
Сколько это в байтах
Числа — про конкретные сборки, а не про язык. 3.13.13, класс с тремя
атрибутами. Слева — то, что отвечает sys.getsizeof, справа — байты, реально
запрошенные при создании 200 000 экземпляров:
getsizeof | замер | |
|---|---|---|
без __slots__ | 48 Б | 96 Б |
со __slots__ | 56 Б | 56 Б |
| вывод | слоты дороже на 8 | слоты дешевле на 40 |
getsizeof считает только сам объект: словарь экземпляра лежит по ссылке и в
это число не входит. Получается переворот — у того, кто платит меньше, число
больше.
Сложить его с размером словаря тоже не выход: у первого экземпляра класса словарь занимает 296 байт, а у него же после тысячи других — 96. Экземпляры одного класса разделяют таблицу ключей (сам словарь у каждого свой), и до того, как разделение случилось, число другое. Поэтому мерить приходится партией.
Скорости слоты не дают
3.13.13, наносекунд на операцию:
| со слотами | без слотов | после vars(obj) | |
|---|---|---|---|
чтение o.a | 8,78 | 8,51 | 8,53 |
запись o.a = 5 | 8,33 | 8,36 | 32,37 |
Разница между слотами и обычным классом — около трёх процентов, и при чтении
она не в пользу слотов. Так и должно быть: прогретое обращение специализируется
в LOAD_ATTR_SLOT у слотов и в LOAD_ATTR_INSTANCE_VALUE у обычного класса, а
это два разных опкода с одинаковой работой — чтение по фиксированному смещению.
Третий столбец — не шум. vars(obj) разворачивает значения в настоящий
словарь: память растёт с 96 до 160 байт, а запись дорожает почти вчетверо.
Когда применять
Когда экземпляров много: миллион объектов — это 40 мегабайт, сотня — четыре
килобайта, о которых и говорить нечего. И когда важно, чтобы чужой код не
материализовал словарь через vars(): у класса со слотами материализовывать
нечего.
У этой темы два этажа, и почти вся путаница вокруг неё — от того, что их складывают в один.
Первый этаж — контракт языка. __slots__ перечисляет имена; каждое имя
получает дескриптор данных на классе и своё место в экземпляре; __dict__ и
__weakref__ перестают создаваться сами. Это не меняется от версии к версии, и
на это можно опираться в коде.
Второй этаж — реализация. Сколько при этом экономится байт, что показывает
sys.getsizeof, где физически лежит словарь. Здесь не гарантировано ничего, и
между 3.11 и 3.14 оно менялось трижды.
Статья идёт снизу вверх: сначала механизм, потом обещания языка, потом устройство CPython и только затем числа. Числа стоят последними не потому, что они неважны — ради них статья и написана, — а потому, что без первых трёх частей они читаются как набор неожиданностей.
Часть I. Что такое __slots__
Зачем обычному экземпляру словарь
Атрибуты в Python можно добавлять когда угодно и какие угодно:
class Plain:
pass
p = Plain()
p.x = 1
p.later = "что угодно, в любой момент"Чтобы это работало, у экземпляра должно быть место, где имена появляются во
время работы, — динамическое пространство имён. Им и служит __dict__.
__slots__ меняет ровно эту предпосылку: имена объявляются заранее, и место
под них отводится заранее.
class Slotted:
__slots__ = ("x", "y")
s = Slotted()
s.x = 1
s.y = 2
s.z = 3 # AttributeErrorОтказ в последней строке — не отдельное правило «слоты запрещают новые
атрибуты», а следствие: под z не отведено места, а словаря, куда можно
положить что угодно, нет.
Слот — это дескриптор данных на классе
Здесь легко остановиться на образе «значение лежит прямо в объекте». Он верен, но неполон, и из него не выводится половина поведения. Точнее так: объявление слота кладёт объект в словарь КЛАССА.
class User:
__slots__ = ("name",)
User.__dict__["name"] # <member 'name' of 'User' objects>У этого объекта есть и __get__, и __set__ — значит, это дескриптор
данных, и по правилу приоритета из статьи про поиск атрибута он сильнее
словаря экземпляра. Отсюда всё остальное:
u = User()
u.name = "ann"
u.name # 'ann'
User.__dict__["name"].__get__(u, User) # 'ann' — то же самое, в обход точкиТочка ничего своего не делает: она вызывает этот дескриптор. Класс хранит знание о том, по какому смещению в экземпляре лежит значение; экземпляр хранит само значение.
Из этой же картины сразу следует, почему cached_property со слотами не
работает: он дескриптор не данных и рассчитывает, что при следующем
обращении его перекроет словарь экземпляра. Перекрывать нечем.
'__dict__' в слотах: запрет не абсолютен
Между «обычный класс» и «только слоты» есть третий вариант, о котором обычно не говорят:
class Flexible:
__slots__ = ("a", "b", "__dict__")
f = Flexible()
f.a = 1 # слот
f.whatever = 2 # динамический атрибут — прошёл
vars(f) # {'whatever': 2}Слоты дают предсказуемую раскладку объявленным именам, а словарь оставляет дверь открытой для остальных.
Обратите внимание на последнюю строку: в vars(f) значений слотов нет. Они
лежат по своим смещениям, и словарь про них не знает. Отсюда практическое
следствие, которое дороже самой возможности: любой код, сериализующий объект
через vars() или __dict__ — логгер, отладчик, наивный asdict — у такого
класса увидит не все атрибуты.
Наследование: одно правило
Правило одно, и версии его не меняют: словарь заводится тому, кто не объявил
__slots__.
class Base:
__slots__ = ("x",)
class ChildBare(Base): # словарь есть — строки нет
pass
class ChildEmpty(Base): # словаря нет
__slots__ = ()
class ChildOwn(Base): # словаря нет
__slots__ = ("y",)__dict__ | |
|---|---|
Base | нет |
ChildBare — строки нет | есть |
ChildEmpty — __slots__ = () | нет |
ChildOwn — __slots__ = ("y",) | нет |
Вторая и третья строка — вся ловушка наследования целиком. __slots__ = () —
это не «слотов нет», это «своих слотов нет, и словарь заводить не надо».
Часть II. Что обещает язык
Три конфигурации и что каждая даёт
| фиксированные слоты | __dict__ | динамические атрибуты | |
|---|---|---|---|
| обычный класс | нет | да | да |
__slots__ = (...) | да | нет | нет |
__slots__ = (..., "__dict__") | да | да | да |
Краевые случаи, о которые спотыкаются
Слабые ссылки не работают, пока не объявлены. weakref.ref на экземпляре
со слотами даёт TypeError, пока в кортеже нет '__weakref__'. Имя служебное,
но перечисляется как обычное.
Множественное наследование ломается на двух непустых наборах слотов.
TypeError: multiple bases have instance lay-out conflict. Слот — фиксированное
смещение, и двух несовместимых раскладок не бывает. Если у одного из родителей
слоты пустые, конфликта нет.
Повтор имени слота в подклассе не даёт ошибки — и это худший вариант.
class A:
__slots__ = ("x",)
class B(A):
__slots__ = ("x",) # то же имя — объявление проходитУ B появляется свой дескриптор x, и место базы тратится впустую. Слот
базы при этом становится недоступен обычным обращением по имени: дескриптор
подкласса его перекрывает. Добраться до него можно только через дескриптор базы
напрямую — и тогда видно, что в одном объекте под одним именем лежат два разных
значения. Правило простое: имя слота в подклассе не повторять.
Непустые слоты запрещены не «встроенным типам», а типам с ненулевым
__itemsize__. Это не список для запоминания, а критерий, проверяемый одной
строкой:
| тип | __itemsize__ | непустые слоты |
|---|---|---|
tuple | 8 | TypeError |
bytes | 1 | TypeError |
int | 4 | TypeError |
str | 0 | разрешены |
list, dict, set, float, object | 0 | разрешены |
Такой тип хранит переменное число элементов прямо в объекте, и фиксированное
смещение под слот назначить некуда. Строка str — тот случай, где привычный
список из int, bytes и tuple даёт неверный ответ: она переменной длины по
смыслу, но __itemsize__ у неё ноль, и подклассу str непустые слоты
разрешены. Пустые слоты разрешены всем.
Словарная форма задаёт документацию атрибутов. Редкая, но полезная:
class User:
__slots__ = {
"name": "имя пользователя",
"age": "возраст в полных годах",
}
inspect.getdoc(User.name) # 'имя пользователя'Это единственный способ дать слотам то, чего у них иначе нет: help() и
инструменты документации видят описание каждого атрибута.
Всё перечисленное в этой части одинаково на 3.11, 3.12, 3.13 и 3.14 —
прогон bench/slots/contract.py на четырёх сборках даёт побайтово совпадающий
вывод. Дальше начинается то, что не совпадает.
Часть III. Что делает CPython
Что показывает getsizeof и что показывает замер
Класс с тремя атрибутами, 3.13.13. Слева — то, что отвечает sys.getsizeof,
справа — байты, реально запрошенные у аллокатора при создании 200 000 таких
экземпляров:
getsizeof | замер | |
|---|---|---|
без __slots__ | 48 Б | 96 Б |
со __slots__ | 56 Б | 56 Б |
| вывод | слоты дороже на 8 | слоты дешевле на 40 |
Причина расхождения записана в документации самой функции:
Only the memory consumption directly attributed to the object is accounted for,
not the memory consumption of objects it refers to.
Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается.
Словарь экземпляра — как раз «объект, на который ссылаются». Он лежит по
указателю, и в число не входит. У экземпляра со слотами словаря нет вовсе, зато
три значения лежат в самом объекте — и попадают в basicsize. Отсюда и
переворот: у того, кто платит меньше, число больше.
На 3.11 та же функция не переворачивает ответ, а молчит: 56 против 56. Правильного ответа она не даёт ни в одной версии.
Почему сумма двух getsizeof — тоже не ответ
Напрашивается поправка: сложить getsizeof(obj) и getsizeof(obj.__dict__).
Она не работает, и разбор этого места объясняет заодно, что такое key-sharing.
Разделяется не словарь, а таблица ключей. Словари у экземпляров разные:
x, y = Plain(), Plain()
x.__dict__ is y.__dict__ # False
x.a = 999
y.__dict__["a"] # 1 — запись в один в другом не виднаОбщее у них другое: имена атрибутов у всех экземпляров класса одни и те же, и хранить их по разу на экземпляр незачем. Поэтому таблица ключей одна на класс, а значения — свои у каждого.
Отсюда следствие, из-за которого сумма и не работает: размер словаря экземпляра зависит от истории программы, а не от класса.
getsizeof(obj.__dict__) | 3.11 / 3.12 / 3.13 / 3.14 |
|---|---|
| у самого первого экземпляра класса | 296 Б |
| у него же после тысячи других | 96 Б |
Один и тот же класс, одни и те же три атрибута. Наивная сумма даёт 48 + 296 = 344 Б в начале программы и 48 + 96 = 144 Б потом, а замер партией — 160 Б. То есть она завышает на 184 байта в одном случае и занижает на 16 в другом, и какой ответ вы получите, зависит от того, когда спросили.
Поэтому мерить приходится партией: в ней ключи уже разделены — как и в настоящей программе, где экземпляров много.
Раскладка от версии к версии
Байты между версиями сравнивать можно: это раскладка объекта, а не время.
| байт на экземпляр | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
|---|---|---|---|---|
без __slots__ | 96 | 88 | 96 | 96 |
со __slots__ | 56 | 56 | 56 | 56 |
| экономия | 40 | 32 | 40 | 40 |
Ряд не монотонный, и это важно: обычно его описывают как «в 3.13 экономия выросла». Выросла она только относительно 3.12: 3.11 уже давал 40 байт, 3.12 сделал обычный экземпляр на восемь байт дешевле, а 3.13 эти восемь байт вернул.
Что изменилось. До 3.13 на «словарь или значения» отводилось одно слово, размеченное объединением:
typedef union {
PyObject *dict;
/* Use a char* to generate a warning if directly assigning a PyDictValues */
char *values;
} PyDictOrValues;В 3.13 указатель на словарь и inline values разъехались: указатель получил своё слово в предзаголовке, а значения переехали внутрь объекта. Обычный экземпляр подорожал на восемь байт, экземпляр со слотами не изменился — у него ни того, ни другого нет.
Ловушка наследования в байтах
Правило из части I версии не меняют. А вот его цена — меняет:
| байт на экземпляр | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
|---|---|---|---|---|
| обычный класс | 96 | 88 | 96 | 96 |
| подкласс без своих слотов | 96 | 88 | 72 | 96 |
На 3.13 такой подкласс дешевле обычного класса на 24 байта. Разница читается по
одному флагу: INLINE_VALUES у него снят, тогда как у обычного класса стоит. А
почему снят — написано в заголовке:
static inline PyDictValues *
_PyObject_InlineValues(PyObject *obj)
{
assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_INLINE_VALUES);
assert(Py_TYPE(obj)->tp_flags & Py_TPFLAGS_MANAGED_DICT);
assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));
return (PyDictValues *)((char *)obj + sizeof(PyObject));
}Третья строка и есть ответ. Значения кладутся ровно по адресу
obj + sizeof(PyObject) — сразу за заголовком. У подкласса там уже лежат слоты
базы, места нет, и массив значений не заводится вовсе: указатель на словарь
остаётся пустым, пока в словарь не напишут.
На 3.14 флаг у такого подкласса стоит, и цена возвращается к 96. Заголовков 3.14 в этой машине нет, поэтому здесь утверждается только прочитанное флагом в рантайме и измеренное: её исходник не читался.
Практический вывод от версии не зависит и совпадает с правилом из части I:
подклассу нужен свой __slots__, хотя бы пустой. Уносить из этого раздела стоит
не числа, а форму: цена этой ловушки не постоянна от версии к версии, и
проверять её надо на той, на которой работает код.
Цена __weakref__ и молчащий basicsize
| 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 | |
|---|---|---|---|---|
| со слотами | 56 Б | 56 Б | 56 Б | 56 Б |
и с '__weakref__' | 64 Б | 72 Б | 72 Б | 72 Б |
| цена | 8 Б | 16 Б | 16 Б | 16 Б |
разница в basicsize | 8 | 0 | 0 | 0 |
weaklistoffset | 40 | −32 | −32 | −32 |
С 3.12 цена вдвое выше, а basicsize показывает ноль. Ссылка уехала в
предзаголовок — область перед началом объекта, — и в basicsize она не
входит по определению. Отрицательный weaklistoffset это и говорит: смещение
считается назад от начала объекта.
Промах ровно тот же, что у getsizeof: привычное число молчит, а байты
тратятся. Величина другая, механизм тот же — считается только то, что лежит
внутри объекта.
Часть IV. Измерения
Как получены числа
tracemalloc вокруг создания партии в 200 000 экземпляров: он считает все
запрошенные аллокации, а не размер одного заголовка. Деление на размер партии
возвращает цену экземпляра, а шум аллокатора — арены, пулы, округления — на
такой партии усредняется.
Три вещи сделаны нарочно, и без них числам верить нельзя.
Значения атрибутов общие. Всем экземплярам присваиваются одни и те же заранее созданные объекты, поэтому на экземпляр не приходится ни одной аллокации под значение. Если дать каждому свои три строки, собранные в рантайме, та же партия показывает 254,4 байта вместо 96 — разница в 158,4 байта и есть память под строки.
Список-держатель отводится целиком до старта tracemalloc. Поэтому в
измерение он не попадает и вычитать из суммы нечего. Проверка меряет партию из
уже созданного объекта и обязана дать ноль — она и даёт.
Величина не должна зависеть от размера партии. 200 000 и 400 000 дают одно и то же число: расхождение 0,00 байта.
Ускоряет ли __slots__ доступ
Проверяется в одном запуске одного интерпретатора — время между версиями не сравнивается вовсе, у сборок разные компиляторы и разные флаги.
3.13.13, наносекунд на операцию, лучшее из семи раундов:
| со слотами | без слотов | после vars(obj) | |
|---|---|---|---|
чтение o.a | 8,78 | 8,51 | 8,53 |
запись o.a = 5 | 8,33 | 8,36 | 32,37 |
Разница между слотами и обычным классом — 3,1 % при чтении и 0,4 % при записи, и при чтении она направлена не в пользу слотов. На этой сборке и на этом классе замер преимущества слотов в скорости доступа не показывает.
Формулировка узкая намеренно. Проверены: одна сборка, класс с тремя атрибутами,
прогретые чтение и запись одного атрибута. Утверждать из этого «__slots__ не
ускоряет доступ» вообще — значит повторить ту же ошибку, за которую эта статья
критикует getsizeof: принять результат частного измерения за свойство языка.
Во что специализируется прогретое чтение
Вот почему времена и не могли разойтись. Прогретую операцию в современном CPython исполняет не общий опкод, а специализированный, и адаптивный дизассемблер показывает какой:
| 3.12 | 3.13 | 3.14 | |
|---|---|---|---|
| со слотами | LOAD_ATTR_SLOT | LOAD_ATTR_SLOT | LOAD_ATTR_SLOT |
| без слотов | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR_INSTANCE_VALUE |
после vars(obj) | LOAD_ATTR_WITH_HINT | LOAD_ATTR_INSTANCE_VALUE | LOAD_ATTR |
Первые две строки — разные опкоды, но одинаковая работа: чтение по фиксированному смещению внутри объекта. Разными путями пришли к одному, поэтому и времена равны. Замер сравнивал не «дескриптор против словаря», а два специализированных опкода.
Третья строка объясняет третий столбец таблицы времён и заодно показывает, до
какой степени это свойство сборки: на 3.14 специализации после vars(obj) не
остаётся вовсе — работает общий LOAD_ATTR со всем протоколом поиска атрибута.
Отсюда точная формулировка того, что вообще измерено: не «какой механизм короче в принципе», а «сколько стоит прогретая операция в этой сборке после оптимизаций интерпретатора».
Почему в этом замере операция повторяется
Первый черновик мерил одно o.a за виток timeit. Прогон печатает оба режима
рядом, и вот что даёт неамортизированный:
| одна операция за виток | нс |
|---|---|
pass | 12,36 |
o.a со слотами | 19,44 |
o.a без слотов | 19,56 |
Полезного сигнала здесь 36 % от числа: всё остальное — цикл. Разница между двумя последними строками лежит внутри его шума, и вывод из неё не стоит ничего.
После того как операция стала повторяться 50 раз внутри витка, дно метода упало до 1,43 нс против измеряемых восьми — и числа стали означать то, что должны. Вывод при этом не перевернулся, а укрепился.
Это третий случай одного сюжета. getsizeof меряет не то, что нужно;
basicsize не видит предзаголовка; однооперационный timeit меряет себя.
Каждый раз инструмент отвечает честно на вопрос, который ему задали, — просто
это не тот вопрос.
Что стоит vars(obj)
Обращение к vars(obj) разворачивает inline values в настоящий словарь. На
измеренной сборке это дороже и по памяти, и по времени: 96 → 160 байт и
запись атрибута дороже почти вчетверо (+287 %) при неизменном чтении.
Кратность здесь важнее числа. В другую сессию на этой же машине то же место дало +136 %: подорожание записи никуда не делось, но во сколько именно раз — величина, привязанная к прогону. Три последовательных прогона подряд дали +296, +298 и +302 %, и в записи лежит один из них.
Формулировать это следует так же узко, как и предыдущий вывод: в CPython с
inline-атрибутами обращение к vars(obj) или obj.__dict__ может
материализовать словарное представление, и это способно увеличить память
экземпляра и изменить стоимость последующих записей. Насколько — зависит от
версии, формы класса и нагрузки.
Часть V. Практика
Когда __slots__ оправдан
Экономия в 40 байт имеет смысл там, где экземпляров много: миллион объектов — это 40 мегабайт, сто штук — четыре килобайта, о которых и говорить нечего.
Порядок вопросов, по которому решение принимается:
- Экземпляров одного небольшого класса действительно много? Нет — слоты, скорее всего, не нужны.
- Нужны динамические атрибуты или инструменты, работающие через
__dict__? Да — обычный класс либо__slots__вместе с'__dict__'. - Нужны слабые ссылки? Да — добавить
'__weakref__'и учесть 16 байт. - Наследование? У каждого подкласса должен быть свой
__slots__, хотя бы пустой. - Проверить на своей версии. Числа этой статьи — про четыре конкретные сборки.
Что слоты забирают взамен: новых атрибутов не добавить; множественное
наследование от двух родителей с непустыми слотами не собирается;
cached_property не работает; weakref требует явной строки.
И главное, ради чего их чаще всего и берут: экономия не исчезает от одного
обращения к vars(obj) в чужом коде — логгере, сериализаторе, отладчике. У
класса со слотами словаря нет вовсе, материализовывать нечего. Обычный класс от
этого не защищён никак.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.11 | Обычный экземпляр стоит 96 байт, со слотами — 56. getsizeof здесь ещё не переворачивает ответ, а просто молчит: 56 против 56. '__weakref__' в слотах стоит 8 байт, и basicsize их показывает. | |
| 3.12 | Обычный экземпляр дешевеет до 88 байт, экономия падает до 32. С этой версии getsizeof отвечает с обратным знаком: 48 против 56. '__weakref__' дорожает вдвое, до 16 байт, и уезжает в предзаголовок — basicsize перестаёт его показывать. Чтение после vars(obj) специализируется в LOAD_ATTR_WITH_HINT. | |
| 3.13 | Появляется флаг Py_TPFLAGS_INLINE_VALUES; указатель на словарь и inline values разъезжаются. Обычный экземпляр возвращается к 96 байтам, экономия — к 40. Побочный эффект: подкласс класса со слотами, у которого нет своих, inline values не получает и стоит 72 байта — дешевле обычного класса. | |
| 3.14 | Ограничение на inline values снято: подкласс без своих слотов снова стоит 96 байт. Зато чтение после vars(obj) перестаёт специализироваться вовсе — остаётся общий LOAD_ATTR. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.
Контракт языка, без байтов и времени — вывод совпадает на всех четырёх сборках:
Байты, флаги и смещения:
Время и специализация:
Python 3.11.15, 3.12.3 и 3.13.13 (GCC 13.3.0), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU. Байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги.
Фрагменты заголовков читались в файлах установленных сборок,
/usr/include/python3.12/internal/pycore_object.h и
/usr/include/python3.13/internal/pycore_object.h, и печатаются прогоном
дословно. Заголовков 3.14 в этой машине нет: про неё здесь утверждается только
то, что прочитано флагом в рантайме и измерено.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
На самом деле
- С 3.12 он показывает обратное: 48 байт у экземпляра без слотов против 56 со слотами, то есть по этому числу слоты выходят дороже на 8 байт. Настоящая экономия — 40 байт. Причина названа в документации самой функции: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to. Словарь экземпляра — как раз объект, на который ссылаются. На 3.11 функция не переворачивает ответ, а просто молчит: 56 против 56. Правильного ответа она не даёт ни в одной версии.
- На измеренной сборке — нет. 3.13.13, чтение
o.a: 8,78 нс со слотами против 8,51 без; запись: 8,33 против 8,36. Разница около трёх процентов, и при чтении она направлена не в пользу слотов. Причина видна в дизассемблере: прогретое обращение специализируется вLOAD_ATTR_SLOTу слотов и вLOAD_ATTR_INSTANCE_VALUEу обычного класса — два разных опкода, но одинаковая работа, чтение по фиксированному смещению. То есть замер сравнивал не «дескриптор против словаря», а две специализации, и отвечает он на вопрос «сколько стоит прогретая операция в этой сборке», а не «какой механизм короче в принципе». Выигрыш слотов — в памяти. - Ряд не монотонный: 96 / 88 / 96 / 96 байт на 3.11.15, 3.12.3, 3.13.13 и 3.14.7 для обычного экземпляра, то есть экономия 40 / 32 / 40 / 40. 3.11 уже давал 40 байт; 3.12 сделал обычный экземпляр на восемь байт дешевле, а 3.13 их вернул, разведя указатель на словарь и inline values по разным местам. «В 3.13 экономия выросла» верно только относительно 3.12.
- Не наследует: без своего
__slots__он снова получает словарь. Причём цена этой ловушки различается по версиям — 96 / 88 / 72 / 96 байт, — и на 3.13 такой подкласс неожиданно дешевле обычного класса на 24 байта. Дело в ограничении, записанном вpycore_object.hпрямо:assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));— inline values кладутся сразу за заголовком объекта, а там уже лежат слоты базы, поэтому массив значений не заводится вовсе. На 3.14 флаг у такого подкласса стоит и цена возвращается к 96 — это видно по флагу в рантайме и по замеру, исходник 3.14 не читался. Лечится одинаково во всех версиях:__slots__ = ()у подкласса. - Меняется память, а не
basicsize. С 3.12 слабая ссылка стоит 16 байт на экземпляр (56 → 72), при этомbasicsizeпоказывает ноль разницы: ссылка уехала в предзаголовок, до начала объекта, аbasicsizeсчитает только то, что внутри. Отрицательныйweaklistoffset(−32) — то место, где это видно. На 3.11 она стоила 8 байт, иbasicsizeих показывал. - Не говорит. Числа 10–20 % — из PEP 412 и относятся к key-sharing словарям, а не к
__slots__: measurements show a memory saving of 10% to 20% for object-oriented programs. Официальных чисел экономии от слотов не существует вовсе, поэтому все цифры здесь — собственные замеры с указанной методикой. - Разделяется не словарь, а таблица ключей. Словари у экземпляров разные объекты:
x.__dict__ is y.__dict__даётFalse, и запись в один в другом не видна. Общее у них другое — имена атрибутов одинаковы у всех экземпляров класса, и хранить их по разу на экземпляр незачем; значения при этом остаются свои. Отсюда следствие, из-за которого нельзя просто сложить дваgetsizeof: размер словаря экземпляра зависит от того, разделены ключи или ещё нет. У самого первого экземпляра класса он 296 байт, у него же после тысячи других — 96. Один класс, три атрибута, ответ зависит от момента вопроса. - Можно почти всем. Запрет действует ровно при
__itemsize__ != 0— то есть у типов, хранящих переменное число элементов прямо в объекте:tuple(8),bytes(1),int(4). Уstr,list,dict,set,floatиobjectitemsize нулевой, и непустые слоты им разрешены. Ходовой список изint,bytesиtupleнаstrдаёт неверный ответ: строка переменной длины по смыслу, но itemsize у неё ноль. Пустые слоты разрешены всем. - Объявление проходит без ошибки, и это худший вариант: ошибки нет, а поведение испорчено. У подкласса появляется СВОЙ дескриптор с тем же именем, место базы тратится впустую, а слот базы становится недоступен обычным обращением. Через дескриптор базы напрямую видно, что в одном объекте под одним именем лежат два разных значения. Правило: имя слота в подклассе не повторять.
- Прямо противоположное. Пустой кортеж говорит «своих слотов нет и словарь заводить не надо» — экземпляр остаётся без
__dict__. Отсутствие строки говорит «заведи словарь». Разница видна по__dictoffset__: у подкласса с__slots__ = ()он равен нулю, у подкласса без строки — отличен от нуля.
По версиям
- 3.11
- Обычный экземпляр стоит 96 байт, со слотами — 56.
getsizeofздесь ещё не переворачивает ответ, а просто молчит: 56 против 56.'__weakref__'в слотах стоит 8 байт, иbasicsizeих показывает.< - 3.12
- Обычный экземпляр дешевеет до 88 байт, экономия падает до 32. С этой версии
getsizeofотвечает с обратным знаком: 48 против 56.'__weakref__'дорожает вдвое, до 16 байт, и уезжает в предзаголовок —basicsizeперестаёт его показывать. Чтение послеvars(obj)специализируется вLOAD_ATTR_WITH_HINT.< - 3.13
- Появляется флаг
Py_TPFLAGS_INLINE_VALUES; указатель на словарь и inline values разъезжаются. Обычный экземпляр возвращается к 96 байтам, экономия — к 40. Побочный эффект: подкласс класса со слотами, у которого нет своих, inline values не получает и стоит 72 байта — дешевле обычного класса.< - 3.14
- Ограничение на inline values снято: подкласс без своих слотов снова стоит 96 байт. Зато чтение после
vars(obj)перестаёт специализироваться вовсе — остаётся общийLOAD_ATTR.<
Что разобрано
- Часть I. Что такое `__slots__`
- Зачем обычному экземпляру словарь
- Слот — это дескриптор данных на классе
- `'__dict__'` в слотах: запрет не абсолютен
- Наследование: одно правило
- Часть II. Что обещает язык
- Три конфигурации и что каждая даёт
- Краевые случаи, о которые спотыкаются
- Часть III. Что делает CPython
- Что показывает `getsizeof` и что показывает замер
- Почему сумма двух `getsizeof` — тоже не ответ
- Раскладка от версии к версии
- Ловушка наследования в байтах
- Цена `__weakref__` и молчащий `basicsize`
- Часть IV. Измерения
- Как получены числа
- Ускоряет ли `__slots__` доступ
- Во что специализируется прогретое чтение
- Почему в этом замере операция повторяется
- Что стоит `vars(obj)`
- Часть V. Практика
- Когда `__slots__` оправдан
- История версий
- Чем измерено
Частые заблуждения
sys.getsizeof покажет, сколько экономит __slots__
С 3.12 он показывает обратное: 48 байт у экземпляра без слотов против 56 со слотами, то есть по этому числу слоты выходят дороже на 8 байт. Настоящая экономия — 40 байт. Причина названа в документации самой функции: Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to
(Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Словарь экземпляра — как раз объект, на который ссылаются. На 3.11 функция не переворачивает ответ, а просто молчит: 56 против 56. Правильного ответа она не даёт ни в одной версии.
__slots__ ускоряет доступ к атрибутам
На измеренной сборке — нет. 3.13.13, чтение o.a: 8,78 нс со слотами против 8,51 без; запись: 8,33 против 8,36. Разница около трёх процентов, и при чтении она направлена не в пользу слотов. Причина видна в дизассемблере: прогретое обращение специализируется в LOAD_ATTR_SLOT у слотов и в LOAD_ATTR_INSTANCE_VALUE у обычного класса — два разных опкода, но одинаковая работа, чтение по фиксированному смещению. То есть замер сравнивал не «дескриптор против словаря», а две специализации, и отвечает он на вопрос «сколько стоит прогретая операция в этой сборке», а не «какой механизм короче в принципе». Выигрыш слотов — в памяти.
Экономия от слотов росла от версии к версии
Ряд не монотонный: 96 / 88 / 96 / 96 байт на 3.11.15, 3.12.3, 3.13.13 и 3.14.7 для обычного экземпляра, то есть экономия 40 / 32 / 40 / 40. 3.11 уже давал 40 байт; 3.12 сделал обычный экземпляр на восемь байт дешевле, а 3.13 их вернул, разведя указатель на словарь и inline values по разным местам. «В 3.13 экономия выросла» верно только относительно 3.12.
Подкласс класса со слотами наследует экономию
Не наследует: без своего __slots__ он снова получает словарь. Причём цена этой ловушки различается по версиям — 96 / 88 / 72 / 96 байт, — и на 3.13 такой подкласс неожиданно дешевле обычного класса на 24 байта. Дело в ограничении, записанном в pycore_object.h прямо: assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject)); — inline values кладутся сразу за заголовком объекта, а там уже лежат слоты базы, поэтому массив значений не заводится вовсе. На 3.14 флаг у такого подкласса стоит и цена возвращается к 96 — это видно по флагу в рантайме и по замеру, исходник 3.14 не читался. Лечится одинаково во всех версиях: __slots__ = () у подкласса.
Добавить '__weakref__' в слоты ничего не стоит: basicsize не меняется
Меняется память, а не basicsize. С 3.12 слабая ссылка стоит 16 байт на экземпляр (56 → 72), при этом basicsize показывает ноль разницы: ссылка уехала в предзаголовок, до начала объекта, а basicsize считает только то, что внутри. Отрицательный weaklistoffset (−32) — то место, где это видно. На 3.11 она стоила 8 байт, и basicsize их показывал.
Официальная документация говорит, что слоты экономят 10–20 %
Не говорит. Числа 10–20 % — из PEP 412 и относятся к key-sharing словарям, а не к __slots__: measurements show a memory saving of 10% to 20% for object-oriented programs
(замеры показывают экономию памяти 10–20 % для объектно-ориентированных программ). Официальных чисел экономии от слотов не существует вовсе, поэтому все цифры здесь — собственные замеры с указанной методикой.
При key-sharing экземпляры разделяют общий словарь
Разделяется не словарь, а таблица ключей. Словари у экземпляров разные объекты: x.__dict__ is y.__dict__ даёт False, и запись в один в другом не видна. Общее у них другое — имена атрибутов одинаковы у всех экземпляров класса, и хранить их по разу на экземпляр незачем; значения при этом остаются свои. Отсюда следствие, из-за которого нельзя просто сложить два getsizeof: размер словаря экземпляра зависит от того, разделены ключи или ещё нет. У самого первого экземпляра класса он 296 байт, у него же после тысячи других — 96. Один класс, три атрибута, ответ зависит от момента вопроса.
Непустые __slots__ нельзя объявить подклассу встроенного типа
Можно почти всем. Запрет действует ровно при __itemsize__ != 0 — то есть у типов, хранящих переменное число элементов прямо в объекте: tuple (8), bytes (1), int (4). У str, list, dict, set, float и object itemsize нулевой, и непустые слоты им разрешены. Ходовой список из int, bytes и tuple на str даёт неверный ответ: строка переменной длины по смыслу, но itemsize у неё ноль. Пустые слоты разрешены всем.
Повторить имя слота в подклассе — безобидно, просто переопределение
Объявление проходит без ошибки, и это худший вариант: ошибки нет, а поведение испорчено. У подкласса появляется СВОЙ дескриптор с тем же именем, место базы тратится впустую, а слот базы становится недоступен обычным обращением. Через дескриптор базы напрямую видно, что в одном объекте под одним именем лежат два разных значения. Правило: имя слота в подклассе не повторять.
__slots__ = () — то же самое, что не писать __slots__ вовсе
Прямо противоположное. Пустой кортеж говорит «своих слотов нет и словарь заводить не надо» — экземпляр остаётся без __dict__. Отсутствие строки говорит «заведи словарь». Разница видна по __dictoffset__: у подкласса с __slots__ = () он равен нулю, у подкласса без строки — отличен от нуля.
Проверка знаний
У класса три атрибута. sys.getsizeof показывает 48 байт для экземпляра без __slots__ и 56 со слотами. Что из этого следует?
Источники и что читать дальше
5 ИСТОЧНИКОВ
- internal/pycore_object.h — inline values в 3.13Исходный код CPython. Место, из которого следует поведение подкласса без своих слотов. Функция `_PyObject_InlineValues` начинается с трёх утверждений, и третье — `assert(Py_TYPE(obj)->tp_basicsize == sizeof(PyObject));`, после чего значения адресуются как `(PyDictValues *)((char *)obj + sizeof(PyObject))`. То есть массив значений кладётся сразу за заголовком объекта, и типу, у которого за заголовком уже лежат слоты базы, он не достаётся. Читалось в заголовке установленной сборки, `/usr/include/python3.13/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.13/Include/internal/pycore_object.h
- internal/pycore_object.h — то же место в 3.12Исходный код CPython. Для сравнения: до 3.13 на «словарь или значения» отводилось одно слово, размеченное объединением — `typedef union { PyObject *dict; char *values; } PyDictOrValues;`, с комментарием `/* Use a char* to generate a warning if directly assigning a PyDictValues */`. В 3.13 указатель на словарь и inline values разъехались, и обычный экземпляр подорожал на 8 байт. Читалось в заголовке установленной сборки, `/usr/include/python3.12/internal/pycore_object.h`.https://github.com/python/cpython/blob/3.12/Include/internal/pycore_object.h
- object.h — биты флагов типаИсходный код CPython. `Py_TPFLAGS_INLINE_VALUES (1 << 2)` определён только начиная с 3.13; в `object.h` версий 3.11 и 3.12 этого бита нет вовсе. Рядом `Py_TPFLAGS_MANAGED_WEAKREF (1 << 3)` (с 3.12) и `Py_TPFLAGS_MANAGED_DICT (1 << 4)`. Именно поэтому спрашивать «стоит ли INLINE_VALUES» на 3.11 бессмысленно: там это другой, никак не названный бит.https://github.com/python/cpython/blob/3.13/Include/object.h
- sys.getsizeof — что именно возвращаетсяОфициальная документация. Официальная формулировка того, из-за чего вся путаница: «Only the memory consumption directly attributed to the object is accounted for, not the memory consumption of objects it refers to» (Учитывается только потребление памяти, непосредственно относящееся к объекту, а не потребление памяти объектов, на которые он ссылается). Словарь экземпляра — как раз объект, на который ссылаются, и в число он не входит.https://docs.python.org/3/library/sys.html#sys.getsizeof
- PEP 412 — Key-Sharing DictionaryPEP. Источник механизма, который делает измерение памяти экземпляра нетривиальным: экземпляры одного класса разделяют ТАБЛИЦУ КЛЮЧЕЙ, а значения остаются у каждого свои. Отсюда же единственное официальное число про экономию памяти, которое обычно приводят рядом со слотами, — и относится оно к key-sharing словарям, а не к `__slots__`: «measurements show a memory saving of 10% to 20% for object-oriented programs» (замеры показывают экономию памяти 10–20 % для объектно-ориентированных программ). Официальных чисел экономии от `__slots__` не существует вовсе.https://peps.python.org/pep-0412/