ЗАМЕР
bench/attr-lookup/mro_cache.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/runtime/attribute-lookup
- Как запустить
У первых двух скриптов времени нет вовсе: сравниваются значения и последовательности вызовов, а адреса объектов заменены на `0x...` ради воспроизводимости прогона. Время появилось позже, вместе с двумя новыми скриптами, — и про него ниже отдельный раздел. ## Время: что меряется и чему верить Записи прогонов лежат в `runs/`, по файлу на скрипт и версию. **Чередование обязательно.** Формы меряются не подряд, а кругами: в каждом круге проходят все конфигурации, минимум для каждой берётся по кругам. Причина не теоретическая. Пока замеры шли подряд, отношение между формами гуляло в полтора раза от запуска к запуску — просадка машины в окне одной формы целиком доставалась ей. Измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках одного и того же замера. **Кратности от малых величин не печатаются.** Частное, у которого в знаменателе стоит величина порядка пятнадцати наносекунд, шумит вдвое и не измеряет ничего: на двух подряд запусках `mro_cache.py` оно дало 7,3 и 10,7 при одинаковом наклоне. Поэтому оба скрипта печатают времена и разности, а утверждения статьи строятся на наклоне, который устойчив. **Что показал `mro_cache.py`.** При живом кеше время обращения не зависит от длины `__mro__` — наклон по крайним точкам укладывается в сотые доли наносекунды на уровень при разбросе самих значений в пару наносекунд. Стоит сбивать кеш присваиванием в класс на каждой итерации, и появляется линейный рост: 3,31 нс на уровень на 3.13.7 и 2,14 на 3.14.7 (наклон воспроизводится между запусками, сами времена — только на нижней границе). Цена самого присваивания измеряется отдельной строкой и вычитается — иначе замер показывал бы стоимость `setattr`, а не промаха кеша. **Что показал `mro_override.py`, и это главное здесь.** Метакласс, который переопределяет `mro()` — даже возвращая ровно `super().mro()`, то есть не меняя в порядке ничего, — на 3.14.7 лишает свой класс кеша атрибутов типа. Обращение к унаследованному атрибуту начинает расти вместе с длиной `__mro__`: 15,3 / 63,8 / 118,4 нс при длинах 2 / 21 / 51 против ровных 6,4 нс у обычного типа. На длине 51 это в восемнадцать раз дороже. На 3.12.3 и 3.13.7 эффекта нет: там все три конфигурации ровные. Средняя строка таблицы — метакласс БЕЗ `mro()` — стоит в замере именно для того, чтобы эффект нельзя было списать на метакласс вообще: она ровная на всех трёх версиях. **Эффект наследуется, и это его практическая цена.** Достаточно одного класса с переопределённым `mro()` где-нибудь в основании иерархии: наследник объявляется обычным `class Sub(Base)`, метакласс Python выводит из баз сам, и кеша лишается тот, кто о метаклассе даже не знает. Замер: 118,7 нс при длине 51, то есть ровно столько же, сколько у самого корня. Со стороны исходников это видно так. Поле `tp_versions_used` появилось у типа в 3.13 — голым, без комментария. В 3.14 рядом с ним встала константа `_Py_ATTR_CACHE_UNUSED (30000)` и объяснение: это значение ставится, «if the attribute cache is disabled for this type (e.g. due to custom MRO entries)». Скрипт печатает оба объявления из заголовков того интерпретатора, на котором запущен, поэтому проверить утверждение о C-уровне можно, не выходя из своей системы. В стандартной библиотеке `mro()` не переопределён нигде: `grep "def mro("` по `Lib/` даёт ноль совпадений. Это приём стороннего кода. **Что показал `bound_method.py`.** `obj.m` — взять атрибут и не вызывать — стоит ДОРОЖЕ, чем `obj.m()` целиком: 24,6 против 16,3 нс на 3.13.7. Объект связанного метода при слитной записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его кладут в переменную. **Что показал `mro_shape.py`.** Времени там нет, только поведение, и оно одинаково на 3.12.3, 3.13.7 и 3.14.7, кроме последнего пункта. Ромб `class D(L, R)` даёт `['D', 'L', 'R', 'Base', 'object']`, и `D().m()` возвращает `'R'`: общий предок уходит в хвост, а не «сначала вся ветка L». Отказ C3 звучит дословно так: `Cannot create a consistent method resolution order (MRO) for bases X, Y`. У встроенных типов `__mro__` короткий — два-четыре элемента, — поэтому длины 21 и 51 в замерах кеша это модель, а не типичный код. Все шесть видов членов класса оказываются дескрипторами: у типа каждого есть `__get__`, и разделение на данных и не данных проходит по `__set__`. Единственное расхождение по версиям — `functools.partial` в теле класса. На 3.13.7 он ещё обычный атрибут и предупреждает: «functools.partial will be a method descriptor in future Python versions; wrap it in staticmethod() if you want to preserve the old behavior». На 3.14.7 он уже метод-дескриптор, и тот же код падает с `TypeError: two() takes 2 positional arguments but 3 were given`. Объект связанного метода при слитной записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его кладут в переменную. ## Версии **Вывод обоих скриптов одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7** — различается только строка `PY`. Различий по версиям нет ни одного, и это самостоятельный результат: правило приоритета не менялось, несмотря на то что `_PyObject_GenericGetAttrWithDict` в C между 3.13 и 3.14 переписывался (см. ниже). ## Псевдокод и настоящий поиск
Запись прогона
Замеры: где в поиске атрибута сидит протокол дескрипторов
Два сюжета, которых нет в bench/descriptors/: питоновский эквивалент
object.__getattribute__, запущенный рядом с настоящим, и разница между
поиском у экземпляра и поиском у класса — то есть дескриптор на метаклассе.
| скрипт | что показывает |
|---|---|
getattribute_equivalent.py |
псевдокод из Doc/howto/descriptor.rst даёт те же ответы, что точка; три ветки приоритета; в псевдокоде нет __getattr__ |
metaclass_descriptor.py |
дескриптор метакласса виден у класса и не существует у экземпляра; дескриптор данных метакласса перекрывает __dict__ класса |
mro_cache.py |
цена o.m при четырёх длинах __mro__ в двух режимах: кеш версий типа цел и сбивается на каждой итерации |
bound_method.py |
во что обходится объект связанного метода и во что — super() |
mro_override.py |
переопределённый mro() и кеш атрибутов типа: на 3.14 он выключается, на 3.12 и 3.13 нет |
mro_shape.py |
форма MRO: ромб, отказ C3 дословным текстом, длины у настоящих типов, кто из членов класса дескриптор, functools.partial в теле класса |
getattr_hook.py |
кто на самом деле зовёт __getattr__: делегирование в super() его НЕ отключает, прямой вызов обходит, поглощение исключения отключает |
descriptor_kinds.py |
классификация дескриптора данных (__set__ или __delete__) против приоритета при чтении (нужен ещё __get__) — и строка, где они расходятся |
mro_stdlib_scan.py |
разбором AST: сколько модулей стандартной библиотеки этой сборки переопределяют mro() |
Запуск:
for v in 3.11 3.12 3.13 3.14; do
echo "== $v"; python$v bench/attr-lookup/getattribute_equivalent.py
done
У первых двух скриптов времени нет вовсе: сравниваются значения и
последовательности вызовов, а адреса объектов заменены на 0x... ради
воспроизводимости прогона. Время появилось позже, вместе с двумя новыми
скриптами, — и про него ниже отдельный раздел.
Время: что меряется и чему верить
Записи прогонов лежат в runs/, по файлу на скрипт и версию.
Чередование обязательно. Формы меряются не подряд, а кругами: в каждом круге проходят все конфигурации, минимум для каждой берётся по кругам. Причина не теоретическая. Пока замеры шли подряд, отношение между формами гуляло в полтора раза от запуска к запуску — просадка машины в окне одной формы целиком доставалась ей. Измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках одного и того же замера.
Кратности от малых величин не печатаются. Частное, у которого в
знаменателе стоит величина порядка пятнадцати наносекунд, шумит вдвое и не
измеряет ничего: на двух подряд запусках mro_cache.py оно дало 7,3 и 10,7
при одинаковом наклоне. Поэтому оба скрипта печатают времена и разности, а
утверждения статьи строятся на наклоне, который устойчив.
Что показал mro_cache.py. При живом кеше время обращения не зависит от
длины __mro__ — наклон по крайним точкам укладывается в сотые доли
наносекунды на уровень при разбросе самих значений в пару наносекунд. Стоит
сбивать кеш присваиванием в класс на каждой итерации, и появляется линейный
рост: 3,31 нс на уровень на 3.13.7 и 2,14 на 3.14.7 (наклон воспроизводится
между запусками, сами времена — только на нижней границе). Цена самого присваивания измеряется отдельной строкой и вычитается —
иначе замер показывал бы стоимость setattr, а не промаха кеша.
Что показал mro_override.py, и это главное здесь. Метакласс, который
переопределяет mro() — даже возвращая ровно super().mro(), то есть не меняя
в порядке ничего, — на 3.14.7 лишает свой класс кеша атрибутов типа. Обращение
к унаследованному атрибуту начинает расти вместе с длиной __mro__:
15,3 / 63,8 / 118,4 нс при длинах 2 / 21 / 51 против ровных 6,4 нс у обычного
типа. На длине 51 это в восемнадцать раз дороже. На 3.12.3 и 3.13.7 эффекта
нет: там все три конфигурации ровные.
Средняя строка таблицы — метакласс БЕЗ mro() — стоит в замере именно для
того, чтобы эффект нельзя было списать на метакласс вообще: она ровная на всех
трёх версиях.
Эффект наследуется, и это его практическая цена. Достаточно одного класса
с переопределённым mro() где-нибудь в основании иерархии: наследник
объявляется обычным class Sub(Base), метакласс Python выводит из баз сам, и
кеша лишается тот, кто о метаклассе даже не знает. Замер: 118,7 нс при длине 51,
то есть ровно столько же, сколько у самого корня.
Со стороны исходников это видно так. Поле tp_versions_used появилось у типа
в 3.13 — голым, без комментария. В 3.14 рядом с ним встала константа
_Py_ATTR_CACHE_UNUSED (30000) и объяснение: это значение ставится, «if the
attribute cache is disabled for this type (e.g. due to custom MRO entries)».
Скрипт печатает оба объявления из заголовков того интерпретатора, на котором
запущен, поэтому проверить утверждение о C-уровне можно, не выходя из своей
системы.
В стандартной библиотеке mro() не переопределён нигде: grep "def mro(" по
Lib/ даёт ноль совпадений. Это приём стороннего кода.
Что показал bound_method.py. obj.m — взять атрибут и не вызывать —
стоит ДОРОЖЕ, чем obj.m() целиком: 24,6 против 16,3 нс на 3.13.7. Объект
связанного метода при слитной записи не создаётся вовсе, а при раздельной
обязан быть создан, потому что его кладут в переменную.
Что показал mro_shape.py. Времени там нет, только поведение, и оно
одинаково на 3.12.3, 3.13.7 и 3.14.7, кроме последнего пункта. Ромб
class D(L, R) даёт ['D', 'L', 'R', 'Base', 'object'], и D().m()
возвращает 'R': общий предок уходит в хвост, а не «сначала вся ветка L».
Отказ C3 звучит дословно так: Cannot create a consistent method resolution order (MRO) for bases X, Y. У встроенных типов __mro__ короткий — два-четыре
элемента, — поэтому длины 21 и 51 в замерах кеша это модель, а не типичный код.
Все шесть видов членов класса оказываются дескрипторами: у типа каждого есть
__get__, и разделение на данных и не данных проходит по __set__.
Единственное расхождение по версиям — functools.partial в теле класса. На
3.13.7 он ещё обычный атрибут и предупреждает: «functools.partial will be a
method descriptor in future Python versions; wrap it in staticmethod() if you
want to preserve the old behavior». На 3.14.7 он уже метод-дескриптор, и тот же
код падает с TypeError: two() takes 2 positional arguments but 3 were given. Объект связанного метода при слитной
записи не создаётся вовсе, а при раздельной обязан быть создан, потому что его
кладут в переменную.
Версии
Вывод обоих скриптов одинаков на 3.11.15, 3.12.3, 3.13.7 и 3.14.7 —
различается только строка PY. Различий по версиям нет ни одного, и это
самостоятельный результат: правило приоритета не менялось, несмотря на то что
_PyObject_GenericGetAttrWithDict в C между 3.13 и 3.14 переписывался (см. ниже).
Псевдокод и настоящий поиск
$ python3.13 getattribute_equivalent.py
PY 3.13.7
1) в __dict__ экземпляра лежат все четыре имени: ['data', 'nondata', 'own', 'plain']
2) точка против псевдокода — одно и то же значение:
c.data = значение дескриптора данных
псевдокод = значение дескриптора данных совпало: True
c.nondata = ЭКЗЕМПЛЯР nondata
псевдокод = ЭКЗЕМПЛЯР nondata совпало: True
c.plain = ЭКЗЕМПЛЯР plain
псевдокод = ЭКЗЕМПЛЯР plain совпало: True
c.own = только у экземпляра
псевдокод = только у экземпляра совпало: True
c.method = <bound method C.method of <__main__.C object at 0x...>>
псевдокод = <bound method C.method of <__main__.C object at 0x...>> совпало: True
3) какая ветка псевдокода сработала:
data -> ветка 1 (data descriptor): __dict__ экземпляра ПРОИГНОРИРОВАН
nondata -> ветка 2 (instance variable): __dict__ экземпляра ПОБЕДИЛ
plain -> ветка 2 (instance variable)
own -> ветка 2 (instance variable)
method -> ветка 3 (non-data descriptor), функция сама дескриптор: True | __set__: False
4) вызовы __get__ НАШИХ дескрипторов (Data/NonData) — точка и псевдокод:
data точка=['Data.__get__'] псевдокод=['Data.__get__']
nondata точка=— псевдокод=—
plain точка=— псевдокод=—
own точка=— псевдокод=—
method точка=— псевдокод=—
5) точка зовёт __getattr__ после AttributeError: __getattr__(missing)
псевдокод (и object.__getattribute__ напрямую) -> AttributeError: missing
object.__getattribute__(w, 'missing') -> AttributeError: 'WithFallback' object has no attribute 'missing'
6) instance lookup против class lookup у одного и того же дескриптора:
D().x -> obj=<__main__.D object at 0x...>, objtype=D
D.x -> obj=None, objtype=D
вызовы (obj is None, objtype): [(False, 'D'), (True, 'D')]
документация: object.__getattribute__ передаёт экземпляр,
type.__getattribute__ подставляет None вместо экземпляра
Пункт 4 читается вместе с пунктом 3: __get__ дескриптора данных вызывается
до того, как поиск вообще посмотрит в __dict__ экземпляра, и это видно по
единственному вызову в строке data. У nondata вызова нет вовсе — до третьей
ветки дело не дошло, потому что вторая уже вернула значение.
Пункт 5 — про то, чего в псевдокоде нет: ветки __getattr__. Она живёт этажом
выше, в операторе точки; сам object.__getattribute__ её не знает.
Первоисточник
Doc/howto/descriptor.rst, строки 583–611 (тег v3.14.5) — код в
getattribute_equivalent.py скопирован дословно, включая комментарии:
The logic for a dotted lookup is in
object.__getattribute__. Here is a pure Python equivalent:
Перевод: «Логика поиска через точку находится в object.__getattribute__. Вот
её эквивалент на чистом Python:»
def find_name_in_mro(cls, name, default):
"Emulate _PyType_Lookup() in Objects/typeobject.c"
for base in cls.__mro__:
if name in vars(base):
return vars(base)[name]
return default
def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = find_name_in_mro(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
Там же, строки 722–727, про отсутствующую ветку:
Note, there is no
__getattr__hook in the__getattribute__code. That is why calling__getattribute__directly or withsuper().__getattribute__will bypass__getattr__entirely. Instead, it is the dot operator and thegetattr()function that are responsible for invoking__getattr__whenever__getattribute__raises anAttributeError.
Перевод: «Заметьте, в коде __getattribute__ нет крючка __getattr__. Именно
поэтому прямой вызов __getattribute__ или вызов через
super().__getattribute__ полностью обходит __getattr__. Вместо этого за
вызов __getattr__ отвечают оператор точки и функция getattr() — всякий раз,
когда __getattribute__ возбуждает AttributeError.»
И строки 806–829, итоговая формулировка:
The mechanism for descriptors is embedded in the
__getattribute__methods forobject,type, andsuper. […]object.__getattribute__andtype.__getattribute__make different calls to__get__. The first includes the instance and may include the class. The second puts inNonefor the instance and always includes the class.
Перевод: «Механизм дескрипторов встроен в методы __getattribute__ у object,
type и super. […] object.__getattribute__ и type.__getattribute__
делают разные вызовы __get__. Первый передаёт экземпляр и может передать
класс. Второй подставляет None вместо экземпляра и всегда передаёт класс.»
Пункт 6 замера — прямая проверка этого абзаца.
C-исходник
Objects/object.c, функция _PyObject_GenericGetAttrWithDict. Порядок ветвей
там тот же, но номера строк по версиям разные, и сам код между 3.13 и 3.14
переписан:
тег v3.13.7 |
тег v3.14.5 |
|
|---|---|---|
| начало функции | строка 1632 | строка 1809 |
| поиск по типу | descr = _PyType_LookupRef(tp, name); (1659) |
_PyType_LookupStackRefAndVersion(tp, name, &cref.ref); (1841) |
| проверка на дескриптор данных | if (f != NULL && PyDescr_IsData(descr)) (1664) |
то же (1847) |
| словарь экземпляра | PyDict_GetItemRef(dict, name, &res) (1705) |
то же (1888) |
| дескриптор не-данных | if (f != NULL) { res = f(descr, obj, ...) } (1720) |
то же (1903) |
| переменная класса | if (descr != NULL) { res = descr; } (1729–1730) |
if (descr != NULL) { res = PyStackRef_AsPyObjectSteal(cref.ref); } (1912–1913) |
AttributeError |
1735–1741 | 1918–1924 |
Изменились работа со ссылками (PyStackRef) и способ обращения к кешу типов —
порядок четырёх ветвей не изменился, и вывод обоих скриптов на 3.13 и 3.14
совпадает построчно.
Дескриптор на метаклассе
$ python3.13 metaclass_descriptor.py
PY 3.13.7
1) __set_name__ вызывается только у дескрипторов в теле СВОЕГО класса:
__set_name__(на-метаклассе, owner=Meta, name=on_meta)
__set_name__(на-классе, owner=C, name=on_class)
для Loud на метаклассе owner=Meta, для Loud на классе owner=C
2) C.on_meta -> <на-метаклассе>
на-метаклассе.__get__(obj=класс C, objtype=Meta)
objtype=Meta: поиск шёл по type(C).__mro__, то есть по MRO метакласса
3) C().on_meta -> AttributeError: 'C' object has no attribute 'on_meta'
вызовов __get__: нет
поиск шёл по type(c).__mro__ = C.__mro__, а метакласса там нет:
C.__mro__ : ['C', 'object']
type(C).__mro__: ['Meta', 'type', 'object']
4) C().on_class -> <на-классе>
на-классе.__get__(obj=экземпляр C, objtype=C)
C.on_class -> <на-классе>
на-классе.__get__(obj=None, objtype=C)
у экземпляра obj — сам экземпляр, у класса obj=None: это и есть разница вызовов
5) C.on_meta = 'новое' -> ["на-метаклассе.__set__('новое')"]
значение НЕ попало в C.__dict__: False
c.on_class = 'новое' -> ["на-классе.__set__('новое')"]
значение НЕ попало в c.__dict__: False
6) дескриптор не-данных на метаклассе:
E.nd -> <не-данных на метаклассе>
после E.nd = 'перекрыто' -> перекрыто | в E.__dict__: True
__dict__ КЛАССА перекрывает дескриптор не-данных метакласса,
ровно как __dict__ экземпляра перекрывает дескриптор не-данных класса
7) имя `both` объявлено и на метаклассе, и на классе:
F.both -> <метакласс>
F().both -> <класс>
метакласс.__get__(obj=класс F, objtype=Meta2)
класс.__get__(obj=экземпляр F, objtype=F)
F.both ВЫИГРАЛ у F.__dict__['both']: Loud — дескриптор ДАННЫХ,
а дескриптор данных на type(F) перекрывает словарь самого F —
тем же правилом, каким дескриптор данных класса перекрывает vars(экземпляра).
F.__dict__['both'] при этом никуда не делся: Loud
8) итог поиска:
x.attr -> ищется в type(x).__mro__ и в vars(x)
C.attr -> ищется в type(C).__mro__ (метаклассы) и в C.__mro__
type(c) = C | type(C) = Meta | type(Meta) = type
Три вещи, ради которых написан файл.
Пункт 3 — самая частая ошибка ожидания: дескриптор на метаклассе у экземпляра
не просто не срабатывает, его там нет. AttributeError, ноль вызовов
__get__. Причина в одной строке псевдокода: objtype = type(obj). Для c
это C, и в C.__mro__ метакласса нет.
Пункт 7 — то же правило приоритета, поднятое на этаж выше. F.both — это
поиск атрибута у объекта F, тип которого Meta2; Loud на Meta2 —
дескриптор данных, поэтому он перекрывает F.__dict__['both'] точно так же,
как дескриптор данных класса перекрывает vars(экземпляра).
Пункт 6 — зеркальный случай: дескриптор не-данных на метаклассе перекрывается словарём класса. Пара 6/7 показывает, что правило приоритета одно, а «уровней» два, и оно применяется на обоих.
Первоисточник class lookup — Doc/howto/descriptor.rst, строки 776–786
(тег v3.14.5):
The logic for a dotted lookup such as
A.xis intype.__getattribute__. The steps are similar to those forobject.__getattribute__but the instance dictionary lookup is replaced by a search through the class's method resolution order. If a descriptor is found, it is invoked withdesc.__get__(None, A). The full C implementation can be found intype_getattro()and_PyType_Lookup()inObjects/typeobject.c.
Перевод: «Логика поиска через точку вида A.x находится в
type.__getattribute__. Шаги похожи на шаги object.__getattribute__, но
поиск в словаре экземпляра заменён поиском по порядку разрешения методов
класса. Если дескриптор найден, он вызывается как desc.__get__(None, A).
Полную реализацию на C можно найти в type_getattro() и _PyType_Lookup() в
Objects/typeobject.c.»
Оговорка про версию
3.14 здесь — это 3.14.7. Ни одна строка вывода на 3.14 не отличается
от 3.13.7, поэтому «поведение не изменилось» — это наблюдение с rc, а не с
финального релиза. Номера строк в C-исходнике взяты с тегов v3.13.7 и
v3.14.5, цитаты из Doc/ — с тега v3.14.5.
Скрипт
101 строк"""Кеш версий типа: сколько стоит `o.m`, пока кеш цел, и сколько — когда сбит.
ЧТО ЗДЕСЬ ГЛАВНОЕ. Одно и то же обращение `o.m` меряется при четырёх длинах
`__mro__` в двух режимах. В первом ничего не трогается, и кеш живёт. Во втором
на каждой итерации в класс что-то присваивается — этого достаточно, чтобы
`PyType_Modified` сбросил `tp_version_tag`, и следующий поиск снова прошёл всю
цепочку наследования.
Утверждение, ради которого замер сделан: **при живом кеше время не зависит от
глубины MRO, а без кеша растёт вместе с ней.** Это и есть ответ на вопрос,
зачем в языке с кешем вообще думать про порядок наследования: он бесплатен,
пока никто не правит классы во время работы.
ПОЧЕМУ КРУГОВ МНОГО, А КАЖДЫЙ КОРОТКИЙ. Машина, на которой снимаются числа,
время от времени проседает на секунды. Длинный круг такую просадку проглатывает
целиком, и минимум по кругам оказывается выше настоящего. Сто коротких кругов
дают сто шансов попасть в спокойное окно, и нижняя граница воспроизводится:
два запуска подряд сходятся до десятых долей наносекунды. Полностью просевший
запуск виден сразу — в нём подняты ВСЕ формы, а отношения между ними те же.
ПОЧЕМУ ЗАМЕРЫ ЧЕРЕДУЮТСЯ. Мерить конфигурации одну за другой нельзя: просадка
машины, случившаяся в окне одной, целиком достаётся ей, и отношение между
режимами гуляет от запуска к запуску. На декораторах это измерено — 5,9 / 7,1 /
7,5 / 9,2 на четырёх запусках подряд. Здесь в каждом круге меряются все восемь
конфигураций, минимум для каждой берётся по кругам.
ЧТО СЮДА НЕ ВХОДИТ. Стоимость самого присваивания в класс. Она вычитается:
режим «кеш сбивается» меряется вместе с присваиванием, а рядом меряется одно
присваивание без обращения к атрибуту, и разность даёт цену поиска. Без этой
поправки замер показывал бы цену `setattr`, а не цену промаха кеша.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import sys
import timeit
N = 20_000
REPEAT = 100
DEPTHS = (2, 6, 21, 51)
def chain(depth):
"""Линейная цепочка наследования длиной `depth` (считая `object`).
Метод объявлен в САМОМ ВЕРХНЕМ классе цепочки — иначе поиск заканчивался бы
на первом же типе и длина `__mro__` ничего не значила бы.
"""
top = type("Top", (object,), {"m": 1})
cls = top
# depth считает и `object`, и `Top`, поэтому промежуточных на два меньше.
for i in range(depth - 2):
cls = type(f"L{i}", (cls,), {})
return cls
CLASSES = {d: chain(d) for d in DEPTHS}
OBJECTS = {d: CLASSES[d]() for d in DEPTHS}
assert all(len(CLASSES[d].__mro__) == d for d in DEPTHS), {
d: len(CLASSES[d].__mro__) for d in DEPTHS
}
def measure(stmts, setup):
"""Минимум по кругам; в каждом круге меряются все формы — см. докстринг."""
best = [float("inf")] * len(stmts)
for _ in range(REPEAT):
for i, stmt in enumerate(stmts):
best[i] = min(best[i], timeit.timeit(stmt, setup=setup, number=N) / N)
return [t * 1e9 for t in best]
setup = "from __main__ import OBJECTS, CLASSES"
HOT = [f"OBJECTS[{d}].m" for d in DEPTHS]
COLD = [f"CLASSES[{d}].touched = 1; OBJECTS[{d}].m" for d in DEPTHS]
# Одно присваивание без обращения к атрибуту — поправка, которую надо вычесть.
SETATTR = [f"CLASSES[{d}].touched = 1" for d in DEPTHS]
results = measure(HOT + COLD + SETATTR, setup)
hot = results[: len(DEPTHS)]
cold_raw = results[len(DEPTHS) : 2 * len(DEPTHS)]
overhead = results[2 * len(DEPTHS) :]
cold = [c - o for c, o in zip(cold_raw, overhead)]
print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} чередующихся кругов по {N} обращений")
print()
print(" длина __mro__ кеш горячий кеш сбивается (цена присваивания)")
for d, h, c, o in zip(DEPTHS, hot, cold, overhead):
print(f" {d:>13} {h:9.1f} нс {c:11.1f} нс {o:16.1f} нс")
print()
# Наклон по крайним точкам: во что обходится каждый лишний уровень, когда
# кеша нет. При живом кеше та же величина должна быть неотличима от нуля.
span = DEPTHS[-1] - DEPTHS[0]
print(f" на уровень MRO без кеша: {(cold[-1] - cold[0]) / span:+.2f} нс")
print(f" на уровень MRO с кешем: {(hot[-1] - hot[0]) / span:+.2f} нс")
# НИКАКИХ КРАТНОСТЕЙ ОТ ГОРЯЧЕЙ ВЕЛИЧИНЫ. Она мала (около 15 нс) и шумит,
# и частное от неё гуляет между прогонами вдвое, ничего при этом не измеряя:
# на двух подряд запусках вышло 7,3 и 10,7 при одинаковом наклоне. Устойчив
# здесь наклон, и утверждение статьи строится на нём.
print(f" при длине {DEPTHS[-1]}: без кеша {cold[-1]:.0f} нс, с кешем {hot[-1]:.0f} нс")