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

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

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

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

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

Первое замер почти не подтверждает. Второе не подтверждает тоже, а третье опровергает — и в обратную сторону: staticmethod не быстрее, а медленнее.

Объяснение у всех трёх одно, и оно не про наносекунды: на быстром пути вызова метода объект связанного метода НЕ СОЗДАЁТСЯ. Экономить на его создании нечего, а тот, кто «оптимизирует» вызов, связав метод заранее, этот объект возвращает.

Вторая половина статьи — про замыкания, и она здесь не за компанию. Ячейка — это то место, где живёт переменная, которую видит вложенная функция, и из её устройства следует и общая переменная у двух функций, и три одинаковых ответа из цикла, и почему nonlocal нужен.

Часть I. Цена вызова

Что вообще меряется

Почти все числа сняты на вызове ПУСТОЙ функции: тело ничего не делает, и меряется только то, что происходит вокруг тела. Две строки выпадают из этого нарочно, и обе стоят ради масштаба: p.x — чтение атрибута у объекта с __slots__, то есть самое дешёвое чтение, какое есть, и вызова в нём нет вовсе; len_(data) — вызов функции, написанной на C, а не на Python.

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); переносится на другое железо отношение, а не наносекунда. Время между версиями в этом проекте не сравнивается вовсе.

Три утверждения и что с ними стало

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(), и между ними различается СРАЗУ ДВА: вид метода и то, через что его читают — через экземпляр или через класс. Пока обе оси смешаны, разницу нельзя приписать виду метода: она могла взяться и от чтения через класс.

Поэтому обе оси разведены отдельным прогоном, в котором все шесть форм сняты вместе:

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

1. ЧТО ВЫБРАЛ ИНТЕРПРЕТАТОР ПОСЛЕ ПРОГРЕВА
------------------------------------------
  Каждая форма прогрета 200 вызовами, затем разобрана.
  форма вызова                инструкции после специализации
  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) и o.m — только чтение. Инструкция чтения атрибута РАЗНАЯ: 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,57 — вид метода, ×1,54 при одинаковом чтении через экземпляр. Байт-код 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. Числа там свои — это другой прогон, и с числами выше они не сравниваются; сравнивать надо разрыв внутри этого прогона с его же разбросом:

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:

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:

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

перевод

Объекты-ячейки используются, чтобы реализовать переменные, на которые ссылаются несколько областей видимости. Для каждой такой переменной создаётся объект-ячейка для хранения значения; локальные переменные каждого кадра, ссылающегося на это значение, содержат ссылку на ячейки из внешних областей, которые тоже используют эту переменную

«Для каждой такой ПЕРЕМЕННОЙ» — здесь всё. Ячейка заводится на переменную, а не на функцию и не на вызов.

Две функции, одна коробка

2. ДВЕ ФУНКЦИИ ДЕЛЯТ ОДНУ КОРОБКУ
---------------------------------
  increment.__closure__[0] is read.__closure__[0]   True
  read() до вызова increment                       0
  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 и не отдельное правило «позднее связывание». Ячейка заводится на переменную, переменная в цикле одна, и к моменту вызова в ней лежит последнее значение. Вторая строка не «чинит связывание», а обходит необходимость в общей коробке: значение попадает в аргумент по умолчанию, а он вычисляется в момент СОЗДАНИЯ функции — по разу на каждую.

Кто решает, что переменная станет ячейкой

Решает компилятор, один раз, и по коду видно, какое решение принято:

4. КТО РЕШАЕТ, ЧТО ПЕРЕМЕННАЯ СТАНЕТ ЯЧЕЙКОЙ
--------------------------------------------
  outer.__code__.co_varnames   ('param', 'local', 'inner')
  outer.__code__.co_cellvars   ('box',)
  inner.co_freevars            ('box',)

  box не попал в co_varnames, хотя присваивается в outer: компилятор
  увидел, что его берёт вложенная функция, и перевёл его в ячейку.
  Решение принято ОДИН РАЗ, при компиляции, и по коду видно, какое:

  в outer:
    MAKE_CELL box
    STORE_DEREF box
  в inner:
    COPY_FREE_VARS
    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, который делает присваивание записью в ячейку, — это и есть контракт.

деталь реализации · 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:

5. ЯЧЕЙКА ПЕРЕЖИВАЕТ КАДР, В КОТОРОМ РОДИЛАСЬ
---------------------------------------------
  функция short_lived вернулась, её кадра больше нет
  keeper() возвращает: 'значение из функции, которая уже вернулась'
  keeper.__closure__[0].cell_contents живо: True

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

Во что обходится чтение из ячейки

Последний вопрос, который здесь стоит закрыть: «замыкания медленные»? Это уже другой прогон — bench/calls/cost.py, блок 5:

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)

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

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

Частые заблуждения

Утверждение

Вызов метода заметно дороже вызова функции: сначала создаётся объект связанного метода

На самом деле

Объект не создаётся. Интерпретатор видит, что сразу за чтением атрибута стоит вызов, и кладёт на стек функцию и экземпляр по отдельности — инструкция 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