MEASUREMENT
bench/attr-lookup/mro_override.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/runtime/attribute-lookup
- How to run it
У первых двух скриптов времени нет вовсе: сравниваются значения и последовательности вызовов, а адреса объектов заменены на `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 переписывался (см. ниже). ## Псевдокод и настоящий поиск
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
Замеры: где в поиске атрибута сидит протокол дескрипторов
Два сюжета, которых нет в 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.
Script
154 lines"""Переопределённый `mro()` и кеш атрибутов типа: что изменилось в 3.14.
ЧТО НАШЛОСЬ. Метакласс, который переопределяет `mro()` — даже если возвращает
ровно `super().mro()`, то есть НИЧЕГО не меняет в порядке, — на 3.14 лишает
свой класс кеша атрибутов типа. Обращение к унаследованному атрибуту начинает
стоить как промах кеша и растёт вместе с длиной `__mro__`. На 3.12 и 3.13
такого эффекта нет.
ЧТО ЭТО ЗНАЧИТ НА ПРАКТИКЕ. Эффект НАСЛЕДУЕТСЯ: достаточно одного базового
класса с переопределённым `mro()` где-нибудь в библиотеке, и кеша лишается
каждый наследник в чужом коде — при том что сам наследник объявлен обычным
`class`, и в его файле ничего необычного нет.
ОТКУДА ЭТО ВИДНО В ИСХОДНИКАХ. Поле `tp_versions_used` у типа появилось ещё в
3.13 — без комментария и без последствий, которые видно отсюда. В 3.14 рядом с
ним встала константа `_Py_ATTR_CACHE_UNUSED` и прямое объяснение: это значение
ставится, «if the attribute cache is disabled for this type (e.g. due to custom
MRO entries)». Скрипт печатает объявление из заголовков ТОГО интерпретатора, на
котором запущен, — чтобы утверждение о C-уровне можно было проверить, не выходя
из своей системы.
ЧТО ИМЕННО ПРОВЕРЯЕТСЯ, ЧТОБЫ НЕ ПРИПИСАТЬ ЭФФЕКТ НЕ ТОМУ. Меряются три
конфигурации: обычный `type`, метакласс БЕЗ `mro()` и метакласс С `mro()`.
Средняя нужна именно для этого: без неё замедление можно было бы списать на
метакласс вообще.
ПОЧЕМУ ЗАМЕРЫ ЧЕРЕДУЮТСЯ И ПОЧЕМУ КРУГОВ МНОГО. Мерить конфигурации подряд
нельзя: просадка машины в окне одной целиком достаётся ей, и отношение между
конфигурациями гуляет от запуска к запуску (на декораторах измерено:
5,9 / 7,1 / 7,5 / 9,2 на четырёх запусках). Круги короткие и многочисленные,
потому что машина проседает на секунды: сто коротких кругов дают сто шансов
попасть в спокойное окно.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import sys
import sysconfig
import timeit
from pathlib import Path
N = 20_000
REPEAT = 100
DEPTHS = (2, 21, 51)
class PlainMeta(type):
"""Метакласс, который ничего не переопределяет."""
class OwnMRO(type):
def mro(cls):
# Порядок НЕ меняется: возвращается ровно то, что построил бы type.
# Значение имеет сам факт переопределения, а не результат.
return super().mro()
def chain(depth, meta=type):
"""Линейная цепочка длиной `depth` (считая `object`); метод — на вершине."""
cls = meta("Top", (object,), {"m": 1})
for i in range(depth - 2):
cls = meta(f"L{i}", (cls,), {})
return cls
CASES = [("обычный type", type), ("метакласс без mro()", PlainMeta), ("метакласс со своим mro()", OwnMRO)]
# КАЖДЫЙ ОБЪЕКТ ПОЛУЧАЕТ СВОЁ ИМЯ МОДУЛЯ, а не ключ в словаре.
#
# Первая версия держала объекты в `OBJECTS[(name, depth)]`, и каждая строка
# таблицы несла лишний поиск в словаре по кортежу — около полутора десятков
# наносекунд поверх измеряемого. На форму таблицы это не влияло (надбавка
# одинакова у всех), но абсолютные числа завышало, а отдельный случай
# «наследники обычные», у которого этой надбавки не было, оказывался НИЖЕ
# базовой строки — то есть бессмысленным.
NAMES = {}
for _name, _meta in CASES:
for _d in DEPTHS:
_var = f"o_{abs(hash(_name)) % 997}_{_d}"
globals()[_var] = chain(_d, _meta)()
NAMES[(_name, _d)] = _var
# Отдельный случай: `mro()` переопределён ТОЛЬКО у корня цепочки, а наследники
# объявлены обычным `class Sub(Base)` — то есть ровно так, как их пишут в чужом
# коде. Метакласс при этом наследуется САМ: Python выводит самый производный
# метакласс из баз, и автору наследника не надо ни знать о нём, ни упоминать.
_root = OwnMRO("Root", (object,), {"m": 1})
_cls = _root
for _i in range(DEPTHS[-1] - 2):
_cls = type(f"P{_i}", (_cls,), {})
INHERITED = _cls()
assert len(type(INHERITED).__mro__) == DEPTHS[-1]
# Наследник не упоминал метакласс, но получил его — в этом вся практическая
# соль: платит чужой код, ничего необычного у себя не написавший.
assert type(type(INHERITED)) is OwnMRO, "метакласс обязан унаследоваться"
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]
keys = [(name, d) for name, _ in CASES for d in DEPTHS]
imported = ", ".join(sorted({NAMES[k] for k in keys} | {"INHERITED"}))
setup = f"from __main__ import {imported}"
stmts = [f"{NAMES[k]}.m" for k in keys] + ["INHERITED.m"]
values = measure(stmts, setup)
by_key = dict(zip(keys, values))
inherited_ns = values[-1]
print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} чередующихся кругов по {N} обращений")
print()
header = " конфигурация " + "".join(f"длина {d:>2} " for d in DEPTHS)
print(header)
for name, _ in CASES:
row = "".join(f"{by_key[(name, d)]:7.1f} нс " for d in DEPTHS)
print(f" {name:<26}{row}")
print()
print(f" mro() только у корня, наследники объявлены обычным class (длина {DEPTHS[-1]}): {inherited_ns:.1f} нс")
print()
# --- то же утверждение со стороны заголовков ---------------------------------
#
# Печатается объявление из заголовков ТОГО интерпретатора, который сейчас
# работает. Если заголовки не установлены, скрипт говорит об этом прямо, а не
# делает вид, что проверил.
include = Path(sysconfig.get_paths()["include"])
header = include / "cpython" / "object.h"
if not header.exists():
print(" заголовки этой сборки не установлены — проверить объявление нечем")
else:
text = header.read_text(errors="replace").splitlines()
field = next((i for i, l in enumerate(text) if "tp_versions_used" in l), None)
if field is None:
# Поле появилось в 3.13. Его отсутствие — не поломка, а версия.
print(" tp_versions_used в заголовках нет: сборка старше 3.13,"
" и описываемого им поведения тоже нет")
else:
start = max(0, field - 6)
print(f" cpython/object.h:{field + 1} — объявление поля:")
for j in range(start, field + 1):
print(f" {text[j]}")
# Константа — признак 3.14: в 3.13 то же поле объявлено голым.
const = next((l for l in text if "_Py_ATTR_CACHE_UNUSED" in l and "define" in l), None)
print()
if const:
print(f" и константа, объясняющая, что значит «кеш выключен»:\n {const.strip()}")
else:
print(" константы _Py_ATTR_CACHE_UNUSED в этой сборке нет:"
" поле объявлено без объяснения, и поведения, которое оно описывает, тоже нет")