Команда Python for Devs подготовила перевод статьи о том, почему любимая оптимизация Python — присваивать глобальные функции локальным переменным — больше не даёт выигрыша. CPython 3.11 стал умнее, и старый хак l = len
уже почти бессмыслен.
Секрет оптимизации производительности заключается в «механической симпатии»: написании кода, который облегчает аппаратному обеспечению его эффективное выполнение. В прошлом микроархитектуры центральных процессоров развивались так быстро, что оптимизация могла устареть всего за несколько лет, потому что аппаратное обеспечение просто становилось лучше в выполнении того же кода.
Та же идея применима при написании кода на интерпретируемых языках, таких как Python. Иногда вам приходится использовать трюки, которые помогают виртуальной машине языка (VM) быстрее выполнять ваш код. Но так же, как улучшается аппаратное обеспечение, улучшаются и VM и компилятор Python. В результате, оптимизации, которые когда-то имели значение, могут больше не иметь его.
Одним из таких трюков для оптимизации в Python является создание локального псевдонима для функции, которую вы многократно вызываете внутри «горячего» цикла. Вот как это выглядит:
# Benchmark 1: Calling built-in len directly
def test_builtin_global(lst: list):
for _ in range(1_000_000):
len(lst)
# Benchmark 2: Aliasing built-in len to a local variable
def test_builtin_local(lst: list):
l = len
for _ in range(1_000_000):
l(lst)
Этот трюк работает благодаря тому, как Python резолвит имена переменных. Создание локального псевдонима заменяет глобальный поиск локальным, что значительно быстрее в CPython. Но стоит ли это делать до сих пор?
Я протестировал этот код на последних версиях Python, и результаты показывают, что ответ: не особо. Так что же изменилось?

Чтобы ответить на этот вопрос, нам нужно будет углубиться в то, как Python резолвит имена во время выполнения, и как это поведение изменилось в последних версиях. В частности, мы рассмотрим:
Почему этот трюк работал в ранних версиях Python
Что изменилось в последних версиях CPython, что сделало его в основном устаревшим
Остались ли ещё пограничные случаи, когда это помогает?
Как Python резолвит локальные и глобальные имена
Чтобы понять, почему этот приём повлиял на производительность, нам нужно рассмотреть, как интерпретатор Python резолвит имена переменных, в частности, как он загружает локально и глобально определённые объекты.
Python использует виртуальную машину, основанную на стеке. Это означает, что он вычисляет выражения, помещая операнды в стек и выполняя операции, извлекая эти операнды из него. Например, для вычисления a + b
интерпретатор помещает a
и b
в стек, извлекает их, выполняет сложение, а затем помещает результат обратно.
Вызовы функций работают таким же образом. Для вызова типа len(lst)
интерпретатор помещает в стек как объект функции len
, так и её аргумент lst
, затем извлекает их и использует для выполнения функции.
Но откуда интерпретатор находит и загружает такие объекты, как len
или lst
?
Интерпретатор проверяет три разных места при резолвинге имён:
Локальные переменные (Locals): Таблица локальных переменных, включая аргументы функции. В CPython она реализована как массив (совместно со стеком виртуальной машины). Компилятор генерирует инструкцию
LOAD_FAST
с предварительно вычисленным индексом для извлечения значений из этой таблицы, что делает поиск локальных переменных очень быстрым.Глобальные переменные (Globals): Словарь глобальных переменных, включая импортированные модули и функции. Доступ к ним требует поиска по хешу с использованием имени переменной, что медленнее, чем доступ к локальному массиву.
Встроенные функции (Builtins): Такие функции, как
len
,min
иmax
. Они находятся в отдельном словаре и проверяются в последнюю очередь, если имя не найдено в глобальных переменных.
Имея представление о том, как работает резолвинг имен в CPython, давайте теперь сравним дизассемблирование двух версий нашей бенчмарк-функции.
Анализ неоптимизированного байт-кода Python
Давайте посмотрим, что на самом деле происходит под капотом. Мы можем использовать встроенный модуль Python dis
для просмотра байт-кода, сгенерированного нашими функциями. Ниже представлено дизассемблирование более медленной версии, той, что вызывает len
напрямую:

Рассмотрим подробнее выделенные инструкции:
LOAD_GLOBAL: Эта инструкция загружает имя
len
из глобальной области видимости в стек. В дизассемблировании вы увидите что-то вродеLOAD_GLOBAL 3 (NULL + len)
. Число3
— это аргумент, передаваемый инструкции. Это индекс в массивco_names
, который представляет собой кортеж всех имен, используемых в функции для глобального поиска или поиска встроенных функций. Таким образом,co_names[3]
дает'len'
. Интерпретатор извлекает строку'len'
, хеширует ее и выполняет поиск в словареglobals()
, при необходимости переходя кbuiltins
. Этот многоступенчатый поиск делаетLOAD_GLOBAL
более затратным, чем другие инструкции резолвинга имен. (Мы рассмотрим, какLOAD_GLOBAL
реализован в CPython сразу после этого).LOAD_FAST: После загрузки вызываемой функции интерпретатору необходимо поместить в стек все аргументы. В данном случае
len
принимает только один аргумент — объект списка. Это делается с помощью инструкцииLOAD_FAST
. Она загружает объектlst
из локальных переменных, используя прямой индекс в массиве локальных переменных, поэтому здесь нет хеширования или поиска по словарю. Это просто прямой доступ к массиву, что делает эту операцию очень быстрой.CALL: Далее интерпретатору необходимо выполнить вызов функции. Это делается с помощью инструкции
CALL
. Число послеCALL
сообщает интерпретатору, сколько аргументов передается. Так,CALL 1
означает, что передается один аргумент. Для выполнения вызова интерпретатор извлекает из стека соответствующее количество аргументов, а затем сам объект функции. Затем он вызывает функцию с этими аргументами и помещает возвращаемое значение обратно в стек.
Одним из самых затратных шагов здесь является LOAD_GLOBAL
, как с точки зрения того, что он делает, так и с точки зрения его реализации. Мы уже видели, что он включает поиск имени в массиве co_names
, его хеширование и проверку двух словарей, globals()
и builtins()
, прежде чем результат будет помещен в стек. Все это делает его заметно медленнее, чем простой локальный доступ.
Чтобы понять, сколько всего происходит за кулисами, давайте рассмотрим реализацию этого механизма в CPython.

Код взят из файла generated_cases.c.h
, который содержит реализации всех опкодов. Давайте сосредоточимся на выделенных частях, которые я пронумеровал.
Первый выделенный блок отвечает за специализацию инструкций. Как мы увидим позже, стандартный способ поиска глобальных переменных медленный, потому что он не знает, какой глобальный символ мы пытаемся загрузить и откуда. Эта информация доступна интерпретатору только во время выполнения. Специализация инструкций кэширует эту динамическую информацию и создает специализированную инструкцию, что ускоряет последующие выполнения того же кода. Мы вернемся к этому в одном из следующих разделов. Отметим, что эта оптимизация отсутствовала до CPython 3.11.
Второй выделенный блок — это место, где происходит фактический поиск глобальных переменных. Он разбит на две части, которые я пометил стрелками с номерами 3 и 4.
Сначала интерпретатору нужно определить, какое имя он должен найти. Инструкция
LOAD_GLOBAL
принимает аргумент (oparg
), который является индексом в кортежеco_names
. Здесь хранятся все глобальные и встроенные имена, используемые в функции. Интерпретатор вызывает макросGETITEM
, чтобы получить фактическое имя (объект-строку), используя этот индекс.После получения имени интерпретатор вызывает функцию
PyEvalLoadGlobalStackRef
. Эта функция сначала ищет имя в словареglobals
. Если имя там не найдено, она обращается к словарюbuiltins
.
Давайте углубимся в эту часть и рассмотрим код, выполняющий поиск в globals
и builtins
. Функция PyEvalLoadGlobalStackRef
просто делегирует выполнение функции PyDictLoadGlobalStackRef
, определённой в файле dictobject.c
, поэтому давайте напрямую рассмотрим её реализацию (показана на рисунке ниже).

Вот что происходит в этом коде:
Сначала функция вычисляет хеш искомого имени. Этот хеш определяет индекс в таблице хешей словаря.
Затем функция проверяет словарь
globals
.Если имя не найдено в
globals
, функция обращается к словарюbuiltins
.
Из всего этого обсуждения глобального поиска в CPython стоит выделить несколько моментов:
Для поиска требуется вычисление хеша. Это означает, что при многократном вызове функции в цикле среда выполнения каждый раз вычисляет хеш. Тем не менее, хеши строк кэшируются, поэтому накладные расходы не так велики, как может показаться.
Ещё один момент, на который стоит обратить внимание:
builtins
пров��ряются в последнюю очередь. То есть, даже если вы вызываете встроенную функцию, среда выполнения всё равно сначала проверяетglobals
и только потомbuiltins
. В критически важных циклах, где производительность имеет значение, эти моменты важны.
Далее мы разберём дизассемблированный код с применением оптимизации.
Разбор оптимизированного байт-кода Python
Давайте посмотрим, как использование локального псевдонима влияет на байт-код и почему это ускоряет оптимизированную версию. На следующем рисунке показана дизассемблированная версия байт-кода:

Сосредоточимся на выделенных инструкциях, отвечающих за вызов l
, псевдонима, который мы создали для len
. Ключевое отличие между неоптимизированной и этой версией заключается в том, что последняя использует инструкцию LOAD_FAST
вместо LOAD_GLOBAL
для загрузки объекта функции в стек. Итак, давайте рассмотрим, как LOAD_FAST
реализована в CPython (показано на рисунке ниже).

Вы можете видеть, насколько коротка и лаконична эта реализация. Она выполняет простой поиск в массиве, используя переданный ей аргумент-индекс. В отличие от LOAD_GLOBAL
, которая включает несколько вызовов функций и поисков по словарю, LOAD_FAST
ничего не вызывает. Это просто прямой доступ к памяти, что делает её чрезвычайно быстрой.
Теперь вы должны чётко понимать, почему работает этот приём оптимизации. Создав локальную переменную для встроенной функции len
, мы превратили дорогостоящий глобальный поиск в быстрый локальный, что и обеспечивает разницу в производительности.
Но, как мы видели в результатах бенчмарка, начиная с CPython 3.11, эта оптимизация больше не даёт существенного прироста производительности. Так что же изменилось? Давайте рассмотрим это далее.
Внутри специализации инструкций CPython
В CPython 3.11 была представлена значительная оптимизация, называемая специализирующимся адаптивным интерпретатором. Она решает одну из основных проблем производительности в динамически типизированных языках. В таких языках инструкции байт-кода не зависят от типа, что означает, что они не знают, с какими типами объектов им предстоит работать. Например, в CPython есть общая инструкция BINARY_OP
, которая используется для всех бинарных операций, таких как +
, -
, *
и /
. Она работает со всеми типами объектов, включая целые числа, строки, списки и так далее. Поэтому интерпретатор должен сначала проверять типы объектов во время выполнения, а затем соответствующим образом вызывать нужную функцию.
Итак, как же работает специализация инструкций? Когда инструкция байт-кода выполняется в первый раз, интерпретатор фиксирует некоторую информацию о ней во время выполнения, такую как тип объектов, выполняемая конкретная операция и т. д. Используя эту информацию, он заменяет медленную общую инструкцию на более быструю специализированную.
Впоследствии, всякий раз, когда та же строка питонячего кода выполняется снова, интерпретатор выполняет специализированную инструкцию. Внутри специализированных инструкций интерпретатор всегда проверяет, остаются ли условия специализации в силе. Если условия изменились, например, типы больше не совпадают, то интерпретатор деоптимизирует и возвращается к более медленной инструкции.
Инструкция LOAD_GLOBAL
также является общей инструкцией. В этом случае интерпретатору приходится выполнять много дополнительной работы, такой как поиск имени символа, вычисление хеша и, наконец, выполнение поиска в словарях globals и builtins. Но как только интерпретатор видит, что вы обращаетесь к определённому встроенному объекту, он специализирует LOAD_GLOBAL
в LOAD_GLOBAL_BUILTIN
.
Инструкция LOAD_GLOBAL_BUILTIN
оптимизирована для прямого поиска в словаре builtins, то есть она пропускает проверку словаря globals. Она также кэширует индекс конкретного встроенного объекта, который мы пытаемся найти, что позволяет избежать вычисления хеша. В результате она ведет себя почти как LOAD_FAST
, выполняя быстрый поиск по массиву вместо дорогостоящего доступа к словарю. Следующий рисунок показывает её реализацию.

Разберём выделенные части:
Сначала инструкция выполняет несколько проверок, чтобы убедиться, что условия, по которым она специализировала инструкцию
LOAD_GLOBAL
в эту специализированную версию, всё ещё соблюдаются. Если условия больше не соблюдаются, она возвращается к общей реализацииLOAD_GLOBAL
.После этого он считывает кэшированное значение индекса. Оно основывается на хеш-значении, вычисленном в прошлый раз при выполнении
LOAD_GLOBAL
. Это означает, что данная инструкция специализирована для поиска только функцииlen
.Далее следует поиск в словаре встроенных функций. Для этого сначала необходимо получить доступ к ключам словаря.
Затем из ключей извлекается список записей во внутренней хеш-таблице, и производится поиск по кэшированному значению индекса. Если запись найдена, это и есть объект, который мы пытались загрузить.
Как видите, дорогостоящий поиск в хеш-таблице превратился в поиск по массиву с использованием известного индекса, что почти равно по объему работы инструкции LOAD_FAST
. Именно по этой причине в новых версиях CPython нам не нужно явно выполнять оптимизации, при которых мы создаем локальную переменную для глобальной функции или объекта. Это оптимизируется автоматически.
Но действительно ли эта оптимизация создания локального псевдонима устарела? Возможно, нет. Позвольте мне показать вам еще один бенчмарк.
Бенчмарк: импортированные функции против псевдонимов
Рассмотрим теперь аналогичный бенчмарк, на этот раз с функцией из импортированного модуля, а не встроенной. Вот как выглядит код:
import timeit
import math
# Benchmark 1: Calling math.sin directly
def benchmark_math_qualified():
for i in range(1000000):
math.sin(i)
# Benchmark 2: Aliasing math.sin to a local variable
def benchmark_math_alias():
mysin = math.sin
for i in range(1000000):
mysin(i)
# Benchmark 3: Calling sin imported via `from math import sin`
from math import sin
def benchmark_from_import():
for i in range(1000000):
sin(i)
Есть три бенчмарка:
benchmark_math_qualified
: вызываетmath.sin
напрямуюbenchmark_math_alias
: создает локальный псевдонимmysin
дляmath.sin
benchmark_from_import
: используетsin
, импортированный черезfrom math import sin
А следующая таблица демонстрирует результаты для последних релизов CPython.

В этом случае мы видим, что вызов math.sin
(полное имя) является самым медленным во всех релизах, а создание псевдонима — самым быстрым. Хотя прямой вызов math.sin
стал быстрее в последних версиях Python, он всё ещё отстаёт от альтернативных вариантов по производительности.
Разница в производительности здесь объясняется тем, как объект функции резолвится при использовании полного имени, такого как math.sin
. Это превращается в двухуровневый поиск. Например, на следующем рисунке показана дизассемблированная инструкция для вызова math.sin(10)
.

Обратите внимание, что теперь интерпретатору приходится выполнять две инструкции для загрузки объекта функции в стек: LOAD_GLOBAL
, за которой следует LOAD_ATTR
. LOAD_GLOBAL
загружает объект модуля math
в стек из глобальной области видимости. Затем LOAD_ATTR
выполняет поиск функции sin
в модуле math
и помещает объект функции в стек.
Таким образом, естественно, это требует гораздо больше работы. И объём работы увеличивается по мере увеличения количества уровней поиска. Например, foo.bar.baz()
требует трёх уровней поиска.
В последних релизах Python производительность вызова по полному имени также улучшилась благодаря специализации инструкций. Однако вам всё равно придётся выполнять несколько инструкций. Тогда как в случае локального псевдонима интерпретатору достаточно выполнить одну инструкцию LOAD_FAST
.
Стоит ли менять читаемость полного имени, такого как math.sin
, на небольшое ускорение путём создания псевдонима mysin
, зависит от ваших целей. Если эта часть кода критична к производительности, и ваше профилирование показывает, что эта строка является узким местом, то это стоит рассмотреть. В противном случае читаемость может быть важнее.
Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!
В заключение
Присвоение глобальных функций локальным переменным когда-то было значимой оптимизацией. В ранних версиях Python глобальный поиск требовал больших накладных расходов, и его избегание давало ощутимую разницу. С недавними улучшениями в CPython, особенно со специализацией инструкций, эта разница сократилась во многих случаях.
Тем не менее, не все поиски одинаковы. Доступ к функциям через модуль или длинную цепочку атрибутов все еще может нести накладные расходы. Создание локального псевдонима или использование from module import name
по-прежнему эффективно в таких ситуациях.
Главное, что оптимизации не вечны. Они зависят от деталей среды выполнения языка, которая постоянно развивается. То, что работало в прошлом, сегодня может быть уже неактуально. Если вам нужна производительность, полезно понимать, как все устроено на самом деле. Этот контекст позволяет легче понять, какие приемы стоит сохранить, а от каких можно отказаться в пользу более чистого и простого кода.