MEASUREMENT
bench/descriptors/cost.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/interview/python/descriptors
- How to run it
python3.11 bench/descriptors/protocol.py python3.12 bench/descriptors/protocol.py python3.13 bench/descriptors/protocol.py python3.14 bench/descriptors/protocol.py python3.13 bench/descriptors/cost.py python3.14 bench/descriptors/cost.py
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
Замеры для урока «Дескрипторы»
| Скрипт | Что делает |
|---|---|
protocol.py |
приоритет дескриптора над словарём экземпляра, __set_name__, кто из встроенного является дескриптором и какого вида, вызываемость staticmethod против classmethod, механика cached_property |
cost.py |
цена обращения к атрибуту шестью способами плюс специализированный байткод, объясняющий разницу |
python3.11 bench/descriptors/protocol.py
python3.12 bench/descriptors/protocol.py
python3.13 bench/descriptors/protocol.py
python3.14 bench/descriptors/protocol.py
python3.13 bench/descriptors/cost.py
python3.14 bench/descriptors/cost.py
Практика урока
practice.py — источник ответов двух практических задач урока, а runs/practice.txt —
дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет
заявленное с этой записью (scripts/validate-practice.mjs) и не проходит,
если они разошлись.
Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.
Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.
Что здесь важно прочитать правильно
Числа сравниваются только внутри одного прогона. Все шесть способов меряются подряд, в одном процессе, ради этого и написаны в один скрипт. Общее правило про несравнимость версий по времени действует и здесь.
Одно ожидание не подтвердилось. Прогретый cached_property должен был
сравняться с обычным атрибутом: значение уже в __dict__, а дескриптор
не-данных слабее словаря. Измерено — устойчивые 1,8 от обычного атрибута.
Объяснение печатает сам скрипт: обычный атрибут получает специализацию
LOAD_ATTR_INSTANCE_VALUE, а атрибут, за которым в типе стоит дескриптор, —
не получает.
protocol.py не меряет время вовсе. Он печатает то, что интерпретатор
сообщает о себе сам: какие методы протокола есть у объекта, что вернулось при
обращении, сколько раз выполнилось тело. Такие утверждения одинаковы на всех
четырёх версиях, и прогон по четырём — способ это показать, а не сравнить.
Script
182 lines"""Во что обходится обращение к атрибуту через разные дескрипторы.
ЧТО МЕРЯЕТСЯ. Одно и то же чтение значения, добытое шестью способами:
- обычный атрибут экземпляра (`__dict__`) — база сравнения;
- слот `__slots__` — дескриптор данных, написанный на C;
- `property` — дескриптор данных, но геттер на Python;
- собственный дескриптор данных на Python;
- собственный дескриптор НЕ данных на Python;
- `functools.cached_property` — после первого обращения значение лежит в
`__dict__`, и дескриптора больше не спрашивают.
ЗАЧЕМ ЭТО В УРОКЕ. Дескриптор обычно объясняют как «магию», после чего его
цена остаётся неизвестной. А она объяснима до последнего числа: разница между
слотом на C и `property` — не «дескриптор дорогой», а «геттер на Python
дорогой».
ЧТО ЗАМЕР ОПРОВЕРГ. Ожидалось, что прогретый `cached_property` сравняется с
обычным атрибутом: значение лежит в `__dict__`, дескриптор не-данных слабее
словаря, спрашивать его незачем. Измерено — 1,8 от обычного атрибута, и
разница устойчива. Причина видна в специализированном байткоде и печатается
этим же скриптом: обычный атрибут интерпретатор заменяет на
`LOAD_ATTR_INSTANCE_VALUE`, а тут не может — в типе всё-таки лежит дескриптор,
и проверять его приходится на каждом обращении.
ЧТО ЗАПРЕЩЕНО ДЕЛАТЬ С ЭТИМИ ЧИСЛАМИ. Сравнивать их между версиями Python:
сборки этих версий различаются компилятором и флагами. Сравнивать можно только
строки внутри одного прогона — именно ради этого все шесть способов меряются
подряд, в одном процессе.
python3.13 bench/descriptors/cost.py
python3.14 bench/descriptors/cost.py
"""
import dis
import functools
import sys
import timeit
REPEAT = 9
NUMBER = 200_000
class Plain:
def __init__(self):
self.value = 42
class Slotted:
__slots__ = ("value",)
def __init__(self):
self.value = 42
class WithProperty:
def __init__(self):
self._value = 42
@property
def value(self):
return self._value
class DataDescriptor:
def __set_name__(self, owner, name):
self.slot = "_" + name
def __get__(self, obj, owner=None):
if obj is None:
return self
return obj.__dict__[self.slot]
def __set__(self, obj, value):
obj.__dict__[self.slot] = value
class WithData:
value = DataDescriptor()
def __init__(self):
self.value = 42
class NonDataDescriptor:
def __set_name__(self, owner, name):
self.slot = "_" + name
def __get__(self, obj, owner=None):
if obj is None:
return self
return obj.__dict__[self.slot]
class WithNonData:
value = NonDataDescriptor()
def __init__(self):
self.__dict__["_value"] = 42
class WithCached:
@functools.cached_property
def value(self):
return 42
def read_value(o):
"""Отдельная функция: специализация живёт в её байткоде, а не в строке."""
return o.value
CASES = [
("обычный атрибут (__dict__)", Plain()),
("слот __slots__", Slotted()),
("property", WithProperty()),
("свой дескриптор данных", WithData()),
("свой дескриптор не-данных", WithNonData()),
("cached_property (после первого)", WithCached()),
]
def best_ns(obj) -> float:
"""Лучшее из REPEAT прогонов, наносекунд на одно обращение."""
times = timeit.repeat("o.value", globals={"o": obj}, repeat=REPEAT, number=NUMBER)
return min(times) / NUMBER * 1e9
def main() -> None:
print("PY", sys.version.split()[0])
print(f"лучшее из {REPEAT} прогонов по {NUMBER:,} обращений".replace(",", " "))
print()
# cached_property должен быть уже посчитан: меряем установившийся режим.
CASES[-1][1].value
base = None
rows = []
for label, obj in CASES:
ns = best_ns(obj)
if base is None:
base = ns
rows.append((label, ns, ns / base))
width = max(len(label) for label, _, _ in rows)
for label, ns, ratio in rows:
print(f" {label:<{width}} {ns:7.2f} нс ×{ratio:.2f}")
print()
print("Почему прогретый cached_property всё равно дороже обычного атрибута")
print("-" * 62)
# Ответ виден в специализированном байткоде: у обычного атрибута
# интерпретатор заменяет LOAD_ATTR на LOAD_ATTR_INSTANCE_VALUE, у
# cached_property — не может, потому что в типе всё-таки лежит дескриптор
# и его приходится проверять на каждом обращении.
for cls in (Plain, WithCached):
probe = cls()
probe.value
for _ in range(200):
read_value(probe)
names = [
i.opname
for i in dis.get_instructions(read_value, adaptive=True)
if i.opname.startswith("LOAD_ATTR")
]
print(f" {cls.__name__:<12} {names}")
print()
print("Первое обращение к cached_property отдельно (там выполняется тело):")
fresh = WithCached()
first = timeit.timeit("WithCached().value", globals={"WithCached": WithCached}, number=20_000)
warm = timeit.timeit("o.value", globals={"o": fresh}, number=20_000)
fresh.value
warm2 = timeit.timeit("o.value", globals={"o": fresh}, number=20_000)
print(f" создать объект и обратиться впервые: {first / 20_000 * 1e9:7.2f} нс")
print(f" повторное обращение к тому же: {warm2 / 20_000 * 1e9:7.2f} нс")
print(f" (первый прогрев для контроля: {warm / 20_000 * 1e9:7.2f} нс)")
if __name__ == "__main__":
main()