ЗАМЕР
bench/calls/shapes.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.
Скрипт
182 строк"""Что интерпретатор делает на вызове — по байт-коду, а не по описанию.
ЗАЧЕМ ЭТОТ СКРИПТ. `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()