Deep Engineering

MEASUREMENT

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

145 lines
"""Ячейка: где живёт переменная, которую видят сразу несколько функций.

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

ЧТО ЗДЕСЬ НЕ МЕРЯЕТСЯ. Время. Цену чтения имени из ячейки против локального и
глобального меряет `cost.py` внутри одного запуска.

ЗАПУСК:

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

import dis
import platform
import sys


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


def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    def read():
        return count

    return increment, read


def make_by_loop():
    return [lambda: i for i in range(3)]


def make_by_default():
    return [lambda i=i: i for i in range(3)]


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

    head(1, "ЯЧЕЙКА — ЭТО ОБЪЕКТ, И ЕЁ ВИДНО ИЗ PYTHON")
    increment, read = make_counter()
    print(f"  increment.__closure__            {increment.__closure__}")
    print(f"  тип элемента                     {type(increment.__closure__[0]).__name__}")
    print(f"  increment.__code__.co_freevars   {increment.__code__.co_freevars}")
    print(f"  значение внутри ячейки           {increment.__closure__[0].cell_contents}")
    print("\n  Ячейка — не «сохранённая копия переменной», а коробка, на которую")
    print("  ссылается функция. Значение лежит в коробке, а не в функции.")

    head(2, "ДВЕ ФУНКЦИИ ДЕЛЯТ ОДНУ КОРОБКУ")
    same = increment.__closure__[0] is read.__closure__[0]
    print(f"  increment.__closure__[0] is read.__closure__[0]   {same}")
    print(f"  read() до вызова increment                       {read()}")
    increment()
    increment()
    print(f"  read() после двух increment()                    {read()}")
    print(f"  значение в ячейке                                "
          f"{increment.__closure__[0].cell_contents}")
    print("\n  Ни одна из двух функций не передавала другой ничего. Они просто")
    print("  ссылаются на одну и ту же коробку — это и есть «общая переменная».")

    head(3, "ПОЧЕМУ ЦИКЛ ВЫДАЁТ ТРИ ОДИНАКОВЫЕ ФУНКЦИИ")
    by_loop = make_by_loop()
    by_default = make_by_default()
    print(f"  [lambda: i for i in range(3)]      → {[f() for f in by_loop]}")
    print(f"  [lambda i=i: i for i in range(3)]  → {[f() for f in by_default]}")
    cells = {id(f.__closure__[0]) for f in by_loop if f.__closure__}
    print(f"\n  разных ячеек у первых трёх функций: {len(cells)}")
    print(f"  есть ли ячейка у вторых трёх:       "
          f"{'нет' if by_default[0].__closure__ is None else 'да'}")
    print("\n  Первая строка — не ошибка Python и не «позднее связывание» как")
    print("  отдельное правило. Коробка одна на все три функции, к моменту")
    print("  вызова в ней лежит последнее значение. Вторая строка обходит это")
    print("  тем, что значение попадает в аргумент по умолчанию — а он")
    print("  вычисляется в момент создания функции, а не вызова.")

    head(4, "КТО РЕШАЕТ, ЧТО ПЕРЕМЕННАЯ СТАНЕТ ЯЧЕЙКОЙ")
    src = """
G = 1

def outer(param):
    local = 2
    box = 3
    def inner():
        return box
    return param, local, G, inner()
"""
    ns: dict = {}
    exec(src, ns)  # noqa: S102 — код свой, не из ввода
    outer = ns["outer"]
    inner_code = next(c for c in outer.__code__.co_consts if hasattr(c, "co_name"))
    print(f"  outer.__code__.co_varnames   {outer.__code__.co_varnames}")
    print(f"  outer.__code__.co_cellvars   {outer.__code__.co_cellvars}")
    print(f"  inner.co_freevars            {inner_code.co_freevars}")
    print("\n  box не попал в co_varnames, хотя присваивается в outer: компилятор")
    print("  увидел, что его берёт вложенная функция, и перевёл его в ячейку.")
    print("  Решение принято ОДИН РАЗ, при компиляции, и по коду видно, какое:")
    print("\n  в outer:")
    for ins in dis.get_instructions(outer):
        if ins.opname in ("MAKE_CELL", "STORE_DEREF") and ins.argrepr == "box":
            print(f"    {ins.opname} {ins.argrepr}".rstrip())
    print("  в inner:")
    for ins in dis.get_instructions(inner_code):
        if ins.opname in ("COPY_FREE_VARS", "LOAD_DEREF"):
            print(f"    {ins.opname} {ins.argrepr}".rstrip())
    print("\n  MAKE_CELL заводит коробку при входе в outer, STORE_DEREF кладёт в")
    print("  неё тройку, COPY_FREE_VARS отдаёт ту же коробку в inner, LOAD_DEREF")
    print("  достаёт из неё значение. Ни на одном шаге значение не копируется.")

    head(5, "ЯЧЕЙКА ПЕРЕЖИВАЕТ КАДР, В КОТОРОМ РОДИЛАСЬ")
    def short_lived():
        temp = "значение из функции, которая уже вернулась"

        def keeper():
            return temp

        return keeper

    keeper = short_lived()
    print(f"  функция short_lived вернулась, её кадра больше нет")
    print(f"  keeper() возвращает: {keeper()!r}")
    print(f"  keeper.__closure__[0].cell_contents живо: "
          f"{keeper.__closure__[0].cell_contents is not None}")
    print("\n  Вот зачем коробка вообще нужна. Кадр функции живёт до возврата,")
    print("  а переменная, взятая замыканием, должна жить дольше — значит, она")
    print("  не может лежать в кадре.")


main()