MEASUREMENT
bench/slots/access.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
280 lines"""Ускоряет ли `__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()