Deep Engineering

MEASUREMENT

bench/slots/memory.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/python/memory/slots
How to run it
Записи прогонов — в `runs/`: `memory.txt` и `layout.txt` для основной сборки
3.13, остальные с суффиксом версии. У `access.py` запись одна: время между
версиями не сравнивается (см. корневой `bench/README.md`), и второй файл
провоцировал бы ровно такое сравнение.

## Почему память меряется партией, а не объектом

`sys.getsizeof` — это `tp_basicsize + tp_itemsize*ob_size`: размер самого
объекта, без того, на что он ссылается. Словарь экземпляра лежит по ссылке, и
в это число не входит. Для `__slots__` результат получается не просто
неточным, а бесполезным, причём по-разному в разных версиях:

| `sys.getsizeof` | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
| --- | --- | --- | --- | --- |
| без `__slots__` | 56 | 48 | 48 | 48 |
| со `__slots__` | 56 | 56 | 56 | 56 |
| что из этого следует | «разницы нет» | «слоты дороже» | «слоты дороже» | «слоты дороже» |
| на самом деле (замер) | слоты дешевле на 40 | на 32 | на 40 | на 40 |

На 3.12 и дальше число **перевёрнуто**: по нему слоты выходят дороже на 8
байт. На 3.11 оно просто молчит — одинаковое в обоих случаях. Ни в одной
версии оно не даёт правильного ответа, и ни в одной не даёт его с одной и той
же ошибкой.

Честный ответ даёт `tracemalloc` вокруг создания партии в 200 000 экземпляров:
он считает все запрошенные аллокации. Список-держатель отводится целиком
**до** старта `tracemalloc`, поэтому в измерение не попадает и вычитать из
суммы нечего — проверка 3 в блоке 5 `memory.py` меряет партию из уже
созданного объекта и обязана дать ноль.

Первый черновик скрипта вычитал «8 байт на ячейку списка»: так методика
описана в плане статьи, и там она верна для варианта с `append`, где список
растёт под измерением. Здесь он не растёт. Проверка и существует затем, чтобы
такие расхождения не оставались на слово.

## Что измерено

Класс с тремя атрибутами, байт на экземпляр:

| | 3.11.15 | 3.12.3 | 3.13.13 | 3.14.7 |
| --- | --- | --- | --- | --- |
| без `__slots__` | 96 | 88 | 96 | 96 |
| со `__slots__` | 56 | 56 | 56 | 56 |
| без слотов, после `vars(obj)` | 160 | 152 | 160 | 160 |
| подкласс класса со слотами, без своих | 96 | 88 | **72** | 96 |

Экономия — 40 байт (42 %) на трёх из четырёх сборок и 32 байта (36 %) на 3.12.

**«Экономия выросла в 3.13» — формулировка, которая держится только на
сравнении с 3.12.** Ряд не монотонный: 3.11 уже давал 96 байт, 3.12 сделал
обычный экземпляр на 8 байт дешевле, 3.13 их вернул. В плане статьи это было
записано как рост в 3.13, потому что 3.11 в таблице не было.

## Подкласс без своих слотов: 72 байта на 3.13 и 96 на 3.14

Самая известная ловушка темы ведёт себя в разных версиях по-разному, и разница
не мелкая. Причина читается по одному флагу, `layout.py` печатает его:

| | 3.13.13 | 3.14.7 |
| --- | --- | --- |
| обычный класс, `INLINE_VALUES` | да | да |
| подкласс без своих слотов, `INLINE_VALUES` | **нет** | **да** |
| байт на экземпляр | 72 | 96 |

Флага `Py_TPFLAGS_INLINE_VALUES` до 3.13 не существует вовсе — в `object.h`
версий 3.11 и 3.12 бит 2 не определён, и спрашивать его там бессмысленно.
Первый черновик `layout.py` спрашивал и печатал «False», то есть выдавал за
измеренный факт ответ про несуществующий флаг.

Почему подкласс его не получает на 3.13 — написано в заголовке этой машины,
`/usr/include/python3.13/internal/pycore_object.h`:

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры: __slots__ — сколько экономит и чем это правильно мерить

Тема, где почти каждый привычный индикатор отвечает неправильно, причём по-разному. Здесь три скрипта, и каждый ловит свой промах.

скрипт что показывает
memory.py честная память на экземпляр через tracemalloc на партии; рядом — sys.getsizeof, который даёт ответ с ОБРАТНЫМ знаком; цена __weakref__, которую не видит basicsize; четыре версии подряд
layout.py из чего эти байты складываются: флаги типа, basicsize, dictoffset, weaklistoffset, плюс дословные фрагменты заголовков CPython этой машины
access.py ускоряет ли __slots__ доступ к атрибуту (нет) и во что обходится обращение к vars(obj) (в запись — почти вчетверо)

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/slots/memory.py
done
for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/slots/layout.py
done
python3.13 bench/slots/access.py

Записи прогонов — в runs/: memory.txt и layout.txt для основной сборки 3.13, остальные с суффиксом версии. У access.py запись одна: время между версиями не сравнивается (см. корневой bench/README.md), и второй файл провоцировал бы ровно такое сравнение.

Почему память меряется партией, а не объектом

sys.getsizeof — это tp_basicsize + tp_itemsize*ob_size: размер самого объекта, без того, на что он ссылается. Словарь экземпляра лежит по ссылке, и в это число не входит. Для __slots__ результат получается не просто неточным, а бесполезным, причём по-разному в разных версиях:

sys.getsizeof 3.11.15 3.12.3 3.13.13 3.14.7
без __slots__ 56 48 48 48
со __slots__ 56 56 56 56
что из этого следует «разницы нет» «слоты дороже» «слоты дороже» «слоты дороже»
на самом деле (замер) слоты дешевле на 40 на 32 на 40 на 40

На 3.12 и дальше число перевёрнуто: по нему слоты выходят дороже на 8 байт. На 3.11 оно просто молчит — одинаковое в обоих случаях. Ни в одной версии оно не даёт правильного ответа, и ни в одной не даёт его с одной и той же ошибкой.

Честный ответ даёт tracemalloc вокруг создания партии в 200 000 экземпляров: он считает все запрошенные аллокации. Список-держатель отводится целиком до старта tracemalloc, поэтому в измерение не попадает и вычитать из суммы нечего — проверка 3 в блоке 5 memory.py меряет партию из уже созданного объекта и обязана дать ноль.

Первый черновик скрипта вычитал «8 байт на ячейку списка»: так методика описана в плане статьи, и там она верна для варианта с append, где список растёт под измерением. Здесь он не растёт. Проверка и существует затем, чтобы такие расхождения не оставались на слово.

Что измерено

Класс с тремя атрибутами, байт на экземпляр:

3.11.15 3.12.3 3.13.13 3.14.7
без __slots__ 96 88 96 96
со __slots__ 56 56 56 56
без слотов, после vars(obj) 160 152 160 160
подкласс класса со слотами, без своих 96 88 72 96

Экономия — 40 байт (42 %) на трёх из четырёх сборок и 32 байта (36 %) на 3.12.

«Экономия выросла в 3.13» — формулировка, которая держится только на сравнении с 3.12. Ряд не монотонный: 3.11 уже давал 96 байт, 3.12 сделал обычный экземпляр на 8 байт дешевле, 3.13 их вернул. В плане статьи это было записано как рост в 3.13, потому что 3.11 в таблице не было.

Подкласс без своих слотов: 72 байта на 3.13 и 96 на 3.14

Самая известная ловушка темы ведёт себя в разных версиях по-разному, и разница не мелкая. Причина читается по одному флагу, layout.py печатает его:

3.13.13 3.14.7
обычный класс, INLINE_VALUES да да
подкласс без своих слотов, INLINE_VALUES нет да
байт на экземпляр 72 96

Флага Py_TPFLAGS_INLINE_VALUES до 3.13 не существует вовсе — в object.h версий 3.11 и 3.12 бит 2 не определён, и спрашивать его там бессмысленно. Первый черновик layout.py спрашивал и печатал «False», то есть выдавал за измеренный факт ответ про несуществующий флаг.

Почему подкласс его не получает на 3.13 — написано в заголовке этой машины, /usr/include/python3.13/internal/pycore_object.h:

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 в этой машине нет. Поэтому про неё утверждается только то, что прочитано флагом в рантайме и измерено; на её исходник здесь не ссылаются, и layout.py говорит об этом в выводе.

Для сравнения — как то же место выглядело в 3.12, /usr/include/python3.12/internal/pycore_object.h:

typedef union {
    PyObject *dict;
    /* Use a char* to generate a warning if directly assigning a PyDictValues */
    char *values;
} PyDictOrValues;

Одно слово на «или словарь, или значения» — против отдельного указателя на словарь и отдельных inline values в 3.13.

__weakref__: тот же промах на другой величине

Слабая ссылка на экземпляр со слотами не создаётся, пока '__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 перестал её показывать: ссылка уехала в предзаголовок, до начала объекта. Отрицательный weaklistoffset — то место, где это видно.

Время: что меряется и почему повторяется

access.py проверяет второе ходовое утверждение темы — «слоты ускоряют доступ». На 3.13.13, лучшее из 7 раундов:

со слотами без слотов после vars(obj)
чтение o.a 8,78 8,51 8,53
запись o.a = 5 8,33 8,36 32,37

Разница между слотами и inline values — около трёх процентов, и при чтении она направлена не в пользу слотов. Утверждение «__slots__ ускоряет доступ» замером не подтверждается. Зато подтверждается другое: обращение к vars(obj) удорожает запись почти вчетверо (+287 %), а чтение не трогает.

Кратность здесь устойчивее числа. В одну сессию то же место дало +136 %, в другую — +296, +298 и +302 % в трёх прогонах подряд. Вывод от этого не зависит, и в статье он подан кратностью, а не числом.

Числа сравниваются только между собой: они получены одним процессом на одной сборке. Между версиями время не сравнивается вовсе (корневой bench/README.md).

Операция повторяется 50 раз внутри одного витка timeit, и это не украшение. Проверка 2 печатает оба режима рядом. Без амортизации: pass 12,36 нс, o.a со слотами 19,44, без слотов 19,56 — то есть на сигнал приходится 36 % числа, остальное цикл, и «разница» между двумя последними строками лежит внутри его шума. После амортизации дно метода — 1,43 нс против измеряемых 8, и числа стали означать то, что должны.

Чем это меряли

Python 3.11.15 и 3.12.3 (GCC 13.3.0), 3.13.13 (GCC 13.3.0), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU. Байты между версиями сравнивать можно — это раскладка объекта. Время нельзя: у сборок разные компиляторы и разные флаги.

Script

369 lines
"""Сколько байт на экземпляр экономит `__slots__` — и почему `getsizeof` врёт.

ЗАЧЕМ ЭТОТ СКРИПТ. Про `__slots__` пишут «экономит память», и почти всегда
меряют это через `sys.getsizeof`. Он даёт ответ **с обратным знаком**: у
экземпляра со слотами число БОЛЬШЕ, чем у экземпляра без слотов. Не потому,
что слоты дороже, а потому, что `getsizeof` — это `tp_basicsize +
tp_itemsize*ob_size`, то есть только сам объект и ничего из того, на что он
ссылается. Словарь экземпляра лежит по ссылке, и в это число не попадает.

ЧТО ЗДЕСЬ МЕРЯЕТСЯ ЧЕСТНО. `tracemalloc` вокруг создания партии экземпляров:
он считает все запрошенные аллокации, а не размер одного заголовка.

Список, который держит партию, отведён ЦЕЛИКОМ до старта `tracemalloc`
(`[None] * n`), поэтому в измерение он не попадает вовсе и вычитать из суммы
нечего. Первый черновик этого скрипта вычитал «8 байт на ячейку списка» — так
методика описана в плане статьи, и там она верна для варианта с `append`, где
список растёт под измерением. Здесь список не растёт. Проверка 3 в блоке 6
существует именно затем, чтобы это не осталось на слово: она меряет партию из
УЖЕ созданного объекта и обязана дать ноль.

ПОЧЕМУ ПАРТИЕЙ, А НЕ ПО ОДНОМУ. Одиночное измерение утонет в шуме аллокатора:
арены, пулы и округления дают разброс больше самой величины. На партии в
200 000 эти эффекты усредняются, а деление на N возвращает цену экземпляра.

ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Блок 6 — самопроверка методики, без неё числам верить
нельзя:

  * величина не зависит от размера партии (200 000 против 400 000);
  * значения атрибутов не попадают в счёт: во всех классах присваиваются
    ОДНИ И ТЕ ЖЕ заранее созданные объекты, поэтому на экземпляр не
    приходится ни одной аллокации под значение;
  * партия из УЖЕ созданного объекта даёт ноль — то есть список-держатель
    в измерение действительно не входит.

Времени здесь нет вовсе: байты между версиями сравнивать можно, время — нет
(bench/README.md).

ЗАПУСК:

    for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v bench/slots/memory.py; done
"""

import itertools
import sys
import tracemalloc

BATCH = 200_000

# Значения, которые получат ВСЕ экземпляры. Созданы один раз до всякого
# измерения: иначе в счёт попала бы память под сами значения, а не под
# экземпляры. Маленькие целые ещё и кешируются интерпретатором.
V1, V2, V3 = 1, 2, 3


class Plain:
    """Обычный класс: атрибуты живут в словаре экземпляра."""

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c


class Slotted:
    """Тот же класс со слотами: атрибуты живут в самом экземпляре."""

    __slots__ = ("a", "b", "c")

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c


class SlottedWeak:
    """Те же слоты плюс `__weakref__`: без него слабая ссылка не создаётся."""

    __slots__ = ("a", "b", "c", "__weakref__")

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c


class SlottedBase:
    __slots__ = ("a", "b", "c")

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c


class HeirWithoutSlots(SlottedBase):
    """Подкласс без своих слотов: `__dict__` возвращается, экономия исчезает."""


def per_instance(factory, n=BATCH, touch_dict=False):
    """Байт на экземпляр: вся память, запрошенная при создании партии из n штук.

    Список-держатель отведён до старта tracemalloc и под измерение не попадает,
    поэтому вычитать из суммы нечего (проверка 3 в блоке 6).
    """
    holder = [None] * n  # отведён ЦЕЛИКОМ до замера: не растёт и не аллоцирует
    tracemalloc.start()
    before = tracemalloc.get_traced_memory()[0]
    for i in range(n):
        obj = factory(V1, V2, V3)
        if touch_dict:
            obj.__dict__  # материализует настоящий словарь  # noqa: B018
        holder[i] = obj
    after = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    total = after - before
    assert len(holder) == n
    return total / n


def rule(title):
    print(f"\n=== {title} ===\n")


def block_honest():
    rule("БЛОК 1. ЧЕСТНАЯ ПАМЯТЬ НА ЭКЗЕМПЛЯР")

    plain = per_instance(Plain)
    slotted = per_instance(Slotted)
    touched = per_instance(Plain, touch_dict=True)
    heir = per_instance(HeirWithoutSlots)

    print(f"  партия: {BATCH} экземпляров, класс с тремя атрибутами")
    print()
    print(f"  без __slots__                       {plain:6.1f} Б")
    print(f"  со __slots__                        {slotted:6.1f} Б")
    print(f"  без слотов, после обращения к __dict__  {touched:6.1f} Б")
    print(f"  подкласс класса со слотами, без своих   {heir:6.1f} Б")
    print()
    saved = plain - slotted
    print(f"  экономия                            {saved:6.1f} Б "
          f"({100 * saved / plain:.0f} %)")
    print()
    print("  Третья строка — не экзотика, а обычный путь: любой код, который")
    print("  трогает vars(obj) или obj.__dict__, переводит экземпляр в неё.")
    print("  Четвёртая — ловушка наследования: слоты у базы не наследуются")
    print("  как запрет, и подкласс без своих __slots__ снова получает словарь.")
    return plain, slotted, touched, heir


def block_naive():
    rule("БЛОК 2. ЧТО ПОКАЗЫВАЕТ getsizeof")

    p = Plain(V1, V2, V3)
    s = Slotted(V1, V2, V3)

    gp, gs = sys.getsizeof(p), sys.getsizeof(s)
    print(f"  sys.getsizeof(без слотов)           {gp:6d} Б")
    print(f"  sys.getsizeof(со слотами)           {gs:6d} Б")
    print()
    if gs > gp:
        print(f"  Со слотами БОЛЬШЕ на {gs - gp} байт. По этому числу вывод получается")
        print("  обратный правильному.")
    else:
        print(f"  Разница {gp - gs} Б в пользу слотов — но это всё равно не ответ:")
        print("  см. строку про словарь ниже.")
    print()
    print("  Почему так. getsizeof — это tp_basicsize + tp_itemsize*ob_size:")
    print("  размер САМОГО объекта, без того, на что он ссылается. Словарь")
    print("  экземпляра лежит по ссылке и в это число не входит:")
    print()
    p.__dict__  # материализуем, чтобы было что показать  # noqa: B018
    print(f"    getsizeof(экземпляра без слотов)  {sys.getsizeof(p):6d} Б")
    print(f"    getsizeof(его __dict__)           {sys.getsizeof(p.__dict__):6d} Б")
    print(f"    сумма                             {sys.getsizeof(p) + sys.getsizeof(p.__dict__):6d} Б")
    print()
    print("  Сумма ближе к правде, но ответом не становится, и блок 3 показывает")
    print("  почему.")
    return gp, gs


def block_keysharing():
    rule("БЛОК 3. ПОЧЕМУ СУММА ДВУХ getsizeof ТОЖЕ НЕ ОТВЕТ")

    # ВАЖНО: этот блок обязан идти ДО того, как будут созданы тысячи Plain, —
    # иначе первый экземпляр уже не будет первым. Отсюда собственный класс.
    class Fresh:
        def __init__(self, a, b, c):
            self.a, self.b, self.c = a, b, c

    first = Fresh(V1, V2, V3)
    size_first = sys.getsizeof(first.__dict__)

    keep = []
    for _ in range(1000):
        o = Fresh(V1, V2, V3)
        o.__dict__          # noqa: B018
        keep.append(o)
    size_steady = sys.getsizeof(Fresh(V1, V2, V3).__dict__)

    print("  Сначала — что вообще разделяется при key-sharing. Не словарь:")
    print()
    x, y = Fresh(V1, V2, V3), Fresh(V1, V2, V3)
    dx, dy = x.__dict__, y.__dict__
    print(f"    dx is dy                          {dx is dy}")
    print(f"    dx == dy                          {dx == dy}")
    x.a = 999
    print(f"    после x.a = 999 → dy['a']         {dy['a']}")
    print()
    print("  Словари разные, и запись в один в другом не видна. Разделяется")
    print("  ТАБЛИЦА КЛЮЧЕЙ: имена атрибутов у всех экземпляров класса одни и те")
    print("  же, и хранить их по разу на экземпляр незачем. Значения при этом")
    print("  остаются свои у каждого.")
    print()
    print("  А теперь то, ради чего блок написан: размер словаря экземпляра")
    print("  зависит от того, разделены ключи или ещё нет, — то есть от истории")
    print("  программы, а не от класса:")
    print()
    print(f"    getsizeof(__dict__) у ПЕРВОГО экземпляра класса   {size_first:6d} Б")
    print(f"    он же после тысячи других экземпляров             {size_steady:6d} Б")
    print()
    obj_size = sys.getsizeof(first)
    print("  Один и тот же класс, одни и те же три атрибута. Наивная сумма")
    print("  getsizeof(obj) + getsizeof(obj.__dict__) даёт, соответственно:")
    print()
    print(f"    в начале программы                {obj_size} + {size_first} = {obj_size + size_first} Б")
    print(f"    в установившемся режиме           {obj_size} + {size_steady} = {obj_size + size_steady} Б")
    print()
    measured = per_instance(Plain, touch_dict=True)
    print(f"  А замер партией того же случая даёт   {measured:.0f} Б")
    print()
    hi, lo = obj_size + size_first, obj_size + size_steady
    print(f"  То есть наивная сумма завышает на {hi - measured:.0f} Б в начале и занижает")
    print(f"  на {measured - lo:.0f} Б потом. Один и тот же способ, один и тот же класс,")
    print("  ответ зависит от того, когда его спросили. Поэтому и меряется")
    print("  партия: в ней ключи уже разделены, как и в настоящей программе,")
    print("  где экземпляров много.")
    return size_first, size_steady, obj_size


def block_weakref():
    rule("БЛОК 4. ВТОРОЙ СЛУЧАЙ ТОГО ЖЕ: ЦЕНА `__weakref__`")

    plain_slots = per_instance(Slotted)
    with_weak = per_instance(SlottedWeak)

    print("  Слабая ссылка на экземпляр со слотами не создаётся, пока")
    print("  '__weakref__' не назван в слотах. Сколько это стоит:")
    print()
    print(f"  со слотами                          {plain_slots:6.1f} Б")
    print(f"  со слотами и '__weakref__'          {with_weak:6.1f} Б")
    print(f"  цена слабой ссылки                  {with_weak - plain_slots:6.1f} Б")
    print()
    print(f"  А что показывает basicsize:")
    print(f"    Slotted.__basicsize__             {Slotted.__basicsize__:6d}")
    print(f"    SlottedWeak.__basicsize__         {SlottedWeak.__basicsize__:6d}")
    print(f"    разница                           "
          f"{SlottedWeak.__basicsize__ - Slotted.__basicsize__:6d}")
    print(f"    weaklistoffset                    {SlottedWeak.__weakrefoffset__:6d}")
    print()
    if SlottedWeak.__basicsize__ == Slotted.__basicsize__:
        print("  basicsize не изменился, а память изменилась. Отрицательный")
        print("  weaklistoffset говорит, куда всё уехало: ссылка легла в")
        print("  ПРЕДЗАГОЛОВОК, до начала объекта, и в basicsize она не входит.")
        print("  Это тот же промах, что и с getsizeof в блоке 2, но на другой")
        print("  величине: привычное число молчит, а байты тратятся.")
    else:
        print("  Здесь basicsize ещё честен: ссылка лежит внутри объекта, и")
        print("  разница видна и в нём, и в замере.")


def block_layout():
    rule("БЛОК 5. ГДЕ ЛЕЖАТ АТРИБУТЫ")

    p = Plain(V1, V2, V3)
    s = Slotted(V1, V2, V3)

    print("  со слотами:")
    print(f"    hasattr(obj, '__dict__')          {hasattr(s, '__dict__')}")
    print(f"    тип дескриптора Slotted.a         {type(Slotted.a).__name__}")
    try:
        s.d = 4
    except AttributeError as exc:
        print(f"    obj.d = 4                         AttributeError: {exc}")
    print()
    print("  без слотов:")
    print(f"    hasattr(obj, '__dict__')          {hasattr(p, '__dict__')}")
    print(f"    'a' in vars(obj)                  {'a' in vars(p)}")
    p.d = 4
    print(f"    obj.d = 4                         прошло, vars = {sorted(vars(p))}")
    print()
    print("  Слот — это дескриптор данных НА КЛАССЕ, а значение лежит по")
    print("  фиксированному смещению в самом экземпляре. Отсюда и экономия, и")
    print("  запрет на новые атрибуты: места под них не отведено.")


def block_checks(plain, slotted):
    rule("БЛОК 6. ПРОВЕРКА МЕТОДИКИ")

    print("  1. Величина не должна зависеть от размера партии")
    print()
    small = per_instance(Plain, n=BATCH)
    large = per_instance(Plain, n=BATCH * 2)
    print(f"     без слотов, партия {BATCH}:   {small:6.1f} Б")
    print(f"     без слотов, партия {BATCH * 2}:   {large:6.1f} Б")
    print(f"     расхождение:                    {abs(small - large):6.2f} Б")
    print()

    print("  2. Значения атрибутов не попадают в счёт")
    print()
    # Тот же класс, но каждому экземпляру — СВОИ значения: три новые строки.
    #
    # ПОЧЕМУ ЧЕРЕЗ СЧЁТЧИК, А НЕ "x" * 20. Первый черновик писал именно так — и
    # проверка показывала разницу 0,0 байта, то есть не проверяла ничего:
    # `"x" * 20` из литералов сворачивается на компиляции в константу, и все
    # экземпляры получали ОДИН И ТОТ ЖЕ объект. Строка должна собираться в
    # рантайме из значения, которого на компиляции не существует.
    counter = itertools.count()
    own = per_instance(
        lambda a, b, c: Plain(
            f"value-{next(counter)}",
            f"value-{next(counter)}",
            f"value-{next(counter)}",
        )
    )
    print(f"     общие значения (как в блоке 1):  {plain:6.1f} Б")
    print(f"     три своих строки на экземпляр:   {own:6.1f} Б")
    print(f"     разница — это память под значения: {own - plain:6.1f} Б")
    print("     В блоке 1 значения общие, поэтому там измерены только экземпляры.")
    print()

    print("  3. Список-держатель в измерение не попадает")
    print()
    sentinel = object()
    empty = per_instance(lambda a, b, c: sentinel)
    print(f"     партия из уже созданного объекта: {empty:6.1f} Б на ячейку")
    print("     Ноль — как и должно быть: список отведён целиком до старта")
    print("     tracemalloc, запись в готовую ячейку ничего не аллоцирует.")
    print("     Поэтому из чисел блока 1 ничего не вычитается.")
    print()

    print("  4. Слоты и словарь — величины разного порядка")
    print()
    print(f"     без слотов {plain:.1f} Б, со слотами {slotted:.1f} Б, "
          f"отношение {plain / slotted:.2f}")


def main():
    print("__SLOTS__: СКОЛЬКО ЭКОНОМИТ НА САМОМ ДЕЛЕ")
    print()
    print(f"Python {sys.version.split()[0]}, {sys.implementation.name}")
    print(f"Сборка: {sys.version.split('[', 1)[1].rstrip(']') if '[' in sys.version else '?'}")
    print()
    print("Байты между версиями сравнивать можно: это раскладка объекта, а не")
    print("время. Время в этом файле не меряется вовсе.")

    plain, slotted, touched, heir = block_honest()
    block_naive()
    ks = block_keysharing()
    block_weakref()
    block_layout()
    block_checks(plain, slotted)

    print()
    print("Все строки выше напечатаны этой программой.")


if __name__ == "__main__":
    main()