Deep Engineering

MEASUREMENT

bench/calls/forms.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

180 lines
"""Формы вызова, разведённые по осям: что именно различается между двумя числами.

ЗАЧЕМ ОТДЕЛЬНЫЙ СКРИПТ. `cost.py` меряет пятнадцать форм вызова и тем самым
проверяет три ходовых утверждения. Но два из этих утверждений он проверяет не до
конца, и это стоит признать прямо:

1. «`staticmethod` медленнее обычного метода» сравнивалось по `o.m()` и `C.s()`.
   Между этими двумя записями различается СРАЗУ ДВА: вид метода и то, через что
   его читают — через экземпляр или через класс. Пока обе оси смешаны, разницу
   нельзя приписать виду метода.
2. «Связать метод заранее дороже» опиралось на 18,34 против 17,74 — это 3 %.
   Три процента чего-то значат только тогда, когда они больше разброса между
   раундами, а разброс `cost.py` не печатает.

И третье, про звёздочки: кратности сняты на ПУСТОЙ функции, то есть это
отношение накладных расходов к накладным расходам. На функции с телом та же
форма передачи даёт другую кратность, и это надо не оговаривать словами, а
показать.

Здесь три блока, каждый закрывает один из трёх пунктов. Все числа сняты в ОДНОМ
запуске, и сравниваются только между собой — с числами `cost.py` их сравнивать
нельзя, это другой прогон (корневой `bench/README.md` о том, как расходятся
абсолютные числа между сессиями).

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

ЗАПУСК:

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

import platform
import sys
import timeit

ROUNDS = 7
REPEAT = 50
NUMBER = 20_000


SETUP = """
def f0(): pass

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

o = C()
bound = o.m

def fwork(a, b, c):
    # Тело нарочно не пустое: нужно, чтобы было видно, во что превращается
    # относительная цена формы передачи, когда функция что-то делает.
    total = 0
    for i in range(50):
        total += i * i
    return total + a + b + c

def fworkstar(*a, **kw):
    total = 0
    for i in range(50):
        total += i * i
    return total

args = (1, 2, 3)
kwargs = {"a": 1, "b": 2, "c": 3}
one = {"a": 1}
"""


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 spread(expr: str) -> tuple[float, float]:
    """Лучший и худший раунд — чтобы разницу двух форм было с чем сравнивать."""
    stmt = "; ".join([expr] * REPEAT)
    runs = timeit.Timer(stmt, SETUP).repeat(ROUNDS, NUMBER)
    return (min(runs) / NUMBER / REPEAT * 1e9, max(runs) / NUMBER / 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("cost.py, это другой прогон.")
    print(f"Лучшее из {ROUNDS} раундов, {REPEAT} операций внутри витка.")

    head(1, "ОДИН И ТОТ ЖЕ МЕТОД, ДВА СПОСОБА ДОСТАТЬ")
    print("  Две оси разведены: вид метода и то, через что его читают.")
    print("  форма                                     нс      к f0()")
    matrix = [
        ("f0() — обычная функция", "f0()"),
        ("o.m() — метод через экземпляр", "o.m()"),
        ("o.s() — staticmethod через экземпляр", "o.s()"),
        ("C.s() — staticmethod через класс", "C.s()"),
        ("o.c() — classmethod через экземпляр", "o.c()"),
        ("C.c() — classmethod через класс", "C.c()"),
    ]
    m = {}
    for name, expr in matrix:
        value = per_op(expr)
        m[expr] = value
        print(f"  {name:<40s} {value:>7.2f}   ×{value / m['f0()']:.2f}")
    print("\n  Теперь каждое сравнение меняет ровно одну вещь:")
    print(f"    вид метода при чтении через экземпляр: o.m() {m['o.m()']:.2f} → "
          f"o.s() {m['o.s()']:.2f} — ×{m['o.s()'] / m['o.m()']:.2f}")
    print(f"    вид метода при чтении через экземпляр: o.m() {m['o.m()']:.2f} → "
          f"o.c() {m['o.c()']:.2f} — ×{m['o.c()'] / m['o.m()']:.2f}")
    print(f"    способ чтения при том же staticmethod: o.s() {m['o.s()']:.2f} → "
          f"C.s() {m['C.s()']:.2f} — ×{m['C.s()'] / m['o.s()']:.2f}")
    print(f"    способ чтения при том же classmethod:  o.c() {m['o.c()']:.2f} → "
          f"C.c() {m['C.c()']:.2f} — ×{m['C.c()'] / m['o.c()']:.2f}")
    print("\n  Читается это так: разница между видами метода остаётся и при")
    print("  одинаковом способе чтения, а сам способ чтения добавляет единицы")
    print("  процентов. То есть исходное сравнение o.m() с C.s() смешивало две")
    print("  оси, но вывод от разведения не перевернулся.")

    head(2, "РАЗБРОС ПО РАУНДАМ: КАКАЯ РАЗНИЦА ВООБЩЕ ЗНАЧИМА")
    print("  Лучший и худший раунд из семи для каждой формы. Разница между")
    print("  двумя формами что-то значит только там, где она больше разброса.")
    print("  форма                    лучший   худший   разброс")
    spreads = {}
    for expr in ("f0()", "o.m()", "bound()", "o.s()", "C.c()"):
        lo, hi = spread(expr)
        spreads[expr] = (lo, hi)
        print(f"  {expr:<22s} {lo:>8.2f} {hi:>8.2f} {hi - lo:>9.2f}")
    gap = abs(spreads["bound()"][0] - spreads["o.m()"][0])
    worst = max(spreads["bound()"][1] - spreads["bound()"][0],
                spreads["o.m()"][1] - spreads["o.m()"][0])
    print(f"\n  bound() против o.m(): разница {gap:.2f} нс при разбросе до {worst:.2f} нс")
    print(f"  внутри раундов — это {'МЕНЬШЕ' if gap < worst else 'больше'} разброса.")
    if gap < worst:
        print("  Значит, в этом прогоне различие не установлено вовсе: два числа")
        print("  лежат внутри собственного шума, и говорить, какое из них больше,")
        print("  не на чем.")
    else:
        print("  Значит, в этом прогоне bound() действительно дороже — но только")
        print("  в этом прогоне. Разброс внутри раундов меньше, чем расхождение")
        print("  между сессиями: корневой bench/README.md описывает случай, когда")
        print("  те же замеры на той же машине разошлись на 28 %. Трёхпроцентная")
        print("  разница, снятая один раз, столько не переживает.")
    print("  Практический вывод в обоих случаях один: связывать метод заранее")
    print("  ради скорости не стоит, потому что выигрыша нет ни в одном прогоне.")
    print("  Заранее связанный метод нужен тогда, когда его надо ПЕРЕДАТЬ.")

    head(3, "ЗВЁЗДОЧКИ, КОГДА ФУНКЦИЯ ЧТО-ТО ДЕЛАЕТ")
    print("  Те же формы передачи на функции с телом из пятидесяти витков")
    print("  арифметики — против тех же форм на пустой функции.")
    print("  форма                                      нс   к прямой передаче")
    base = per_op("fwork(1, 2, 3)")
    for name, expr in (
        ("fwork(1, 2, 3) — прямая передача", "fwork(1, 2, 3)"),
        ("fwork(*args) — распаковка кортежа", "fwork(*args)"),
        ("fwork(**kwargs) — распаковка словаря", "fwork(**kwargs)"),
        ("fworkstar(*args, **one) — и то и другое", "fworkstar(*args, **one)"),
    ):
        value = per_op(expr)
        print(f"  {name:<40s} {value:>9.2f}   ×{value / base:.2f}")
    print("\n  Механизм тот же, кратность другая: работа по разбору словаря")
    print("  никуда не делась, но теперь делится на цену тела. Отсюда правило:")
    print("  кратность «впятеро» относится к пустой функции и к трём аргументам,")
    print("  а не к любому коду. Переносится не коэффициент, а то, какая форма")
    print("  передачи дороже остальных.")


main()