Deep Engineering

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()