Deep Engineering

ЗАМЕР

bench/slots/access.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/memory/slots
Как запустить
Записи прогонов — в `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`:

Запись прогона

Замеры: __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. Байты между версиями сравнивать можно — это раскладка объекта. Время нельзя: у сборок разные компиляторы и разные флаги.

Скрипт

280 строк
"""Ускоряет ли `__slots__` доступ к атрибуту.

ЗАЧЕМ ЭТОТ СКРИПТ. «Слоты ещё и быстрее» — второе по частоте утверждение о
теме после «слоты экономят память». Первое верно и измерено в `memory.py`.
Второе проверяется здесь, и результат у него другой.

ЧТО СРАВНИВАЕТСЯ. Чтение и запись атрибута у трёх экземпляров одного и того же
класса по форме: со слотами, без слотов (значения лежат inline, если версия
это умеет) и без слотов после обращения к `__dict__` — то есть когда inline
values уже развернулись в настоящий словарь. Третий случай важен: именно в него
попадает всякий код, который трогал `vars(obj)`.

ПРАВИЛО ЗАМЕРА. Всё меряется ВНУТРИ ОДНОГО запуска ОДНОГО интерпретатора, и
сравниваются только числа из одного прогона (bench/README.md). Между версиями
время не сравнивается вовсе: у сборок песочницы разные компиляторы и разные
флаги, и разделить «стал быстрее язык» и «стала быстрее сборка» нечем.

МЕТОДИКА. `timeit`, лучшее из N раундов. Берётся лучшее, а не среднее: шум на
этой машине односторонний — соседний процесс может отнять время, но не может
его добавить.

ПОЧЕМУ ОПЕРАЦИЯ ПОВТОРЯЕТСЯ ВНУТРИ ЗАМЕРА. Первый черновик мерил ровно одно
`o.a` за виток цикла `timeit` и получил числа, из которых следовало «разница
есть, но маленькая». Проверка 2 показала, чего они стоят: пустой оператор
`pass` в том же цикле стоит почти столько же, сколько сам доступ. То есть
мерился цикл, а не доступ, и любая «разница» между двумя такими числами — его
шум.

Поэтому оператор повторяется `REPEAT` раз внутри одного витка, и накладные
расходы цикла делятся на столько же. Проверка 2 печатает ОБА режима рядом —
амортизированный и однооперационный, — чтобы это не оставалось на слово: видно
и дно каждого, и какая доля числа в неамортизированном режиме приходится на сигнал.

ЗАПУСК:

    python3.13 bench/slots/access.py
"""

import dis
import io
import sys
import timeit

ROUNDS = 7
INNER = 20_000

# Сколько раз операция повторяется ВНУТРИ одного витка цикла timeit. Делит
# накладные расходы витка на себя: проверка 2 печатает дно в обоих режимах,
# и разница между ними — это и есть цена самого цикла.
REPEAT = 50


class Plain:
    def __init__(self):
        self.a = 1
        self.b = 2
        self.c = 3


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

    def __init__(self):
        self.a = 1
        self.b = 2
        self.c = 3


def best(stmt, setup, rounds=ROUNDS, inner=INNER, repeat=REPEAT):
    """Наносекунды на одну операцию.

    Оператор повторяется `repeat` раз в одном витке, поэтому накладные расходы
    цикла timeit делятся на `repeat` (см. «ПОЧЕМУ ОПЕРАЦИЯ ПОВТОРЯЕТСЯ» выше).
    """
    body = "\n".join([stmt] * repeat)
    times = timeit.repeat(body, setup=setup, repeat=rounds, number=inner,
                          globals=globals())
    return min(times) / (inner * repeat) * 1e9


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


def block_read():
    rule("БЛОК 1. ЧТЕНИЕ АТРИБУТА")

    slotted = best("o.a", "o = Slotted()")
    plain = best("o.a", "o = Plain()")
    materialized = best("o.a", "o = Plain(); o.__dict__")

    print(f"  наносекунд на одно чтение o.a, лучшее из {ROUNDS} раундов "
          f"по {INNER}×{REPEAT}:")
    print()
    print(f"  со слотами                          {slotted:6.2f} нс")
    print(f"  без слотов                          {plain:6.2f} нс")
    print(f"  без слотов, после vars(obj)         {materialized:6.2f} нс")
    print()
    diff = plain - slotted
    print(f"  разница слоты против inline:        {diff:+6.2f} нс "
          f"({100 * diff / plain:+.1f} %)")
    return slotted, plain, materialized


def block_write():
    rule("БЛОК 2. ЗАПИСЬ АТРИБУТА")

    slotted = best("o.a = 5", "o = Slotted()")
    plain = best("o.a = 5", "o = Plain()")
    materialized = best("o.a = 5", "o = Plain(); o.__dict__")

    print("  наносекунд на одну запись o.a = 5:")
    print()
    print(f"  со слотами                          {slotted:6.2f} нс")
    print(f"  без слотов                          {plain:6.2f} нс")
    print(f"  без слотов, после vars(obj)         {materialized:6.2f} нс")
    return slotted, plain, materialized


def block_verdict(read, write):
    rule("БЛОК 3. ЧТО ИЗ ЭТОГО СЛЕДУЕТ")

    r_slot, r_plain, r_mat = read
    w_slot, w_plain, w_mat = write

    print("  Утверждение, которое проверялось: «__slots__ ускоряет доступ».")
    print()
    for name, slot, plain in (("чтение", r_slot, r_plain), ("запись", w_slot, w_plain)):
        rel = 100 * (plain - slot) / plain
        if abs(rel) < 5:
            verdict = "различие в пределах шума метода"
        elif rel > 0:
            verdict = f"слоты быстрее на {rel:.0f} %"
        else:
            verdict = f"слоты МЕДЛЕННЕЕ на {-rel:.0f} %"
        print(f"    {name:<8} слоты {slot:6.2f} нс, inline {plain:6.2f} нс — {verdict}")
    print()
    print("  Разница между слотами и inline values мала, потому что это два")
    print("  способа сделать одно и то же: значение по фиксированному смещению")
    print("  в самом объекте. Слот адресуется дескриптором, inline value —")
    print("  через кеш ключей, и обе дороги короткие.")
    print()
    print("  А вот третья строка блоков 1 и 2 — не шум:")
    for name, mat, plain in (("чтение", r_mat, r_plain), ("запись", w_mat, w_plain)):
        rel = 100 * (mat - plain) / plain
        print(f"    {name:<8} после vars(obj) {mat:6.2f} нс против {plain:6.2f} — "
              f"{rel:+.0f} %")
    print()
    print("  То есть обращение к __dict__ бьёт и по памяти (memory.py, блок 1),")
    print("  и по времени. Из этих двух замеров и складывается практический")
    print("  вывод темы: выигрыш слотов — в памяти, а не в скорости, и главный")
    print("  способ его потерять — тронуть vars(obj).")


def specialization(obj):
    """Во что превращается прогретое `o.a` после специализации.

    Печатает имя опкода из адаптивного дизассемблера. Это не украшение: без
    него замер читается как «дескриптор против словаря», а на самом деле
    сравниваются два РАЗНЫХ специализированных опкода, и оба — загрузка по
    фиксированному смещению. Отсюда и равенство времён.
    """
    g = {}
    exec("def f(o):\n    return o.a\n", g)  # noqa: S102
    f = g["f"]
    for _ in range(3000):
        f(obj)
    buf = io.StringIO()
    dis.dis(f, file=buf, adaptive=True)
    for line in buf.getvalue().splitlines():
        for tok in line.split():
            if tok.startswith("LOAD_ATTR"):
                return tok
    return "?"


def block_specialization():
    rule("БЛОК 4. ВО ЧТО СПЕЦИАЛИЗИРУЕТСЯ ПРОГРЕТОЕ ЧТЕНИЕ")

    plain = Plain()
    slotted = Slotted()
    touched = Plain()
    touched.__dict__  # noqa: B018

    print("  Замер выше меряет ПРОГРЕТУЮ операцию, а прогретую операцию в")
    print("  современном CPython исполняет не общий опкод, а специализированный.")
    print("  Вот какой достаётся каждому случаю:")
    print()
    print(f"    со слотами                        {specialization(slotted)}")
    print(f"    без слотов                        {specialization(plain)}")
    print(f"    без слотов, после vars(obj)       {specialization(touched)}")
    print()
    print("  Отсюда видно, что именно сравнивалось. Не «дескриптор против")
    print("  словаря»: обе первые строки — специализированные опкоды, и каждый")
    print("  читает значение по фиксированному смещению в объекте. Разными")
    print("  путями пришли к одной и той же работе, поэтому и времена равны.")
    print()
    print("  Третья строка объясняет третий столбец замера: после vars(obj)")
    print("  специализация другая, а на некоторых сборках её нет вовсе — тогда")
    print("  работает общий LOAD_ATTR со всем протоколом поиска атрибута.")
    print()
    print("  Практический вывод: этот замер отвечает не на вопрос «какой")
    print("  механизм короче в принципе», а на вопрос «сколько стоит прогретая")
    print("  операция в этой сборке после оптимизаций интерпретатора».")


def block_method():
    rule("БЛОК 5. ПРОВЕРКА МЕТОДА")

    print("  1. Разброс между раундами — насколько числам выше можно верить")
    print()
    body = "\n".join(["o.a"] * REPEAT)
    times = timeit.repeat(body, setup="o = Slotted()", repeat=ROUNDS,
                          number=INNER, globals=globals())
    ns = sorted(t / (INNER * REPEAT) * 1e9 for t in times)
    print(f"     лучшее   {ns[0]:6.2f} нс")
    print(f"     медиана  {ns[len(ns) // 2]:6.2f} нс")
    print(f"     худшее   {ns[-1]:6.2f} нс")
    print(f"     худшее больше лучшего на {100 * (ns[-1] - ns[0]) / ns[0]:.0f} %")
    print()
    print("     Вот почему берётся лучшее и почему вывод строится на")
    print("     кратностях, а не на абсолютных числах.")
    print()

    print("  2. Пустой оператор — сколько стоит сам цикл timeit")
    print()
    empty = best("pass", "pass")
    print(f"     амортизированный режим (REPEAT = {REPEAT}):  {empty:6.3f} нс")
    print()
    print("     А вот то же самое БЕЗ амортизации — так мерил первый черновик,")
    print("     и так делают почти все замеры доступа к атрибуту:")
    print()
    raw_empty = best("pass", "pass", repeat=1)
    raw_slot = best("o.a", "o = Slotted()", repeat=1)
    raw_plain = best("o.a", "o = Plain()", repeat=1)
    print(f"     pass, одна операция за виток:    {raw_empty:6.2f} нс")
    print(f"     o.a со слотами, одна за виток:   {raw_slot:6.2f} нс")
    print(f"     o.a без слотов, одна за виток:   {raw_plain:6.2f} нс")
    print()
    print(f"     Полезного сигнала здесь {100 * (raw_slot - raw_empty) / raw_slot:.0f} % от числа: всё остальное —")
    print("     цикл. «Разница» между двумя последними строками лежит внутри")
    print("     его шума, и любой вывод из неё ничего не стоит.")
    print()
    print(f"     После амортизации дно {empty:.2f} нс против измеряемых восьми —")
    print("     на порядок ниже, как и должно быть.")
    print()

    print("  3. Разные атрибуты одного объекта дают одно и то же")
    print()
    for attr in ("a", "b", "c"):
        print(f"     o.{attr} со слотами                 "
              f"{best(f'o.{attr}', 'o = Slotted()'):6.2f} нс")
    print("     Если бы числа разъезжались, мерился бы не доступ, а порядок")
    print("     слотов или попадание в кеш.")


def main():
    print("__SLOTS__ И СКОРОСТЬ ДОСТУПА К АТРИБУТУ")
    print()
    print(f"Python {sys.version.split()[0]}")
    build = sys.version.split("[", 1)[1].rstrip("]") if "[" in sys.version else "?"
    print(f"Сборка: {build}")
    print()
    print("Числа этого файла сравниваются ТОЛЬКО между собой: они получены")
    print("одним процессом на одной сборке. Между версиями время не")
    print("сравнивается (bench/README.md).")

    read = block_read()
    write = block_write()
    block_verdict(read, write)
    block_specialization()
    block_method()

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


if __name__ == "__main__":
    main()