Search
Write a publication
Pull to refresh
498.99
OTUS
Развиваем технологии, обучая их создателей

Как изменения в Python сделали старую оптимизацию бесполезной

Level of difficultyMedium
Reading time11 min
Views2.3K
Original author: Abhinav Upadhyay

Глубокий анализ разрешения имен в 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, и результаты показывают, что ответ: не особо. Что изменилось?

Performance of global vs local object access across recent CPython releases
Производительность доступа к глобальным и локальным объектам в последних версиях CPython

Чтобы ответить на этот вопрос, нам нужно разобраться, как 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 напрямую:

The bytecode disassembly for the slow (unoptimized) version
Диссассемблирование байт-кода для медленной (неоптимизированной) версии

Давайте разберем, что происходит в этих выделенных инструкциях:

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

  2. LOAD_FAST. После загрузки функции, которую нужно вызвать, интерпретатору нужно поместить все аргументы. В данном случае len принимает только один аргумент — объект списка. Это делается с помощью инструкции LOAD_FAST. Она загружает объект lst из локальных переменных, используя прямой индекс в массиве локальных переменных, поэтому здесь не происходит хеширования или поиска в словаре. Это просто доступ к массиву, что делает операцию очень быстрой.

  3. CALL. Далее интерпретатор должен выполнить вызов функции. Это делается с помощью инструкции CALL. Число, следующее за CALL, указывает интерпретатору, сколько аргументов передается. Например, CALL 1 означает, что передается один аргумент. Для выполнения вызова интерпретатор извлекает нужное количество аргументов из стека, затем извлекает сам объект функции. После этого вызывается функция с этими аргументами, и возвращаемое значение помещается обратно в стек.

Одним из наиболее затратных шагов здесь является LOAD_GLOBAL, как по тому, что она делает, так и по тому, как она реализована. Мы уже видели, что она включает в себя поиск имени в массиве co_names, хеширование и проверку двух словарей — globals() и builtins(), прежде чем результат будет помещен в стек. Все это делает её заметно медленнее, чем простой доступ к локальным переменным.

Чтобы понять, сколько работы выполняется под капотом, давайте теперь взглянем на её фактическую реализацию в CPython.

The implementation of the LOAD_GLOBAL instruction in CPython from the file generated_cases.c.h
Реализация инструкции LOAD_GLOBAL в CPython из файла generated_cases.c.h

Код взят из файла generated_cases.c.h, который содержит все реализации опкодов. Давайте сосредоточимся на выделенных частях, которые я пронумеровал.

  1. Первый выделенный блок касается специализации инструкции. Как мы увидим позже, стандартный способ поиска глобальных переменных медленный, потому что интерпретатор не знает, какой именно глобальный символ нужно загрузить и откуда. Эта информация доступна интерпретатору только во время выполнения. Специализация инструкций кэширует эту динамическую информацию и создает специализированную инструкцию, что делает будущие выполнения того же кода быстрее. Мы вернемся к этому вопросу в следующем разделе. Обратите внимание, что эта оптимизация появилась только в CPython 3.11.

  2. Второй выделенный блок — это место, где происходит сам поиск глобальной переменной. Этот процесс разбит на два шага, которые я отметил стрелками, подписанными как 3 и 4.

  3. Первоначально интерпретатору нужно понять, какое именно имя ему нужно искать. Инструкция LOAD_GLOBAL получает аргумент (oparg), который является индексом в кортеже co_names. Здесь хранятся все глобальные и встроенные имена, используемые в функции. Интерпретатор вызывает макрос GETITEM, чтобы извлечь реальное имя (строку) с использованием этого индекса.

  4. Как только имя извлечено, интерпретатор вызывает функцию PyEvalLoadGlobalStackRef. Эта функция сначала ищет имя в словаре globals. Если оно не найдено там, она выполняет поиск в словаре builtins.

Теперь давайте подробнее рассмотрим эту часть и посмотрим на код выполнения поиска в globals и builtins. Функция PyEvalLoadGlobalStackRef просто делегирует выполнение функции PyDictLoadGlobalStackRef, которая определена в файле dictobject.c, поэтому давайте непосредственно посмотрим на её реализацию (показано на рисунке ниже).

The function where the actual global lookup is performed
Функция, где выполняется фактический поиск глобальных переменных

Вот что происходит в этом коде:

  1. Сначала функция вычисляет хеш имени, которое нужно найти. Этот хеш определяет индекс в внутренней хеш-таблице словаря.

  2. Далее функция проверяет словарь globals.

  3. Если имя не найдено в globals, функция выполняет поиск в словаре builtins.

Из всего обсуждения поиска глобальных переменных в CPython несколько моментов стоит выделить:

  • Поиск требует вычисления хеша. Это означает, что при многократном вызове функции в цикле, во время выполнения будет вычисляться хеш каждый раз. Однако стоит отметить, что хеши строк кэшируются, и поэтому накладные расходы не так велики, как это может показаться на первый взгляд.

  • Еще одно важное замечание: builtins проверяются последними. То есть, даже если вы вызываете встроенную функцию, интерпретатор сначала проверяет globals, а затем уже builtins. В горячем цикле, где важна производительность, эти детали имеют значение.

Теперь давайте разберем дизассемблирование кода с оптимизацией.

Диссассемблирование оптимизированного байт-кода Python

Давайте посмотрим, как использование локального псевдонима изменяет байт-код и почему это делает оптимизированную версию быстрее. Следующая иллюстрация показывает дизассемблирование байт-кода для этой версии:

The bytecode disassembly for the optimized Python code
Диссассемблирование байт-кода для оптимизированной версии, где мы создаем локальный псевдоним для функции len

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

The implementation of the LOAD_FAST instruction in CPython
Реализация инструкции 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, выполняя быстрый поиск в массиве вместо дорогого доступа к словарю. Следующая иллюстрация показывает её реализацию.

The implementation of the LOAD_GLOBAL_BUILTIN instruction in CPython. It is a specialized version of the LOAD_GLOBAL instruction to directly lookup the builtins, skipping the globals check.
Реализация инструкции LOAD_GLOBAL_BUILTIN в CPython. Это специализированная версия инструкции LOAD_GLOBAL, предназначенная для прямого поиска в builtins, минуя проверку глобальных переменных.

Давайте разберем выделенные части:

  1. Сначала инструкция выполняет несколько проверок, чтобы убедиться, что условия, при которых была специализирована инструкция LOAD_GLOBAL на эту специализированную версию, все еще действуют. Если условия больше не выполняются, она возвращается к универсальной реализации LOAD_GLOBAL.

  2. Затем она читает закэшированное значение индекса. Этот индекс основан на хеш-значении, вычисленном при последнем выполнении LOAD_GLOBAL. Это означает, что эта инструкция специализирована для поиска только функции len.

  3. Далее идет поиск в словаре builtins. Для этого необходимо сначала получить доступ к ключам в словаре.

  4. Из этих ключей она получает список записей во внутренней хеш-таблице и выполняет поиск с использованием закэшированного индекса. Если она находит запись, это тот объект, который мы пытались загрузить.

Как видите, дорогой поиск в хеш-таблице превратился в поиск в массиве с использованием известного индекса, что почти приравнивается к выполнению инструкции 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.

The benchmark results for accessing a function with fully qualified name, a locally aliased name and directly importing it from the module.
Результаты бенчмарка для доступа к функции с полным именем, с локальным псевдонимом и при прямом импорте из модуля.

В данном случае мы видим, что вызов math.sin (с полным именем) является самым медленным среди всех версий, а создание локального псевдонима — самым быстрым. Хотя вызов «math.sin» стал быстрее в последних версиях Python, он все еще уступает альтернативам по производительности.

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

Диссассемблирование байт-кода для вызова math.sin(10)
Диссассемблирование байт-кода для вызова 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". На странице курса можно записаться на открытые уроки, а также пройти вступительное тестирование, чтобы узнать свой уровень знаний для поступления на курс.

Чтобы узнать больше о курсах по тестированию и получить доступ к записям открытых уроков, переходите в телеграм-бот.

Tags:
Hubs:
+12
Comments5

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS