Deep Engineering
Экспертный·Опубликовано·3.12 · 3.13 · 3.14·35 МИН

Вызов функции: сколько стоит, что при этом не происходит и где живут ячейки

Три ходовых утверждения о цене вызова — «метод дороже функции», «связать метод заранее дешевле», «staticmethod быстрее» — замер не подтверждает ни одного, а одно опровергает прямо. Причина видна в байт-коде: на быстром пути объект связанного метода не создаётся вовсе. Отсюда же и вторая половина статьи — замыкания: переменная, которую видит вложенная функция, хранится через отдельный объект — ячейку, на которую кадр только ссылается, — и из этого следует всё остальное.

Полное техническое изложение

Про цену вызова в 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() — lambda15,36
clo() — замыкание с одной ячейкой20,88
o.m() — метод через точку17,74
bound() — метод, связанный заранее18,34
C.s() — staticmethod27,88
C.c() — classmethod46,89
o() — объект с __call__36,36
len_(data) — функция на C9,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, когда вызова нет.

Разница в том, что кладётся на стек. На пути с вызовом туда попадают функция и экземпляр по отдельности: связывать их в один объект не нужно, и объект связанного метода не создаётся вовсе. На пути без вызова он создаётся, потому что его запросили.

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

PYTHON
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,3615,720,37
o.m()17,2719,512,24
bound()18,5519,380,82
o.s()27,1227,540,42
C.c()49,3351,321,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) — приём в *args42,27×2,10
fstar(a=1) — приём в **kwargs46,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() до вызова increment0
read() после двух increment()2
значение в ячейке2

Ни одна из двух функций ничего другой не передавала. Они просто ссылаются на один и тот же объект, и is это подтверждает. Вот что такое «общая переменная» на самом деле — не магия области видимости, а разделяемый объект.

Почему цикл выдаёт три одинаковые функции

Отсюда же и классическая неожиданность:

PYTHON
[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:

гдеинструкция
в outerMAKE_CELL box
в outerSTORE_DEREF box
в innerCOPY_FREE_VARS
в innerLOAD_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, который делает присваивание записью в ячейку, — это и есть контракт.

деталь реализации · 3.13

А вот как это устроено — уже реализация 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_FAST19,313,96
имя из ячейки — LOAD_DEREF19,253,91
глобальное имя — LOAD_GLOBAL19,133,78
для сравнения: f0() — пустой вызов15,350,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)

Практика · оцените

Функция принимает три позиционных аргумента. Во сколько раз вызов f3(**kwargs) со словарём из тех же трёх значений дороже прямого f3(1, 2, 3)?
раза

Часть 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, и только внутри каждого из двух прогонов по отдельности. Между версиями оно не сравнивается вовсе: у сборок разные компиляторы и разные флаги. Свободнопоточные сборки здесь не мерились.

Унести в работу

  • Не связывать метод заранее ради скорости: 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. Опираться на эти имена в коде нельзя; смотреть на них, чтобы понять, что происходит, — можно и нужно.

Проверьте себя

Вопрос 1 из 6

Почему o.m() почти не дороже вызова обычной функции?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. 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/
  2. Протокол вызова: в каком виде вызываемое получает аргументыОфициальная документация. Место, из которого взято объяснение, почему распаковка словаря дороже распаковки кортежа: «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
  3. 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
  4. 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
  5. Модель данных: объекты-функции и связывание методовОфициальная документация. Нормативное описание того, что происходит при 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
  6. Модель данных: объекты-ячейкиОфициальная документация. Ячейка описана как обычный объект языка: «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