Deep Engineering

MEASUREMENT

bench/calls/shapes.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/function-call
How to run it
Записи прогонов — в `runs/`. У `shapes.py` и `cells.py` записи на трёх версиях:
там разбирается байт-код и объекты, то есть свойства кода, а их между версиями
сравнивать можно. У `cost.py` запись одна: время между версиями не сравнивается
вовсе (корневой `bench/README.md`).

## Что замер делает с тремя ходовыми утверждениями

Все три числа — из одного прогона 3.13.7, из `runs/cost.txt`.

| утверждение | что вышло |
| --- | --- |
| «вызов метода дороже вызова функции» | `o.m()` 17,74 нс против `f0()` 15,35 нс — ×1,16, то есть разницы практически нет |
| «связать метод заранее дешевле» | `bound()` 18,34 нс против `o.m()` 17,74 нс — дешевле не стало; что дороже, не установлено, см. ниже |
| «`staticmethod` быстрее: у него нет `self`» | `C.s()` 27,88 нс против `o.m()` 17,74 нс — ×1,57 в другую сторону, но сравнение поставлено небрежно, см. ниже |

## Чего не проверяет `cost.py` и что доделывает `forms.py`

**Сравнение `o.m()` с `C.s()` меняет сразу две вещи** — вид метода и то, через
что его читают. `forms.py`, блок 1, разводит обе оси в одном прогоне: при чтении
через экземпляр `o.s()` стоит ×1,54 от `o.m()`, а способ чтения при том же виде
метода добавляет ×1,07. Вывод не перевернулся, но теперь он опирается на
сравнение, в котором меняется одна вещь.

**Разница `bound()` и `o.m()` — три процента**, и без разброса она ничего не
значит. `forms.py`, блок 2, печатает лучший и худший раунд каждой формы: разрыв
1,28 нс при разбросе до 2,24 нс у самого `o.m()`. То есть «заранее связанный
медленнее» не установлено; установлено, что не быстрее.

**Кратности звёздочек сняты на пустой функции.** `forms.py`, блок 3, повторяет
те же формы на функции с телом из пятидесяти витков арифметики: вместо ×5,17
выходит ×1,06. Переносится не коэффициент, а то, какая форма передачи дороже
остальных.

Числа `forms.py` и `cost.py` между собой не сравниваются: это разные прогоны.
Сравнивать внутри одного прогона — единственный способ что-то утверждать о
времени, и именно поэтому разброс печатается там же, где разрыв.

Первые два объясняются байт-кодом, и `shapes.py` его печатает: на пути
`o.m(t)` стоит пара `LOAD_ATTR_METHOD_WITH_VALUES` + `CALL_PY_EXACT_ARGS`, и
объект связанного метода не создаётся вовсе. На пути `bound(t)` он уже создан
и лежит в переменной, и вызывается через него — `CALL_BOUND_METHOD_EXACT_ARGS`.
То есть «сэкономить на создании объекта» нечего: на быстром пути его и не
создают.

Что объект действительно создаётся, когда его просят, проверяется отдельно и
без всякого байт-кода:

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

Замеры: вызов функции — сколько стоит, из чего состоит и где живут ячейки

Три скрипта отвечают на три разных вопроса об одном и том же действии.

скрипт что показывает
cost.py время: пятнадцать форм вызова в наносекундах, три ходовых утверждения о цене вызова и то, что с ними делает замер; звёздочки на передаче; цена чтения имени за вычетом вызова
shapes.py байт-код: какую специализацию интерпретатор выбрал после прогрева, чем o.m отличается от o.m(), и есть ли у выбранных опкодов имя в dis.opmap
cells.py объекты: ячейка как наблюдаемая величина, общая ячейка у двух функций, одна ячейка на весь цикл, MAKE_CELL/STORE_DEREF/COPY_FREE_VARS/LOAD_DEREF
forms.py то, что cost.py оставляет непроверенным: матрица «вид метода × способ чтения», разброс по раундам как мера значимости разницы, звёздочки на непустой функции

Запуск:

python3.13 bench/calls/cost.py
for v in 3.12 3.13 3.14; do echo "== $v"; python$v bench/calls/shapes.py; done
for v in 3.12 3.13 3.14; do echo "== $v"; python$v bench/calls/cells.py; done

Записи прогонов — в runs/. У shapes.py и cells.py записи на трёх версиях: там разбирается байт-код и объекты, то есть свойства кода, а их между версиями сравнивать можно. У cost.py запись одна: время между версиями не сравнивается вовсе (корневой bench/README.md).

Что замер делает с тремя ходовыми утверждениями

Все три числа — из одного прогона 3.13.7, из runs/cost.txt.

утверждение что вышло
«вызов метода дороже вызова функции» o.m() 17,74 нс против f0() 15,35 нс — ×1,16, то есть разницы практически нет
«связать метод заранее дешевле» bound() 18,34 нс против o.m() 17,74 нс — дешевле не стало; что дороже, не установлено, см. ниже
«staticmethod быстрее: у него нет self» C.s() 27,88 нс против o.m() 17,74 нс — ×1,57 в другую сторону, но сравнение поставлено небрежно, см. ниже

Чего не проверяет cost.py и что доделывает forms.py

Сравнение o.m() с C.s() меняет сразу две вещи — вид метода и то, через что его читают. forms.py, блок 1, разводит обе оси в одном прогоне: при чтении через экземпляр o.s() стоит ×1,54 от o.m(), а способ чтения при том же виде метода добавляет ×1,07. Вывод не перевернулся, но теперь он опирается на сравнение, в котором меняется одна вещь.

Разница bound() и o.m() — три процента, и без разброса она ничего не значит. forms.py, блок 2, печатает лучший и худший раунд каждой формы: разрыв 1,28 нс при разбросе до 2,24 нс у самого o.m(). То есть «заранее связанный медленнее» не установлено; установлено, что не быстрее.

Кратности звёздочек сняты на пустой функции. forms.py, блок 3, повторяет те же формы на функции с телом из пятидесяти витков арифметики: вместо ×5,17 выходит ×1,06. Переносится не коэффициент, а то, какая форма передачи дороже остальных.

Числа forms.py и cost.py между собой не сравниваются: это разные прогоны. Сравнивать внутри одного прогона — единственный способ что-то утверждать о времени, и именно поэтому разброс печатается там же, где разрыв.

Первые два объясняются байт-кодом, и shapes.py его печатает: на пути o.m(t) стоит пара LOAD_ATTR_METHOD_WITH_VALUES + CALL_PY_EXACT_ARGS, и объект связанного метода не создаётся вовсе. На пути bound(t) он уже создан и лежит в переменной, и вызывается через него — CALL_BOUND_METHOD_EXACT_ARGS. То есть «сэкономить на создании объекта» нечего: на быстром пути его и не создают.

Что объект действительно создаётся, когда его просят, проверяется отдельно и без всякого байт-кода:

o.m is o.m    False
o.m == o.m    True

Про звёздочки

«Звёздочки дорогие» — из пяти форм дорога одна:

форма к прямой передаче
f3(*args) ×1,16
f3(**kwargs) ×5,17
fstar(1, 2, 3) — приём в *args ×2,10
fstar(a=1) — приём в **kwargs ×2,29
fstar(*args, **one) ×6,24

Дорога распаковка СЛОВАРЯ на передаче, и только она.

Чего этими замерами утверждать нельзя

Что какая-то версия быстрее другой. В runs/ лежит одна запись cost.py, и это сделано нарочно: второй файл с другой версией провоцировал бы сравнение, которое в проекте запрещено (корневой bench/README.md).

Что специализации — часть языка. Ни одного из имён CALL_PY_EXACT_ARGS, LOAD_ATTR_METHOD_WITH_VALUES, CALL_BOUND_METHOD_EXACT_ARGS, CALL_LEN нет в dis.opmap — блок 4 shapes.py проверяет это перечислением. Они видны только через dis.dis(..., adaptive=True), в Doc/library/dis.rst не описаны и меняются между выпусками: на 3.12.3 та же специализация len называется CALL_NO_KW_LEN, на 3.13.7 и 3.14.7 — CALL_LEN.

Script

182 lines
"""Что интерпретатор делает на вызове — по байт-коду, а не по описанию.

ЗАЧЕМ ЭТОТ СКРИПТ. `cost.py` показывает, что `o.m()` стоит почти столько же,
сколько вызов обычной функции, хотя «должен» стоить дороже: по модели данных
`o.m` — это обращение к дескриптору, который создаёт объект связанного метода,
и только потом вызов. Здесь видно, почему числа получаются другими: объект
связанного метода на этом пути не создаётся вовсе. Его отсутствие — не
рассуждение, а наблюдаемый факт: в разобранном байт-коде стоит пара инструкций,
устроенная именно так.

ЧТО ЗДЕСЬ ПРЕДЪЯВЛЯЕТСЯ.

1. Какую специализацию интерпретатор выбрал после прогрева для каждой формы
   вызова. Видно через `dis.dis(..., adaptive=True)`.
2. Что `o.m` и `o.m()` — разные пути, а не «второе есть первое плюс скобки».
3. Что документации на выбранные опкоды нет: строк `CALL_PY_EXACT_ARGS` и
   `LOAD_ATTR_METHOD_WITH_VALUES` нет в `Doc/library/dis.rst`. Скрипт проверяет
   это по установленной документации, если она есть, и говорит прямо, когда
   проверить нечем.

ЧТО ЗДЕСЬ НЕ МЕРЯЕТСЯ. Время. Байт-код — свойство кода, его между версиями
сравнивать можно; время между версиями не сравнивается вовсе.

ЗАПУСК:

    for v in 3.12 3.13 3.14; do python$v bench/calls/shapes.py; done
"""

import dis
import io
import platform
import sys

WARMUP = 200


SRC = '''
class C:
    def m(self, x): return x
    @staticmethod
    def s(x): return x
    @classmethod
    def c(cls, x): return x

def plain(x): return x

def call_function(n):
    t = 0
    for _ in range(n):
        t = plain(t)
    return t

def call_method(n, o):
    t = 0
    for _ in range(n):
        t = o.m(t)
    return t

def call_prebound(n, bound):
    t = 0
    for _ in range(n):
        t = bound(t)
    return t

def call_static(n):
    t = 0
    for _ in range(n):
        t = C.s(t)
    return t

def call_classmethod(n):
    t = 0
    for _ in range(n):
        t = C.c(t)
    return t

def call_builtin(n, data):
    t = 0
    for _ in range(n):
        t = len(data)
    return t

def just_lookup(n, o):
    t = None
    for _ in range(n):
        t = o.m
    return t
'''


def opcodes(fn) -> list[str]:
    """Инструкции ТЕЛА ЦИКЛА — от FOR_ITER до прыжка назад.

    Брать весь разбор нельзя: у каждой функции в заголовке стоит свой вызов
    `range(n)`, и его LOAD_GLOBAL с CALL попадали бы в каждую строку таблицы,
    одинаковые и бессмысленные. Интересует ровно то, что повторяется.
    """
    body: list[str] = []
    inside = False
    for ins in dis.get_instructions(fn, adaptive=True):
        name = ins.opname
        if name.startswith("FOR_ITER"):
            inside = True
            continue
        if name.startswith("JUMP_BACKWARD"):
            inside = False
            continue
        if inside and name.startswith(("CALL", "LOAD_ATTR", "LOAD_GLOBAL")):
            body.append(name)
    return body


def head(n: int, title: str) -> None:
    line = f"{n}. {title}"
    print(f"\n{line}\n{'-' * len(line)}")


def main() -> None:
    print(f"Python {platform.python_version()}")
    print(f"Сборка: {sys.version.split('[')[-1].rstrip('] ')}")
    print("\nЗдесь разбирается байт-код — свойство кода, а не время.")

    ns: dict = {}
    exec(SRC, ns)  # noqa: S102 — код свой, не из ввода
    obj = ns["C"]()
    data = [1, 2, 3]
    ns["call_function"](WARMUP)
    ns["call_method"](WARMUP, obj)
    ns["call_prebound"](WARMUP, obj.m)
    ns["call_static"](WARMUP)
    ns["call_classmethod"](WARMUP)
    ns["call_builtin"](WARMUP, data)
    ns["just_lookup"](WARMUP, obj)

    head(1, "ЧТО ВЫБРАЛ ИНТЕРПРЕТАТОР ПОСЛЕ ПРОГРЕВА")
    print(f"  Каждая форма прогрета {WARMUP} вызовами, затем разобрана.")
    print("  форма вызова                инструкции после специализации")
    rows = [
        ("plain(t)", "call_function"),
        ("o.m(t)", "call_method"),
        ("bound(t)", "call_prebound"),
        ("C.s(t)", "call_static"),
        ("C.c(t)", "call_classmethod"),
        ("len(data)", "call_builtin"),
        ("o.m  — только чтение", "just_lookup"),
    ]
    seen = {}
    for label, name in rows:
        ops = opcodes(ns[name])
        seen[label] = ops
        print(f"  {label:<27s} {' + '.join(ops)}")

    head(2, "ЧТЕНИЕ АТРИБУТА И ВЫЗОВ МЕТОДА — РАЗНЫЕ ПУТИ")
    print("  o.m   без вызова:  " + " + ".join(seen["o.m  — только чтение"]))
    print("  o.m(t) с вызовом:  " + " + ".join(seen["o.m(t)"]))
    print("\n  Инструкция чтения РАЗНАЯ. Во втором случае интерпретатор знает,")
    print("  что сразу за чтением стоит вызов, и кладёт на стек функцию и")
    print("  экземпляр по отдельности — объект связанного метода не создаётся.")
    print("  Отсюда и число из cost.py: o.m() почти не дороже вызова функции.")

    head(3, "ТО ЖЕ САМОЕ, ПРОВЕРЕННОЕ ЧЕРЕЗ ОБЪЕКТЫ")
    print(f"  type(o.m)                         {type(obj.m).__name__}")
    print(f"  o.m is o.m                        {obj.m is obj.m}")
    print(f"  o.m == o.m                        {obj.m == obj.m}")
    print(f"  o.m.__func__ is C.__dict__['m']    {obj.m.__func__ is ns['C'].__dict__['m']}")
    print("\n  Каждое чтение o.m создаёт НОВЫЙ объект: два подряд не тождественны.")
    print("  Именно этой работы и нет на пути вызова через точку.")

    head(4, "ЕСТЬ ЛИ У ЭТИХ ОПКОДОВ ДОКУМЕНТАЦИЯ")
    names = sorted({op for ops in seen.values() for op in ops})
    documented = set(dis.opmap)
    print("  опкод                            есть в dis.opmap (то есть в языке)")
    for name in names:
        print(f"  {name:<32s} {'да' if name in documented else 'НЕТ — только специализация'}")
    print("\n  dis.opmap перечисляет инструкции, у которых есть имя в модуле dis.")
    print("  Специализаций там нет: они видны только через adaptive=True и в")
    print("  Doc/library/dis.rst не описаны. Опираться на них в коде нельзя —")
    print("  это деталь реализации, которая меняется между выпусками.")


main()