Вызов функции: сколько стоит, что при этом не происходит и где живут ячейки
Три ходовых утверждения о цене вызова — «метод дороже функции», «связать метод заранее дешевле», «staticmethod быстрее» — замер не подтверждает ни одного, а одно опровергает прямо. Причина видна в байт-коде: на быстром пути объект связанного метода не создаётся вовсе. Отсюда же и вторая половина статьи — замыкания: переменная, которую видит вложенная функция, хранится через отдельный объект — ячейку, на которую кадр только ссылается, — и из этого следует всё остальное.
Полное техническое изложение
TL;DR
Пустой вызов — около пятнадцати наносекунд, то есть примерно вдвое дороже самого дешёвого чтения атрибута. Это сравнение масштаба, а не разложение цены вызова на слагаемые. Всё остальное в таблице ниже — надбавки за форму вызова.
Три ходовых утверждения замер не подтверждает. o.m() дороже f0() всего
в 1,16 раза; заранее связанный метод не дешевле — а что дороже, не доказано,
разница лежит внутри разброса; staticmethod при том же способе чтения
медленнее обычного метода в полтора раза, а classmethod — почти втрое.
Причина одна: на быстром пути объект связанного метода не создаётся.
Интерпретатор видит вызов сразу за чтением атрибута и кладёт на стек функцию и
экземпляр по отдельности. Экономить нечего, а «оптимизация» через bound = o.m
этот объект возвращает.
Из звёздочек дорога одна. f(**kwargs) — впятеро дороже прямой передачи на
пустой функции, f(*args) — на шестнадцать процентов. На функции с телом от
пятикратной разницы остаётся шесть процентов.
Замыкание держит не значение, а ячейку. Отсюда общая переменная у двух
функций, одна ячейка на весь цикл и [2, 2, 2]. Чтение из ячейки при этом
неотличимо по времени от чтения локальной переменной.
Сколько стоит вызов
Почти все числа сняты на вызове пустой функции: тело ничего не делает, и меряется
только то, что происходит вокруг него. Две строки стоят ради масштаба: p.x —
чтение слота, вызова в нём нет вовсе, и len_(data) — функция на C.
Числа из bench/calls/cost.py, один прогон 3.13.7; сравнивать их можно только
между собой.
| форма | нс |
|---|---|
p.x — чтение слота, вызова нет | 8,16 |
f0() — функция без аргументов | 15,35 |
o.m() — метод через точку | 17,74 |
bound() — метод, связанный заранее | 18,34 |
C.s() — staticmethod | 27,88 |
C.c() — classmethod | 46,89 |
len_(data) — функция на C | 9,48 |
Почему метод почти не дороже функции
По модели данных o.m создаёт объект связанного метода, и вызов должен был бы
стоить дороже. На быстром пути этого объекта нет вовсе: интерпретатор видит,
что сразу за чтением атрибута стоит вызов, и кладёт на стек функцию и экземпляр
по отдельности. Связывать их в один объект не нужно.
А что без вызова объект действительно создаётся, видно по одной строке:
o.m is o.m # False
o.m == o.m # TrueОтсюда и совет, который лучше не выполнять: bound = o.m возвращает этот
объект, и вызов начинает идти через него. Итог — 18,34 против 17,74 наносекунды:
дешевле не стало, а что стало дороже, замер не доказывает — разница меньше
разброса между раундами.
Звёздочки
Дорога одна форма из пяти: f3(**kwargs) впятеро дороже прямой передачи.
f3(*args) — всего на шестнадцать процентов.
Причина в том, что позиционные аргументы и значения именованных едут массивом, а имена именованных — отдельным кортежем. Позиционные уже лежат так, как надо, и отдаются как есть; словарь не лежит ни тем, ни другим, и его приходится разобрать на массив значений и кортеж имён. Дело не в числе значений, а в том, что одну структуру приходится превращать в две другие.
Где живёт переменная замыкания
Замыкание держит не значение, а ячейку — отдельный объект, видимый из Python
как элемент __closure__. Из этого следует всё остальное.
Две функции, созданные рядом, ссылаются на одну и ту же ячейку, и
increment.__closure__[0] is read.__closure__[0] даёт True. Никто никому
ничего не передавал: они просто смотрят в одну коробку.
Три лямбды из цикла тоже делят одну ячейку — потому что ячейка заводится на
переменную, а переменная в цикле одна. К моменту вызова в ней лежит последнее
значение, отсюда [2, 2, 2]. Это не отдельное правило про циклы и не ошибка.
Запись lambda i=i: i не «чинит связывание», а избавляет от нужды в общей
коробке: у таких функций __closure__ пуст, а значение вычислено при создании
каждой.
Бояться замыканий из-за скорости не нужно: чтение из ячейки стоит 3,91 наносекунды против 3,96 у локальной переменной и 3,78 у глобальной — все три неразличимы, и каждая — лишь доля пустого вызова.
Что из этого делать
Не переписывать o.m() в bound = o.m ради скорости. Не выбирать
staticmethod и classmethod по скорости — только по смыслу. Из звёздочек
остерегаться стоит только **kwargs на горячем пути. И помнить про ячейку: она одна на всех,
кто видит переменную, — вот что в замыканиях действительно стоит внимания.
Про цену вызова в Python ходят три утверждения, и проверяются они одним и тем
же замером: «вызов метода дороже вызова функции», «связать метод заранее
дешевле, чем каждый раз через точку», «staticmethod быстрее обычного метода —
у него нет self».
Первое замер почти не подтверждает. Второе не подтверждает тоже, а третье
опровергает — и в обратную сторону: staticmethod не быстрее, а медленнее.
Объяснение у всех трёх одно, и оно не про наносекунды: на быстром пути вызова метода объект связанного метода не создаётся. Экономить на его создании нечего, а тот, кто «оптимизирует» вызов, связав метод заранее, этот объект возвращает.
Вторая половина статьи — про замыкания, и она здесь не за компанию. Ячейка —
это то место, где живёт переменная, которую видит вложенная функция, и из её
устройства следует всё остальное: общая переменная у двух функций, три
одинаковых ответа из цикла и то, зачем нужен nonlocal.
Часть I. Цена вызова
Что вообще меряется
Почти все числа сняты на вызове пустой функции: тело ничего не делает, и
меряется только то, что происходит вокруг тела. Две строки выпадают из этого
нарочно, и обе стоят ради масштаба: p.x — чтение атрибута у объекта с
__slots__, то есть самое дешёвое чтение, какое есть, и вызова в нём нет
вовсе; len_(data) — вызов функции, написанной на C, а не на Python. Замер на
3.13.7, bench/calls/cost.py, блок 2:
| форма | нс |
|---|---|
p.x — чтение слота, вызова нет | 8,16 |
f0() — функция без аргументов | 15,35 |
f1(1) — один позиционный | 16,65 |
f3(1, 2, 3) — три позиционных | 20,22 |
f1(a=1) — то же, но по имени | 25,39 |
fdef(1) — два аргумента по умолчанию | 23,61 |
fdef(1, 2, 3) — они же переданы | 20,24 |
fkwonly(a=1) — только по имени | 25,64 |
lam() — lambda | 15,36 |
clo() — замыкание с одной ячейкой | 20,88 |
o.m() — метод через точку | 17,74 |
bound() — метод, связанный заранее | 18,34 |
C.s() — staticmethod | 27,88 |
C.c() — classmethod | 46,89 |
o() — объект с __call__ | 36,36 |
len_(data) — функция на C | 9,48 |
Читается это так: пустой вызов на этой машине — около пятнадцати наносекунд, то есть примерно вдвое дороже самого дешёвого чтения атрибута, какое тут есть. Это сравнение масштаба двух разных операций, а не разложение цены вызова на слагаемые: из чего складываются эти пятнадцать наносекунд, замер не говорит вовсе. Всё остальное в таблице — надбавки за форму.
Все числа сняты одним запуском 3.13.7 и сравниваются только между собой. Абсолютные значения — свойство машины замера (Intel Xeon 2,80 ГГц, 2 vCPU); на другое железо переносятся отношения, а не наносекунды. Время между версиями в этом проекте не сравнивается вовсе.
Три утверждения и что с ними стало
Тот же прогон, bench/calls/cost.py, блок 3:
| утверждение | пара | отношение |
|---|---|---|
| «вызов метода дороже вызова функции» | f0() 15,35 нс, o.m() 17,74 нс | ×1,16 |
| «связать метод заранее — дешевле, чем каждый раз через точку» | o.m() 17,74 нс, bound() 18,34 нс | ×1,03 |
«staticmethod быстрее обычного метода: у него нет self» | o.m() 17,74 нс, C.s() 27,88 нс | ×1,57 |
Первое почти не подтверждается: шестнадцать процентов — не повод что-то
переписывать, и это меньше, чем разница между f1(1) и f1(a=1) в той
же таблице: 16,65 против 25,39.
Второе не подтверждается: заранее связанный метод во всяком случае не дешевле. Что он дороже, эти два числа не доказывают; почему — разобрано ниже, в отдельном разделе про разброс.
Третье опровергнуто, причём в обратную сторону: staticmethod медленнее
обычного метода, а classmethod — заметно медленнее обоих. Но в этом сравнении
меняются сразу две вещи, поэтому ниже они разобраны по отдельности.
Сначала — что здесь сравнивается с чем
В третьей строке таблицы стоят o.m() и C.s(), и различаются они сразу
двумя вещами: видом метода и тем, через что его читают, — через экземпляр или
через класс. Пока обе оси смешаны, разницу нельзя приписать виду метода: она могла
взяться и от чтения через класс.
Поэтому обе оси разведены отдельным прогоном, в котором все шесть форм сняты
вместе, — bench/calls/forms.py, блок 1:
| форма | нс | к f0() |
|---|---|---|
f0() — обычная функция | 15,45 | ×1,00 |
o.m() — метод через экземпляр | 17,33 | ×1,12 |
o.s() — staticmethod через экземпляр | 26,60 | ×1,72 |
C.s() — staticmethod через класс | 28,45 | ×1,84 |
o.c() — classmethod через экземпляр | 47,15 | ×3,05 |
C.c() — classmethod через класс | 51,26 | ×3,32 |
Теперь каждое сравнение меняет ровно одну вещь. При одинаковом чтении — через
экземпляр — staticmethod стоит ×1,54 от обычного метода, а classmethod
×2,72. Сам способ чтения при том же виде метода добавляет ×1,07 и ×1,09, то есть
единицы процентов.
Вывод от разведения не перевернулся: staticmethod действительно дороже
обычного метода, и дело именно в виде метода, а не в том, что его читали через
класс. Но сказать это можно только теперь.
Числа этой таблицы — из другого прогона, чем таблица выше: их снял скрипт
bench/calls/forms.py, а не bench/calls/cost.py. Сравнивать их между собой нельзя, и
поэтому 17,33 здесь и 17,74 там — не расхождение, а два разных измерения
одного и того же на одной машине. Отношения внутри каждого прогона при этом
сохранились.
Почему так: что именно не происходит
Ответ на все три сразу лежит в байт-коде. bench/calls/shapes.py, блок 1, разбирает эти формы после двухсот прогревочных
вызовов каждой:
| форма вызова | инструкции после специализации |
|---|---|
plain(t) | LOAD_GLOBAL_MODULE + CALL_PY_EXACT_ARGS |
o.m(t) | LOAD_ATTR_METHOD_WITH_VALUES + CALL_PY_EXACT_ARGS |
bound(t) | CALL_BOUND_METHOD_EXACT_ARGS |
C.s(t) | LOAD_GLOBAL_MODULE + LOAD_ATTR + CALL_PY_EXACT_ARGS |
C.c(t) | LOAD_GLOBAL_MODULE + LOAD_ATTR + CALL_BOUND_METHOD_EXACT_ARGS |
len(data) | LOAD_GLOBAL_BUILTIN + CALL_LEN |
o.m — только чтение | LOAD_ATTR |
Смотреть надо на строку o.m(t) и на последнюю. Инструкция чтения атрибута разная:
LOAD_ATTR_METHOD_WITH_VALUES, когда сразу за чтением стоит вызов, и обычный
LOAD_ATTR, когда вызова нет.
Разница в том, что кладётся на стек. На пути с вызовом туда попадают функция и экземпляр по отдельности: связывать их в один объект не нужно, и объект связанного метода не создаётся вовсе. На пути без вызова он создаётся, потому что его запросили.
Что без вызова объект действительно создаётся, можно проверить и без всякого байт-кода:
o.m is o.m # False
o.m == o.m # TrueДва чтения подряд дают два разных объекта. Именно этой работы и нет на быстром пути.
И третье утверждение видно в той же таблице. У C.s(t) инструкций три, а не
две: сначала читается имя класса, потом атрибут на нём, и специализации на
экземпляре здесь нет вовсе. Это та самая ось «способ чтения», которой матрица
выше отводит ×1,07; остальное — вид метода: ×1,54 при одинаковом чтении
через экземпляр, вместе ×1,64 (28,45 против 17,33 в прогоне матрицы; ×1,57 в таблице
утверждений — другой прогон). Байт-код o.s(t) этот прогон не разбирает, так что разложение
получено по времени, а не по инструкциям. У C.c(t) то же лишнее чтение атрибута, но вызов идёт ещё и через
связанный метод: CALL_BOUND_METHOD_EXACT_ARGS. Во что обходится каждая из
двух добавок по отдельности, этот замер не раскладывает — виден только итог,
46,89 против 27,88.
Отсюда же ответ и на второе утверждение. Связать метод заранее — значит создать объект
PyMethodObject один раз и вызывать через него: CALL_BOUND_METHOD_EXACT_ARGS.
Экономить нечего — на быстром пути этот объект и так не создаётся, — а лишний
уровень косвенности остаётся.
Числа при этом говорят меньше, чем хочется. 18,34 против 17,74 — это три
процента, и прежде чем на такую разницу опираться, надо знать разброс. Числа
ниже свои — это другой прогон, и с числами выше они не сравниваются; сравнивать
надо разрыв внутри этого прогона с его же разбросом. Лучший и худший раунд из
семи для каждой формы печатает bench/calls/forms.py, блок 2:
| форма | лучший | худший | разброс |
|---|---|---|---|
f0() | 15,36 | 15,72 | 0,37 |
o.m() | 17,27 | 19,51 | 2,24 |
bound() | 18,55 | 19,38 | 0,82 |
o.s() | 27,12 | 27,54 | 0,42 |
C.c() | 49,33 | 51,32 | 1,99 |
Разница между двумя формами что-то значит только там, где она больше разброса.
У bound() и o.m() она составляет 1,28 нс при разбросе внутри раундов до 2,24 нс —
это меньше разброса.
То есть «заранее связанный метод медленнее» из этих замеров не следует: два числа лежат внутри собственного шума. Следует другое — что он не дешевле, и это ровно тот вывод, который нужен на практике: связывать метод заранее ради скорости не стоит, потому что выигрыша нет ни в одном прогоне. А нужен заранее связанный метод тогда, когда его надо куда-то передать.
При этом модель данных описывает o.m именно как создание объекта:
When a non-data attribute of an instance is referenced, the instance's class is searched. If the name denotes a valid class attribute that is a function object, references to both the instance object and the function object are packed into a method object
(Когда запрашивается не-данный атрибут экземпляра, поиск идёт в классе экземпляра. Если имя обозначает допустимый атрибут класса, который является объектом-функцией, ссылки на объект-экземпляр и на объект-функцию упаковываются в объект-метод). Это остаётся верным: наблюдаемое
поведение не изменилось, и o.m без вызова объект создаёт. Изменилось то,
как интерпретатор добивается того же результата, когда видит вызов.
Звёздочки: дорога одна форма из пяти
«Звёздочки дорогие» — ещё одно ходовое убеждение, и верно оно ровно в одном
случае из пяти. bench/calls/cost.py, блок 4:
| форма | нс | к прямой передаче |
|---|---|---|
f3(1, 2, 3) — прямая передача | 20,17 | ×1,00 |
f3(*args) — распаковка кортежа | 23,49 | ×1,16 |
f3(**kwargs) — распаковка словаря | 104,35 | ×5,17 |
fstar(1, 2, 3) — приём в *args | 42,27 | ×2,10 |
fstar(a=1) — приём в **kwargs | 46,12 | ×2,29 |
fstar(*args, **one) — и то и другое | 125,77 | ×6,24 |
f3(1, 2, 3) меряется здесь заново — отсюда 20,17 против 20,22 в таблице
выше. Разница в пять сотых наносекунды — это одна и та же величина,
дважды измеренная в одном прогоне. Значима ли разница, дальше решает разброс
между раундами, а не эта величина.
Распаковка кортежа почти бесплатна — шестнадцать процентов. Приём в звёздочки стоит вдвое, и это нормальная цена за произвольную сигнатуру. А вот распаковка словаря на передаче обходится впятеро дороже, и только здесь есть о чём задуматься.
Последняя строка с её ×6,24 — не исключение, а следствие той же причины: в ней тоже есть распаковка словаря, и она одна даёт почти весь разрыв. Кортеж рядом с ней добавляет немного.
Причина — в том, как устроен быстрый протокол вызова. В его описании сказано, в каком виде вызываемый объект получает аргументы:
args is a C array consisting of the positional arguments followed by the
values of the keyword arguments
args — это массив C, в котором сначала идут позиционные аргументы, а за ними значения именованных
А имена этих аргументов едут отдельно:
kwnames is a tuple containing the names of the keyword arguments; in other
words, the keys of the kwargs dict
kwnames — это кортеж с именами именованных аргументов, то есть с ключами словаря kwargs
Позиционные аргументы уже лежат массивом — их можно передать как есть. Словарь не лежит ни массивом значений, ни кортежем имён, и его приходится разобрать на то и другое. Отсюда и пятикратная разница: дело не в том, сколько значений передаётся, а в том, что одну структуру приходится превращать в две другие. Байт-код этой формы прогон не разбирает: объяснение взято из описания протокола, а как эти сто наносекунд раскладываются по шагам, замер не показывает.
И сразу о том, где эта кратность верна. Все числа сняты на пустой функции,
так что это отношение одних накладных расходов к другим. Те же формы передачи на
функции с телом из пятидесяти витков арифметики сняты отдельным прогоном —
bench/calls/forms.py, блок 3:
| форма | нс | к прямой передаче |
|---|---|---|
fwork(1, 2, 3) — прямая передача | 1663,47 | ×1,00 |
fwork(*args) — распаковка кортежа | 1659,83 | ×1,00 |
fwork(**kwargs) — распаковка словаря | 1763,83 | ×1,06 |
fworkstar(*args, **one) — и то и другое | 1755,32 | ×1,06 |
Механизм тот же, кратность другая: ×1,06 против ×5,17 на пустой функции. Эти два числа из разных прогонов, и сравниваются здесь не они, а кратности внутри каждого. Из этого замера переносится не коэффициент, а то, какая форма передачи дороже остальных, и вывод: избегать её имеет смысл там, где тело функции сравнимо с накладными расходами на вызов.
Часть II. Ячейки
Замыкание держит не значение, а коробку
«Функция помнит окружение» — объяснение, из которого не выводится ничего из
того, что видно на практике. А общая переменная у двух функций, три одинаковых
ответа из цикла и nonlocal следуют из одного факта: переменная, которую видит
вложенная функция, хранится в отдельном объекте — ячейке. Дальше она
называется коробкой: важно в ней именно то, что значение лежит в ней, а не в
самой функции.
Сказать «лежит не в кадре» было бы неточно, и неточность тут важная. Ссылка на
ячейку как раз в кадре и есть: локальные переменные кадра держат ссылки на
ячейки, а инструкции LOAD_DEREF и STORE_DEREF работают через них. Ячейка
стоит не вместо кадра, а между кадром и значением, и именно поэтому одну и ту
же ячейку могут держать сразу несколько замыканий, а живёт она дольше кадра, в
котором появилась.
Ячейка — не деталь реализации CPython, а часть модели данных, и описана она прямо:
Cell objects are used to implement variables referenced by multiple scopes.
For each such variable, a cell object is created to store the value; the
local variables of each stack frame that references the value contain a
reference to the cells from outer scopes which also use that variable
Объекты-ячейки используются, чтобы реализовать переменные, на которые ссылаются несколько областей видимости. Для каждой такой переменной создаётся объект-ячейка для хранения значения; локальные переменные каждого кадра, ссылающегося на это значение, содержат ссылку на ячейки из внешних областей, которые тоже используют эту переменную
«Для каждой такой переменной» — в этом всё дело. Ячейка заводится на переменную, а не на функцию и не на вызов.
Две функции, одна коробка
Прогон bench/calls/cells.py, блок 2:
| проверка | результат |
|---|---|
increment.__closure__[0] is read.__closure__[0] | True |
read() до вызова increment | 0 |
read() после двух increment() | 2 |
| значение в ячейке | 2 |
Ни одна из двух функций ничего другой не передавала. Они просто ссылаются на
один и тот же объект, и is это подтверждает. Вот что такое «общая переменная»
на самом деле — не магия области видимости, а разделяемый объект.
Почему цикл выдаёт три одинаковые функции
Отсюда же и классическая неожиданность:
[lambda: i for i in range(3)] # [2, 2, 2]
[lambda i=i: i for i in range(3)] # [0, 1, 2]bench/calls/cells.py, блок 3, считает ячейки, и это отвечает на вопрос лучше
любого объяснения: у первых трёх функций ячейка одна, у вторых трёх её нет вовсе.
Первая строка — не ошибка Python и не отдельное правило «позднее связывание». Ячейка заводится на переменную, переменная в цикле одна, и к моменту вызова в ней лежит последнее значение. Вторая строка не «чинит связывание», а избавляет от нужды в общей коробке: значение попадает в аргумент по умолчанию, а он вычисляется в момент создания функции — по разу на каждую.
Кто решает, что переменная станет ячейкой
Решает компилятор, один раз, и по коду видно, какое решение принято, —
bench/calls/cells.py, блок 4:
| что смотрим | значение |
|---|---|
outer.__code__.co_varnames | ('param', 'local', 'inner') |
outer.__code__.co_cellvars | ('box',) |
inner.co_freevars | ('box',) |
box не попал в co_varnames, хотя присваивается в outer: компилятор
увидел, что его берёт вложенная функция, и перевёл его в ячейку. Инструкции
печатает тот же bench/calls/cells.py:
| где | инструкция |
|---|---|
в outer | MAKE_CELL box |
в outer | STORE_DEREF box |
в inner | COPY_FREE_VARS |
в inner | LOAD_DEREF box |
По ним видно всю цепочку: MAKE_CELL заводит коробку при входе в outer,
STORE_DEREF кладёт в неё значение, COPY_FREE_VARS отдаёт ту же коробку в
inner, LOAD_DEREF достаёт из неё значение. Ни на одном шаге значение не
копируется.
Вывод bench/calls/cells.py на 3.12.3, 3.13.7 и 3.14.7 совпадает во всём, кроме адресов
в repr ячейки. Так и должно быть: наблюдаемое поведение замыканий —
часть модели данных. Одна ячейка на всех, кто видит переменную; значение, живущее
дольше кадра; nonlocal, который делает присваивание записью в ячейку, —
это и есть контракт.
А вот как это устроено — уже реализация CPython, и опираться в коде на
неё нельзя. PyCellObject, инструкции MAKE_CELL, STORE_DEREF,
COPY_FREE_VARS, LOAD_DEREF, разделение имён на co_varnames,
co_cellvars и co_freevars, имена специализаций вызова — всё это
устройство конкретной реализации. Его видно из Python, поэтому на нём удобно
объяснять; но между выпусками оно менялось и будет меняться, а обещано из
всего этого только то, что перечислено в предыдущей врезке.
Зачем коробка вообще нужна и при чём тут nonlocal
Кадр функции живёт до возврата, а коробка — дольше. Прогон bench/calls/cells.py, блок 5:
| что сообщает прогон | значение |
|---|---|
функция short_lived вернулась, её кадра больше нет | — |
keeper() возвращает | 'значение из функции, которая уже вернулась' |
keeper.__closure__[0].cell_contents живо | True |
Вот отсюда и берётся nonlocal. Обычное присваивание в функции — это запись в
кадр, и компилятор решает, куда писать, по одному правилу: имя, которому в теле
что-то присваивают, считается локальным. Вложенной функции этого мало: ей надо
писать не в свой кадр, а в коробку, общую с объемлющей функцией. nonlocal не
«разрешает изменять внешнюю переменную» — он говорит компилятору, что имя не
локальное, и присваивание должно стать STORE_DEREF.
Во что обходится чтение из ячейки
Последний вопрос, который здесь стоит закрыть: «замыкания медленные»? Каждое
имя в этом замере читается внутри функции, поэтому в число входит и вызов; в
последней колонке пустой вызов вычтен, то есть это само чтение. Это уже другой
прогон — bench/calls/cost.py, блок 5:
| что читается | вызов + чтение, нс | чтение, нс |
|---|---|---|
локальное имя — LOAD_FAST | 19,31 | 3,96 |
имя из ячейки — LOAD_DEREF | 19,25 | 3,91 |
глобальное имя — LOAD_GLOBAL | 19,13 | 3,78 |
для сравнения: f0() — пустой вызов | 15,35 | 0,00 |
Три разные инструкции чтения дали 3,96, 3,91 и 3,78 наносекунды — то есть различаются меньше чем на пять процентов, и каждая — примерно четверть пустого вызова. Платят не за чтение из ячейки, а за то, что вообще делается вызов.
Часть III. Практика
Практика · что напечатает
def make():
out = []
for i in range(3):
out.append(lambda: i)
return out
def make_bound():
out = []
for i in range(3):
out.append(lambda i=i: i)
return out
class C:
def m(self):
return 1
o = C()
print([f() for f in make()])
print([f() for f in make_bound()])
print(o.m is o.m, o.m == o.m)Практика · оцените
Часть IV. Что из этого следует
Четыре вывода, которые держатся на замере
Не переписывать o.m() в bound = o.m. На быстром пути объект связанного
метода не создаётся, экономить нечего, а такая правка его возвращает — и дешевле
не становится: 18,34 против 17,74 наносекунды, причём разница лежит внутри
разброса между раундами. Единственный случай, когда заранее связанный метод
уместен, — когда его надо куда-то передать, и тогда речь не об экономии.
Не выбирать staticmethod ради скорости. При одинаковом способе чтения он
медленнее обычного метода в полтора раза, а classmethod — почти втрое.
Выбирать их стоит по смыслу: нужен ли методу экземпляр или класс.
Из звёздочек опасаться стоит только одной. f(**kwargs) на горячем пути с пустым телом
стоит впятеро дороже прямой передачи; f(*args) — шестнадцать процентов, и о
ней можно не думать. Кратность зависит от того, сколько работает сама функция:
на теле из пятидесяти витков арифметики от пятикратной разницы остаётся шесть
процентов. Смотреть надо не на коэффициент, а на то, сравнимо ли тело функции с
ценой вызова.
Не бояться замыканий из-за цены. Чтение из ячейки неотличимо по времени от чтения локальной и глобальной переменной, и каждое — лишь доля цены вызова. Бояться в замыканиях стоит другого: ячейка одна на всех, кто видит переменную.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.12 | Специализации вызова те же, что дальше, но у некоторых другие имена: специализация len называется CALL_NO_KW_LEN. Ячейки, __closure__ и co_cellvars ведут себя так же, как в 3.13 и 3.14, — вывод bench/calls/cells.py совпадает во всём, кроме адресов в repr ячейки. | |
| 3.13 | Специализация len переименована в CALL_LEN. Это и есть иллюстрация правила: ни одного из этих имён нет в dis.opmap и в документации модуля dis, и опираться на них в коде нельзя. | |
| 3.14 | Набор инструкций на разобранных путях вызова тот же, что в 3.13. Поведение ячеек не менялось ни разу за три версии. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.
Время — две записи, обе на одной сборке:
bench/calls/cost.py— пятнадцать форм вызова и три утвержденияbench/calls/forms.py— матрица форм метода, разброс по раундам, звёздочки на непустой функции
Байт-код и объекты — сравнимы между версиями, записи на всех трёх:
Числа для задач раздела «Практика»:
Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU.
Границы у трёх видов утверждений статьи разные, и стоит их развести:
- Поведение — одинаково на всех трёх версиях:
o.mбез вызова создаёт объект метода,o.m is o.mдаётFalse, одна ячейка приходится на переменную. Проверено на 3.12.3, 3.13.7 и 3.14.7. - Имена специализаций — свойство версии: на 3.12.3 специализация
lenназываетсяCALL_NO_KW_LEN, на 3.13.7 и 3.14.7 —CALL_LEN. Это свойства кода, и сравнивать их между версиями можно. - Время — только 3.13.7 с включённым GIL, и только внутри каждого из двух прогонов по отдельности. Между версиями оно не сравнивается вовсе: у сборок разные компиляторы и разные флаги. Свободнопоточные сборки здесь не мерились.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
На самом деле
- Объект не создаётся. Интерпретатор видит, что сразу за чтением атрибута стоит вызов, и кладёт на стек функцию и экземпляр по отдельности — инструкция
LOAD_ATTR_METHOD_WITH_VALUESвместо обычногоLOAD_ATTR. Отсюда и число:o.m()стоит 17,74 нс против 15,35 у вызова функции, то есть в 1,16 раза. Объект действительно создаётся, когда его просят без вызова, и это проверяется одной строкой:o.m is o.mдаётFalse. - Не дешевле — а что дороже, замер не доказывает. В одном прогоне это 18,34 против 17,74 нс; в другом, где печатается и разброс, разрыв 1,28 нс при разбросе до 2,24 нс у того же
o.m(). Выигрыша нет ни в одном. Экономить нечего — на быстром пути объект связанного метода и так не создавался, — а такая правка его возвращает, и вызов идёт через него:CALL_BOUND_METHOD_EXACT_ARGSвместо пары «чтение плюс вызов». Заранее связанный метод уместен тогда, когда его надо куда-то передать; ради скорости — нет. - Медленнее — и это видно, только когда способ чтения зафиксирован. В отдельном прогоне, где сняты обе оси,
o.s()стоит ×1,54 отo.m()при чтении через экземпляр, а сам способ чтения добавляет ×1,07. Исходное сравнениеC.s()сo.m()смешивало и то и другое. Причина в байт-коде: уC.s()лишнийLOAD_ATTR— сначала читается класс, потом атрибут на нём, — тогда какo.m()обходится специализацией на экземпляре.classmethodещё дороже — ×2,72 от обычного метода в том же прогоне. Выбирать между ними стоит по смыслу, а не по скорости. - Дорога одна форма из пяти, и только на пустой функции. Распаковка кортежа
f3(*args)стоит ×1,16 к прямой передаче, приём в*argsи**kwargs— ×2,10 и ×2,29. А вотf3(**kwargs)стоит ×5,17 — но на функции с телом из пятидесяти витков арифметики от этой пятикратности остаётся ×1,06, и причина в том, в каком виде вызываемый объект получает аргументы: args is a C array consisting of the positional arguments followed by the values of the keyword arguments, а имена едут отдельным кортежем. Позиционные аргументы уже лежат массивом, словарь — нет: его приходится разобрать на массив значений и кортеж имён. - Оно держит ячейку — отдельный объект, в котором лежит значение. Отсюда всё остальное: две функции, созданные рядом, ссылаются на одну и ту же ячейку (
increment.__closure__[0] is read.__closure__[0]даётTrue), и изменение через одну видно через другую. Ячейка — часть модели данных, а не деталь CPython: Cell objects are used to implement variables referenced by multiple scopes. - Отдельного правила нет. Ячейка заводится на переменную, переменная в цикле одна, и все три функции ссылаются на одну и ту же ячейку: прогон считает разные ячейки и находит одну. К моменту вызова в ней лежит последнее значение — отсюда
[2, 2, 2]. Записьlambda i=i: iне «чинит связывание», а избавляет от нужды в общей ячейке: у этих функций__closure__пуст вовсе, а значение вычислено при создании каждой. - Три инструкции чтения —
LOAD_FAST,LOAD_DEREFиLOAD_GLOBAL— стоят 3,96, 3,91 и 3,78 наносекунды, то есть неразличимы в пределах шума машины, и каждая — лишь доля пустого вызова (15,35 нс). Платят не за чтение из ячейки, а за то, что вообще делается вызов. - Ни одного из них нет в
dis.opmap— прогон проверяет это перечислением; в документации модуляdisони тоже не описаны. Они видны только черезdis.dis(..., adaptive=True)и меняются между выпусками: специализацияlenна 3.12.3 называетсяCALL_NO_KW_LEN, на 3.13.7 и 3.14.7 —CALL_LEN. Опираться на эти имена в коде нельзя; смотреть на них, чтобы понять, что происходит, — можно и нужно.
По версиям
- 3.12
- Специализации вызова те же, что дальше, но у некоторых другие имена: специализация
lenназываетсяCALL_NO_KW_LEN. Ячейки,__closure__иco_cellvarsведут себя так же, как в 3.13 и 3.14, — выводbench/calls/cells.pyсовпадает во всём, кроме адресов вreprячейки.< - 3.13
- Специализация
lenпереименована вCALL_LEN. Это и есть иллюстрация правила: ни одного из этих имён нет вdis.opmapи в документации модуляdis, и опираться на них в коде нельзя.< - 3.14
- Набор инструкций на разобранных путях вызова тот же, что в 3.13. Поведение ячеек не менялось ни разу за три версии.<
Что разобрано
- Часть I. Цена вызова
- Что вообще меряется
- Три утверждения и что с ними стало
- Сначала — что здесь сравнивается с чем
- Почему так: что именно не происходит
- Звёздочки: дорога одна форма из пяти
- Часть II. Ячейки
- Замыкание держит не значение, а коробку
- Две функции, одна коробка
- Почему цикл выдаёт три одинаковые функции
- Кто решает, что переменная станет ячейкой
- Зачем коробка вообще нужна и при чём тут `nonlocal`
- Во что обходится чтение из ячейки
- Часть III. Практика
- Часть IV. Что из этого следует
- Четыре вывода, которые держатся на замере
- История версий
- Чем измерено
Унести в работу
- Не связывать метод заранее ради скорости:
bound = o.mне дешевле вызова через точку и к тому же возвращает объект связанного метода, которого на быстром пути не было. - Из звёздочек опасаться стоит одной:
f(**kwargs)впятеро дороже прямой передачи на пустой функции,f(*args)— на шестнадцать процентов. На функции с телом от пятикратной разницы остаётся шесть процентов. - Не переносить кратности на свой код: они сняты на пустой функции и на трёх аргументах. Переносится то, какая форма передачи дороже остальных, а не коэффициент.
- Помнить, что замыкание держит ячейку, а не значение: одна ячейка на весь цикл даёт
[2, 2, 2], и лечится это аргументом по умолчанию или отдельным вызовом. - Не бояться замыканий по скорости: чтение из ячейки неотличимо по времени от чтения локальной переменной.
Частые заблуждения
Вызов метода заметно дороже вызова функции: сначала создаётся объект связанного метода
Объект не создаётся. Интерпретатор видит, что сразу за чтением атрибута стоит вызов, и кладёт на стек функцию и экземпляр по отдельности — инструкция LOAD_ATTR_METHOD_WITH_VALUES вместо обычного LOAD_ATTR. Отсюда и число: o.m() стоит 17,74 нс против 15,35 у вызова функции, то есть в 1,16 раза. Объект действительно создаётся, когда его просят без вызова, и это проверяется одной строкой: o.m is o.m даёт False.
Связать метод заранее (bound = o.m) дешевле, чем вызывать через точку
Не дешевле — а что дороже, замер не доказывает. В одном прогоне это 18,34 против 17,74 нс; в другом, где печатается и разброс, разрыв 1,28 нс при разбросе до 2,24 нс у того же o.m(). Выигрыша нет ни в одном. Экономить нечего — на быстром пути объект связанного метода и так не создавался, — а такая правка его возвращает, и вызов идёт через него: CALL_BOUND_METHOD_EXACT_ARGS вместо пары «чтение плюс вызов». Заранее связанный метод уместен тогда, когда его надо куда-то передать; ради скорости — нет.
staticmethod быстрее обычного метода: у него нет self
Медленнее — и это видно, только когда способ чтения зафиксирован. В отдельном прогоне, где сняты обе оси, o.s() стоит ×1,54 от o.m() при чтении через экземпляр, а сам способ чтения добавляет ×1,07. Исходное сравнение C.s() с o.m() смешивало и то и другое. Причина в байт-коде: у C.s() лишний LOAD_ATTR — сначала читается класс, потом атрибут на нём, — тогда как o.m() обходится специализацией на экземпляре. classmethod ещё дороже — ×2,72 от обычного метода в том же прогоне. Выбирать между ними стоит по смыслу, а не по скорости.
Звёздочки в вызове дорогие
Дорога одна форма из пяти, и только на пустой функции. Распаковка кортежа f3(*args) стоит ×1,16 к прямой передаче, приём в *args и **kwargs — ×2,10 и ×2,29. А вот f3(**kwargs) стоит ×5,17 — но на функции с телом из пятидесяти витков арифметики от этой пятикратности остаётся ×1,06, и причина в том, в каком виде вызываемый объект получает аргументы: args is a C array consisting of the positional arguments followed by the values of the keyword arguments
(args — это массив C, в котором сначала идут позиционные аргументы, а за ними значения именованных), а имена едут отдельным кортежем. Позиционные аргументы уже лежат массивом, словарь — нет: его приходится разобрать на массив значений и кортеж имён.
Замыкание запоминает значение переменной
Оно держит ячейку — отдельный объект, в котором лежит значение. Отсюда всё остальное: две функции, созданные рядом, ссылаются на одну и ту же ячейку (increment.__closure__[0] is read.__closure__[0] даёт True), и изменение через одну видно через другую. Ячейка — часть модели данных, а не деталь CPython: Cell objects are used to implement variables referenced by multiple scopes
(Объекты-ячейки используются, чтобы реализовать переменные, на которые ссылаются несколько областей видимости).
Цикл создаёт три функции с одинаковым ответом из-за «позднего связывания» — это отдельное правило Python
Отдельного правила нет. Ячейка заводится на переменную, переменная в цикле одна, и все три функции ссылаются на одну и ту же ячейку: прогон считает разные ячейки и находит одну. К моменту вызова в ней лежит последнее значение — отсюда [2, 2, 2]. Запись lambda i=i: i не «чинит связывание», а избавляет от нужды в общей ячейке: у этих функций __closure__ пуст вовсе, а значение вычислено при создании каждой.
Замыкания медленнее обычных функций: чтение из ячейки дороже
Три инструкции чтения — LOAD_FAST, LOAD_DEREF и LOAD_GLOBAL — стоят 3,96, 3,91 и 3,78 наносекунды, то есть неразличимы в пределах шума машины, и каждая — лишь доля пустого вызова (15,35 нс). Платят не за чтение из ячейки, а за то, что вообще делается вызов.
Имена специализаций вроде CALL_PY_EXACT_ARGS — часть языка, их можно найти в документации
Ни одного из них нет в dis.opmap — прогон проверяет это перечислением; в документации модуля dis они тоже не описаны. Они видны только через dis.dis(..., adaptive=True) и меняются между выпусками: специализация len на 3.12.3 называется CALL_NO_KW_LEN, на 3.13.7 и 3.14.7 — CALL_LEN. Опираться на эти имена в коде нельзя; смотреть на них, чтобы понять, что происходит, — можно и нужно.
Проверьте себя
Почему o.m() почти не дороже вызова обычной функции?
Источники и что читать дальше
6 ИСТОЧНИКОВ
- PEP 590 — Vectorcall: a fast calling protocol for CPythonPEP. Документ, которым введён протокол, лежащий под всеми числами этой статьи. Задача названа в абстракте: «This PEP introduces a new C API to optimize calls of objects. It introduces a new "vectorcall" protocol and calling convention» (Этот PEP вводит новый интерфейс на C для ускорения вызовов объектов. Он вводит новый протокол и соглашение о вызове — vectorcall). Откуда бралась прежняя цена, сказано в разделе Motivation: «The poor performance is largely a result of having to create intermediate tuples, and possibly intermediate dicts, during the call» (Слабая производительность во многом следствие того, что во время вызова приходится создавать промежуточные кортежи, а иногда и промежуточные словари). Статус Final, реализован в 3.8. Как именно аргументы едут по этому протоколу, описано не здесь, а в справочнике C API, — см. следующий источник.https://peps.python.org/pep-0590/
- Протокол вызова: в каком виде вызываемое получает аргументыОфициальная документация. Место, из которого взято объяснение, почему распаковка словаря дороже распаковки кортежа: «args is a C array consisting of the positional arguments followed by the values of the keyword arguments» (args — это массив C, в котором сначала идут позиционные аргументы, а за ними значения именованных) и «kwnames is a tuple containing the names of the keyword arguments; in other words, the keys of the kwargs dict» (kwnames — это кортеж с именами именованных аргументов, то есть с ключами словаря kwargs). Позиционные аргументы уже лежат в нужном виде, словарь — нет: его приходится разобрать на массив значений и кортеж имён.https://docs.python.org/3/c-api/call.html
- Objects/classobject.c — что такое связанный методИсходный код CPython. Структура
PyMethodObjectиз трёх полей —im_func,im_selfи слабые ссылки — и функцияmethod_vectorcall, через которую идёт вызов уже созданного связанного метода. Именно этот объект НЕ создаётся на путиo.m(), и именно он создаётся, когда метод связывают заранее. Читалось по тегу v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/classobject.c - dis — список инструкцийОфициальная документация. Приводится как источник ОТСУТСТВИЯ: ни
CALL_PY_EXACT_ARGS, ниLOAD_ATTR_METHOD_WITH_VALUES, ниCALL_BOUND_METHOD_EXACT_ARGSв этом документе не описаны, и ни одного из них нет вdis.opmap— что прогон и печатает перечислением. Они видны только черезdis.dis(..., adaptive=True)и между выпусками меняются: на 3.12.3 специализацияlenназываетсяCALL_NO_KW_LEN, на 3.13.7 и 3.14.7 —CALL_LEN.https://docs.python.org/3/library/dis.html - Модель данных: объекты-функции и связывание методовОфициальная документация. Нормативное описание того, что происходит при
o.m: «When a non-data attribute of an instance is referenced, the instance's class is searched. If the name denotes a valid class attribute that is a function object, references to both the instance object and the function object are packed into a method object» (Когда запрашивается не-данный атрибут экземпляра, поиск идёт в классе экземпляра. Если имя обозначает допустимый атрибут класса, который является объектом-функцией, ссылки на объект-экземпляр и на объект-функцию упаковываются в объект-метод). Именно это и НЕ выполняется буквально на быстром пути вызова — при том что наблюдаемое поведение остаётся тем же, иo.mбез вызова объект по-прежнему создаёт.https://docs.python.org/3/tutorial/classes.html#method-objects - Модель данных: объекты-ячейкиОфициальная документация. Ячейка описана как обычный объект языка: «Cell objects are used to implement variables referenced by multiple scopes. For each such variable, a cell object is created to store the value; the local variables of each stack frame that references the value contain a reference to the cells from outer scopes which also use that variable» (Объекты-ячейки используются, чтобы реализовать переменные, на которые ссылаются несколько областей видимости. Для каждой такой переменной создаётся объект-ячейка для хранения значения; локальные переменные каждого кадра, ссылающегося на это значение, содержат ссылку на ячейки из внешних областей, которые тоже используют эту переменную). Отсюда и то, что показывает фигура: ячейка одна на все функции, которые видят переменную, и живёт она дольше кадра.https://docs.python.org/3/c-api/cell.html