Deep Engineering

ЗАМЕР

bench/calls/cost.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/runtime/function-call
Как запустить
Записи прогонов — в `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`.
То есть «сэкономить на создании объекта» нечего: на быстром пути его и не
создают.

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

Запись прогона

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

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

скрипт что показывает
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.

Скрипт

214 строк
"""Сколько стоит вызов и от чего эта цена зависит.

ЗАЧЕМ ЭТОТ СКРИПТ. Вокруг вызова в Python держится несколько устойчивых
утверждений, и проверяются они одним и тем же замером: «вызов метода дороже
вызова функции», «staticmethod быстрее, потому что нет self», «звёздочки
дорогие». Первые два здесь не подтверждаются, третье подтверждается только
наполовину — и половина, которая подтверждается, не та, о которой обычно
говорят.

ЧТО СРАВНИВАЕТСЯ. Пятнадцать форм вызова, и у всех вызывается пустая функция:
тело ничего не делает, и меряется только то, что происходит ВОКРУГ тела.
Шестнадцатая строка вызова не содержит вовсе — это чтение атрибута из слота,
и стоит она там, чтобы у наносекунд был масштаб.

ПРАВИЛО ЗАМЕРА. Всё меряется ВНУТРИ ОДНОГО запуска ОДНОГО интерпретатора, и
сравниваются только числа из одного прогона (bench/README.md). Между версиями
время не сравнивается вовсе: у сборок песочницы разные компиляторы и разные
флаги. Поэтому запись прогона здесь одна, без суффикса версии.

МЕТОДИКА. `timeit`, лучшее из семи раундов. Берётся лучшее, а не среднее: шум
на этой машине односторонний — соседний процесс может отнять время, но не может
его добавить.

ПОЧЕМУ ВЫЗОВ ПОВТОРЯЕТСЯ ВНУТРИ ВИТКА. Один вызов стоит десятки наносекунд, и
сам виток цикла `timeit` стоит столько же. Если мерить по одному вызову за
виток, половина числа окажется ценой цикла, а разница между двумя такими
числами — его шумом. Поэтому вызов повторяется REPEAT раз внутри одного витка,
и накладные расходы делятся на столько же. Блок 1 печатает цену витка отдельной
строкой, чтобы это не оставалось на слово.

ЗАПУСК:

    python3.13 bench/calls/cost.py
"""

import platform
import sys
import timeit

ROUNDS = 7
REPEAT = 50
NUMBER = 20_000


SETUP = """
def f0(): pass
def f1(a): pass
def f3(a, b, c): pass
def fdef(a, b=1, c=2): pass
def fkwonly(*, a=1): pass
def fstar(*args, **kwargs): pass

class C:
    def m(self): pass
    @staticmethod
    def s(): pass
    @classmethod
    def c(cls): pass
    def __call__(self): pass

o = C()
bound = o.m
lam = lambda: None

def outer():
    x = 1
    def inner(): return x
    return inner
clo = outer()

class P:
    __slots__ = ("x",)
    def __init__(self): self.x = 1
p = P()

args = (1, 2, 3)
kwargs = {"a": 1, "b": 2, "c": 3}
one = {"a": 1}
len_ = len
data = [1, 2, 3]

GLOBAL_NAME = 1

def read_local():
    x = 1
    return x

def make_reader():
    x = 1
    def read_cell():
        return x
    return read_cell
read_cell = make_reader()

def read_global():
    return GLOBAL_NAME
"""


def per_op(expr: str) -> float:
    """Наносекунды на одну операцию, с делением накладных расходов витка."""
    stmt = "; ".join([expr] * REPEAT)
    sec = min(timeit.Timer(stmt, SETUP).repeat(ROUNDS, NUMBER)) / NUMBER
    return sec / REPEAT * 1e9


def empty_loop() -> float:
    stmt = "; ".join(["pass"] * REPEAT)
    sec = min(timeit.Timer(stmt, SETUP).repeat(ROUNDS, NUMBER)) / NUMBER
    return sec / REPEAT * 1e9


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Все числа сняты одним запуском одного интерпретатора: сравнивать")
    print("их можно только между собой, с другими версиями — нельзя.")
    print(f"Лучшее из {ROUNDS} раундов, {REPEAT} операций внутри витка.")

    head(1, "ЧТО СТОИТ САМ ЗАМЕР")
    idle = empty_loop()
    print(f"  пустой оператор pass в том же цикле        {idle:>8.2f} нс")
    print("  Это накладные расходы витка, поделённые на 50. В числах ниже цена")
    print("  самого цикла не участвует — иначе она была бы больше всего, что")
    print("  меряется.")

    head(2, "ЦЕНА ВЫЗОВА ПО ФОРМАМ")
    forms = [
        ("p.x — чтение слота, вызова нет", "p.x"),
        ("f0() — функция без аргументов", "f0()"),
        ("f1(1) — один позиционный", "f1(1)"),
        ("f3(1, 2, 3) — три позиционных", "f3(1, 2, 3)"),
        ("f1(a=1) — то же, но по имени", "f1(a=1)"),
        ("fdef(1) — два аргумента по умолчанию", "fdef(1)"),
        ("fdef(1, 2, 3) — они же переданы", "fdef(1, 2, 3)"),
        ("fkwonly(a=1) — только по имени", "fkwonly(a=1)"),
        ("lam() — lambda", "lam()"),
        ("clo() — замыкание с одной ячейкой", "clo()"),
        ("o.m() — метод через точку", "o.m()"),
        ("bound() — метод, связанный заранее", "bound()"),
        ("C.s() — staticmethod", "C.s()"),
        ("C.c() — classmethod", "C.c()"),
        ("o() — объект с __call__", "o()"),
        ("len_(data) — функция на C", "len_(data)"),
    ]
    print("  форма                                        нс")
    got = {}
    for name, expr in forms:
        value = per_op(expr)
        got[expr] = value
        print(f"  {name:<44s} {value:>7.2f}")

    head(3, "ТРИ УТВЕРЖДЕНИЯ, КОТОРЫЕ ЭТОТ ЗАМЕР ПРОВЕРЯЕТ")
    print(f"  «вызов метода дороже вызова функции»")
    print(f"    f0()   {got['f0()']:>7.2f} нс     o.m() {got['o.m()']:>7.2f} нс"
          f"     отношение ×{got['o.m()'] / got['f0()']:.2f}")
    print(f"  «связать метод заранее — дешевле, чем каждый раз через точку»")
    print(f"    o.m()  {got['o.m()']:>7.2f} нс   bound() {got['bound()']:>7.2f} нс"
          f"     отношение ×{got['bound()'] / got['o.m()']:.2f}")
    print(f"  «staticmethod быстрее обычного метода: у него нет self»")
    print(f"    o.m()  {got['o.m()']:>7.2f} нс    C.s() {got['C.s()']:>7.2f} нс"
          f"     отношение ×{got['C.s()'] / got['o.m()']:.2f}")

    head(4, "ЗВЁЗДОЧКИ НА ПЕРЕДАЧЕ")
    star = [
        ("f3(1, 2, 3) — прямая передача", "f3(1, 2, 3)"),
        ("f3(*args) — распаковка кортежа", "f3(*args)"),
        ("f3(**kwargs) — распаковка словаря", "f3(**kwargs)"),
        ("fstar(1, 2, 3) — приём в *args", "fstar(1, 2, 3)"),
        ("fstar(a=1) — приём в **kwargs", "fstar(a=1)"),
        ("fstar(*args, **one) — и то и другое", "fstar(*args, **one)"),
    ]
    print("  форма                                        нс   к прямой передаче")
    plain = None
    for name, expr in star:
        value = per_op(expr)
        if plain is None:
            plain = value
        print(f"  {name:<44s} {value:>7.2f}   ×{value / plain:.2f}")
    print("\n  Дорого ровно одно: распаковка СЛОВАРЯ на передаче. Она и только она")
    print("  даёт пятикратную разницу — и когда стоит одна, и когда рядом с")
    print("  кортежем. Распаковка кортежа почти бесплатна, приём в звёздочки")
    print("  стоит вдвое, а это уже другой порядок разговора.")

    head(5, "ЦЕНА ЧТЕНИЯ ИМЕНИ: ЛОКАЛЬНОЕ, ЯЧЕЙКА, ГЛОБАЛЬНОЕ")
    print("  Замыкание читает имя не оттуда же, откуда функция читает своё")
    print("  локальное. Вот во что это обходится — и во что обходится вызов")
    print("  рядом, чтобы было видно масштаб.")
    names = [
        ("локальное имя — LOAD_FAST", "read_local()"),
        ("имя из ячейки — LOAD_DEREF", "read_cell()"),
        ("глобальное имя — LOAD_GLOBAL", "read_global()"),
    ]
    print("  Каждое имя читается ВНУТРИ функции, поэтому в числе сидит и вызов.")
    print("  Третья колонка — за вычетом пустого вызова, то есть само чтение.")
    empty_call = got["f0()"]
    print(f"  что читается                          вызов+чтение   чтение")
    for name, expr in names:
        total = per_op(expr)
        print(f"  {name:<36s} {total:>11.2f} {total - empty_call:>9.2f}")
    print(f"  {'для сравнения: f0() — пустой вызов':<36s} {empty_call:>11.2f} {0.0:>9.2f}")
    print("\n  Три разные инструкции чтения стоят одинаково в пределах шума этой")
    print("  машины, и все три — доли вызова. Из чего следует, что «замыкание")
    print("  медленнее обычной функции» на этих числах не подтверждается: платят")
    print("  не за чтение из ячейки, а за то, что вообще делается вызов.")


main()