Глубокий анализ разрешения имен в Python, байт-кода и того, как CPython 3.11 тихо сделал популярную оптимизацию неактуальной.
Трюк оптимизации производительности заключается в понимании механики работы: написании кода, который облегчает его эффективное выполнение на аппаратном уровне. Ранее архитектуры процессоров развивались так быстро, что оптимизация могла устареть всего через несколько лет, потому что оборудование просто становилось лучше в выполнении одного и того же кода.
Та же идея применима и при написании кода для интерпретируемых языков, таких как Python. Иногда приходится использовать трюки, которые помогают виртуальной машине (VM) языка работать быстрее. Но так же, как и аппаратное обеспечение, виртуальная машина Python и компилятор продолжают развиваться. В результате оптимизации, которые раньше имели значение, теперь могут не иметь значимого эффекта.
Один из таких трюков оптимизации в Python заключается в создании локального псевдонима для функции, которая вызывается многократно в горячем цикле. Вот как это выглядит:
# Бенчмарк 1: Прямой вызов встроенной функции len
def test_builtin_global(lst: list):
for _ in range(1_000_000):
len(lst)
# Бенчмарк 2: Создание локального псевдонима для встроенной функции len
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
?
Интерпретатор проверяет три разных места при разрешении имен:
Локальные. Таблица переменных локальной области видимости, включая аргументы функций. В CPython это реализовано как массив (общий с стеком виртуальной машины). Компилятор генерирует инструкцию LOAD_FAST с заранее вычисленным индексом для извлечения значений из этой таблицы, что делает локальные обращения очень быстрыми.
Глобальные. Словарь глобальных переменных, включая импортированные модули и функции. Доступ к ним требует поиска по хешу с использованием имени переменной, что медленнее, чем доступ к локальному массиву.
Встроенные. Функции, такие как
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()
, при необходимости переходя к встроенным функциям. Этот многошаговый поиск делает инструкцию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
, которая используется для всех бинарных операций, таких как +
, -
, *, и /
. Она работает с любыми типами объектов, включая целые числа, строки, списки и так далее. Поэтому интерпретатор должен сначала проверять типы объектов во время выполнения, а затем направлять выполнение на соответствующую функцию.
Итак, как работает специализация инструкций? Когда инструкция байт-кода выполняется в первый раз, интерпретатор захватывает некоторую информацию о времени выполнения, такую как типы объектов, конкретную операцию, которую нужно выполнить, и так далее. Используя эту информацию, интерпретатор заменяет медленную универсальную инструкцию на более быструю специализированную.
Затем, каждый раз, когда та же строка Python-кода выполняется снова, интерпретатор выполняет специализированную инструкцию. Внутри специализированных инструкций интерпретатор всегда проверяет, что условия для специализации все еще актуальны. Если условия изменились, например, типы больше не совпадают, интерпретатор деоптимизирует и возвращается к более медленной инструкции.
Инструкция LOAD_GLOBAL
также является универсальной инструкцией. В этом случае интерпретатору нужно выполнить много дополнительной работы, такой как поиск имени символа, вычисление хеша и, наконец, выполнение поиска в словарях globals
и builtins
. Однако, как только интерпретатор понимает, что вы обращаетесь к конкретной встроенной функции, он специализирует LOAD_GLOBAL
в LOAD_GLOBAL_BUILTIN
.
Инструкция LOAD_GLOBAL_BUILTIN
оптимизирована так, чтобы напрямую проверять словарь builtins
, то есть она пропускает проверку словаря globals
. Она также кэширует индекс конкретной встроенной функции, которую мы пытаемся найти, что позволяет избежать вычисления хеша. В результате она работает почти так же, как LOAD_FAST
, выполняя быстрый поиск в массиве вместо дорогого доступа к словарю. Следующая иллюстрация показывает её реализацию.

Давайте разберем выделенные части:
Сначала инструкция выполняет несколько проверок, чтобы убедиться, что условия, при которых была специализирована инструкция
LOAD_GLOBAL
на эту специализированную версию, все еще действуют. Если условия больше не выполняются, она возвращается к универсальной реализацииLOAD_GLOBAL
.Затем она читает закэшированное значение индекса. Этот индекс основан на хеш-значении, вычисленном при последнем выполнении
LOAD_GLOBAL
. Это означает, что эта инструкция специализирована для поиска только функции len.Далее идет поиск в словаре
builtins
. Для этого необходимо сначала получить доступ к ключам в словаре.Из этих ключей она получает список записей во внутренней хеш-таблице и выполняет поиск с использованием закэшированного индекса. Если она находит запись, это тот объект, который мы пытались загрузить.
Как видите, дорогой поиск в хеш-таблице превратился в поиск в массиве с использованием известного индекса, что почти приравнивается к выполнению инструкции LOAD_FAST
. Это объясняет, почему в новых версиях CPython нам больше не нужно явно применять такие оптимизации, как создание локальной переменной для глобальной функции или объекта. Это автоматически оптимизируется.
Но действительно ли эта оптимизация создания локального псевдонима устарела? Возможно, нет. Давайте рассмотрим еще один бенчмарк.
Бенчмарк: импортированные функции против псевдонимов
Теперь давайте посмотрим на аналогичный бенчмарк, но с функцией из импортированного модуля, а не встроенной. Вот как выглядит код:
import timeit
import math
# Бенчмарк 1: Прямой вызов math.sin
def benchmark_math_qualified():
for i in range(1000000):
math.sin(i)
# Бенчмарк 2: Создание локального псевдонима для math.sin
def benchmark_math_alias():
mysin = math.sin
for i in range(1000000):
mysin(i)
# Бенчмарк 3: Вызов sin, импортированного через 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
, ради небольшой оптимизации с использованием локального псевдонима? Это зависит от ваших целей. Если эта часть кода критична для производительности и профилирование показывает, что эта строка является узким местом, тогда стоит рассмотреть такой подход. В противном случае читаемость может быть важнее.
Заключение
Ранее создание локального псевдонима для глобальных функций было значимой оптимизацией. В более ранних версиях Python глобальные поиски имели больше накладных расходов, и их избегание давало заметное улучшение производительности. С последними улучшениями в CPython, особенно в части специализации инструкций, эта разница значительно уменьшилась для многих случаев.
Тем не менее, не все поиски одинаковы. Доступ к функциям через модуль или через цепочку вложенных атрибутов может по-прежнему нести накладные расходы. Эффективным в таких случаях остатеся использование from module import name
или создание локального псевдонима.
Главный вывод заключается в том, что оптимизации не вечны. Они зависят от деталей работы среды исполнения языка, которая продолжает развиваться. То, что работало в прошлом, может уже не иметь значения сегодня. Если вы хотите производительность, важно понимать, как всё работает на самом деле. Этот контекст помогает понять, какие подходы стоит оставить, а какие — оставить в прошлом ради более чистого и простого кода.
Освоить фреймворк PyTest и автоматизировать тесты UI и API можно на курсе "Python QA Engineer". На странице курса можно записаться на открытые уроки, а также пройти вступительное тестирование, чтобы узнать свой уровень знаний для поступления на курс.
Чтобы узнать больше о курсах по тестированию и получить доступ к записям открытых уроков, переходите в телеграм-бот.