Я наткнулся на пост вX/Twitter, где Pritam обнаружил, чтоего решение наLeetcode работало медленнее, когда он использовал встроенную функцию min, производительность улучшилась, когда он реализовал min всвоем коде наPython.
Это правда, чтовызовы функций могутбыть затратными, и они, какизвестно, еще более затратны винтерпретируемых языках, таких какPython. Стандартная рекомендация— использовать встраивание функций, если они являются частью узкого места.
Автор наэтом скриншоте использовал Python 2, который наданный момент уже стал древностью. Запоследние 10лет Python 3получил множество релизов, и последние версиибыли нацелены наулучшение производительности языка. Так действительноли вызовы функций по‑прежнему так сильно влияют напроизводительность вPython?
Мне стало интересно, и я написал три микро‑бенчмарка, чтобы проверить три кейса:
Эффект отвызова встроенной функции вцикле
Эффект отвызова функции Python вцикле
Эффект отвстраивания функции вцикле
Каки ожидалось, результаты показывают, чтопоследние релизы значительно улучшили производительность CPython вовсех трех кейсах.
Вэтой статье я собираюсь обсудить конкретные улучшения, внесенные вCPython, которые повышают производительность интерпретатора. Рассмотрим причины медленной работы встарых версиях и какнововведения помогают исправить ситуацию. Давайте погрузимся вдетали.
Бенчмарк 1: Проверка скорости выполнения простых инструкций в цикле
Начнем спервого бенчмарка, вкотором мы выполняем простые вычисления внутри цикла, например, вычисляем минимум изсписка значений. Код приведен ниже. Он использует цикл while вместо for, так каквкоде изпоста вTwitter использовался цикл while, и я хотел сохранить его.
def benchmark1(heights): a = 1 b = len(heights) - 1 min_height = heights[0] while a < b: if heights[a] < min_height: min_height = heights[a] a += 1 return min_height
Ниже приведены показатели производительности дляпервого бенчмарка впоследних версиях CPython:
Этот бенчмарк просто измеряет скорость выполнения простых вычислений, вданном случае сравнение двух целых чисел внутри цикла. Какмы видим, интерпретаторбыл значительно ускорен споследними релизами. Теперь давайте обсудим, благодаря каким внутренним оптимизациям получилось увеличить скорость выполнения.
Появление суперинструкций
Одна изпростых оптимизаций, введенных вCPython,— суперинструкции. Это специальные инструкции байт‑кода, которые создаются путем объединения двух последовательных инструкций определенных типов, которые часто встречаются впарах впрограммах. Давайте разберемся, какэто работает вконтексте этого конкретного бенчмарка.
Наизображении ниже показан байт‑код тела цикла этого бенчмарка дляPython 3.14.0a0(слева) и Python 3.10(справа). Вцикле интерпретатору нужно многократно загружать значения heights[a]
и min_height
встек перед тем, каксравнить их. Длязагрузки этих значений встек интерпретатор использует инструкцию LOAD_FAST
.
Можно легко заметить разницу вбайткоде дляразных версий Python. Вверсия 3.10байткод содержит две последовательные инструкции LOAD_FAST
, тогда каквверсии 3.14они заменяются одной инструкцией LOAD_FAST_LOAD_FAST
.
Это пример суперинструкции. Она создается компилятором вовремя оптимизации после генерации начального байт‑кода программы. Ниже код этой оптимизации вCPython, котораябыла введена врелизе 3.13.
static intinsert_superinstructions(cfg_builder *g){ for (basicblock *b = g->g_entryblock; b != NULL; b = b->b_next) { for (int i = 0; i < b->b_iused; i++) { cfg_instr *inst = &b->b_instr[i]; int nextop = i+1 < b->b_iused ? b->b_instr[i+1].i_opcode : 0; switch(inst->i_opcode) { case LOAD_FAST: if (nextop == LOAD_FAST) { make_super_instruction(inst, &b->b_instr[i + 1], LOAD_FAST_LOAD_FAST); } break; case STORE_FAST: switch (nextop) { case LOAD_FAST: make_super_instruction(inst, &b->b_instr[i + 1], STORE_FAST_LOAD_FAST); break; case STORE_FAST: make_super_instruction(inst, &b->b_instr[i + 1], STORE_FAST_STORE_FAST); break; } break; } } } int res = remove_redundant_nops(g); assert(no_redundant_nops(g)); return res;}
Преимущества суперинструкций
Основное преимущество этой оптимизации заключается вснижении объема работы, выполняемой интерпретатором. Интерпретация каждой инструкции требует извлечения следующего опкода, его декодирования и перехода ккоду, где реализована эта байт‑код инструкция. Затраты наэто небольшие, ноесли код выполняется часто, то эффект отэтого можетбыть ощутимым.
Также это позволяет процессору оптимизировать цикл интерпретации— уменьшение количества инструкций приводит кменьшему количеству переходов, чтовсвою очередь приводит клучшей локальности кэша и повышению эффективности предсказателя переходов засчет освобождения таблицы переходов длядругих записей.
Более того, реализация инструкции LOAD_FAST_LOAD_FAST
винтерпретаторе позволяет процессору увеличить скорость ее выполнения, поскольку она включает всебя несколько независимых друг отдруга инструкций, которые могутбыть выполнены параллельно.
Специализация инструкций байт-кода
Вторая оптимизация, значительно улучшающая производительность дляэтого бенчмарка,— это специализация инструкций, введенная врелизе CPython 3.12.
Если вы снова посмотрите набайт‑код изпредыдущего раздела, вы заметите, чтоинтерпретатору нужно многократно выполнять COMPARE_OP
и BINARY_OP
длявыполнения операций сравнения и инкремента внутри цикла.
Эти инструкции относительно затратны ввыполнении, поскольку они связаны сдинамической диспетчеризацией. Я рассматривал, чтоименно происходит подкапотом встатье «How Many Lines of C it Takes to Execute a + b in Python?». Ниже краткое содержание.
Когда интерпретатору нужно обработать такие инструкции, какBINARY_OP
илиCOMPARE_OP
, он получает их операнды изстека. Интерпретатор незнает конкретные типы этих объектов‑операндов, будь то целые числа, строки, числа сплавающей запятой иличто‑то еще, и, следовательно, незнает, какобработать данный случай. Интерпретатор определяет, какобработать операцию, выполняя поиск указателя нафункцию внутри объектов‑операндов. Ноэто требует огромного количества операций поиска поуказателям.
Сначала интерпретатору нужно разыменовать объект‑операнд.
Затем нужно разыменовать указатель наполе PyTypeObject (ob_type), которое содержит таблицы указателей нафункции.
Затем интерпретатору нужно разыменовать таблицу указателей нафункции и найти указатель нафункцию.
Наконец, нужно разыменовать сам указатель нафункцию, чтобы вызвать функцию.
Следующая иллюстрация показывает этот процесс поиска поуказателям.
Такой уровень абстракции негативно влияет наскорость работы процессора, потому чтовсе эти разыменования указателей зависят отобращений кпамяти. Это означает, чтопроцессору нужно ждать завершения первого обращения кпамяти, прежде чем он сможет приступить кследующему. Это снижает пропускную способность выполнения инструкций, аесли какое‑либо изэтих обращений кпамяти приводит кпропуску вкэше, то это может вызвать длительную задержку всотни циклов дополучения данных изосновной памяти.
Ноблагодаря специализации медленные инструкции BINARY_OP
и COMPARE_OP
преобразуются вспециализированные, например BINARY_ADD_INT
, где операция сложения выполняется непосредственно винтерпретаторе безвыполнения переходов поуказателям.
Бенчмарк 2: Проверка скорости выполнения с использованием встроенной функции
Мы немного изменим предыдущий бенчмарк. Вместо вычисления минимума самостоятельно, воспользуемся встроенной функцией min. Код бенчмарка приведен ниже:
def benchmark2(heights): a = 1 b = len(heights) - 1 min_height = heights[0] while a < b: min_height = min(heights[a], min_height) a += 1 return min_height
Этот бенчмарк измеряет затраты, связанные свызовом встроенной функции. Следующая таблица показывает улучшение производительности CPython вновых версиях.
Произошло два изменения, скоторыми связано уменьшение времени выполнения с 17,33секунд вPython 3.10до 6,7секунд вPython 3.14.0a0. Давайте обсудим их.
Ускорение загрузки глобальных объектов
Давайте посмотрим набайт‑код этого бенчмарка.
Привыполнении этого кода интерпретатору необходимо загрузить встроенную функцию min()
встек. Дляэтого он использует инструкцию LOAD_GLOBAL
.
Инструкции LOAD_GLOBAL
необходимо найти глобальный именованный объект вдвух словарях. Первый словарь содержит все глобальные переменные втекущей области видимости, авторой— все встроенные функции.
Поиск всловарях выполняетсябыстро, нонемоментально. Благодаря специализации инструкций интерпретатор оптимизирует это винструкцию LOAD_GLOBAL_BUILTIN
.
Эта специализированная инструкция кэширует индекс объекта всловаре встроенных функций. Это позволяет избежать всего процесса поиска всловаре и просто возвращать объект покэшированному индексу. Следующая иллюстрация показывает, какинтерпретатор реализует LOAD_GLOBAL_BUILTIN
.
Оптимизация встроенных функций min и max
Специализация инструкции LOAD_GLOBAL
вLOAD_GLOBAL_BUILTIN
неявляется основной причиной впечатляющего результата последних версий Python врамках этого бенчмарка. Реальная причина— это специфическая оптимизация, примененная квстроенным функциям min
и max
.
Винтерпретаторе есть два соглашения длявызова функций: старое— tp_call
и новое— vectorcall
.
Прииспользовании tp_call
создаются промежуточные кортежи и словари дляпередачи аргументов функции, чтоможет приводить кзатратам насоздание других промежуточных объектов (более подробно это описано вPEP 0590). Прииспользовании vectorcall
аргументы передаются какчасть вектора, чтоустраняет необходимость всоздании множества промежуточных объектов.
Дорелиза CPython 3.13встроенные функции min
и max
вызывались сиспользованием tp_call
. Это приводило ктому, чточастый вызов функций приводил квыделению и освобождению множества промежуточных объектов. Переход наvectorcall
улучшил производительность этих встроенных функций вплоть до 200%, авнашем бенчмарке видно улучшение более чем на 150%.
Дляполучения дополнительного контекста вы можете ознакомиться сPR этого изменения.
Бенчмарк 3: Проверка скорости выполнения с использованием функции Python
Наконец, давайте обсудим изменения, которые стали причиной улучшения производительности втретьем бенчмарке, который реализует min какфункцию Python и вызывает ее внутри цикла. Код приведен ниже.
def pymin(a, b): if a <= b: return a return bdef benchmark3(heights): a = 1 b = len(heights) - 1 min_height = heights[0] while a < b: min_height = pymin(heights[a], min_height) a += 1 return min_height
Сравнение результатов выполнения данного бенчмарка дляпоследних релизов CPython:
Согласно данным втаблице, производительность значительно улучшилась с 3.10до 3.12, азатем немного улучшилась для 3.14.0a0.
Этот бенчмарк посути измеряет затраты навыполнение вызова функции Python‑to‑Python (поскольку и вызывающая, и вызываемая функции реализованы наPython).
Доверсии 3.11обработка вызовов функций Python‑to‑Python винтерпретаторебыла сложной и затратной, поскольку это приводило крекурсивному вызову самого интерпретатора дляобработки каждого такого вызова. Эта рекурсиябыла встроена врелизе CPython 3.11, чтопривело кзначительному приросту производительности. Давайте разберем это более детально.
Встраивание вызовов функций Python-to-Python
Интерпретатор начинает выполнение программы Python сфункции main. Впервую очередь подготавливается фрейм стека функции main, после чего идет вызов интерпретатора. Точка входа винтерпретатор— это функция _PyEval_EvalFrameDefault
, определенная вceval.c.
Чтотакое фрейм стека? Выполнение каждой функции ввашем коде требует соответствующего фрейма. Он содержит локальные и глобальные переменные этой функции, скомпилированный байт‑код, указатель наинструкцию и другие данные, которые помогают интерпретатору выполнить код. Дляполучения более подробной информации вы можете посмотреть запись моей лекции о внутреннем устройстве виртуальной машины CPython.
Функция _PyEval_EvalFrameDefault
содержит огромный оператор switch дляобработки всех инструкций байт‑кода, поддерживаемых интерпретатором. Функция перебирает инструкции полученной функции и выполняет соответствующие операции.
Когда вы вызываете другую функцию ввашем коде Python, это приводит кгенерации инструкции байт‑кода CALL
. Вот тут все и становится интереснее.
ВCPython 3.10и ранее инструкция CALL
создавала новый фрейм стека длявызываемой функции, азатем рекурсивно возвращалась винтерпретатор, вызывая его точку входа _PyEval_EvalFrameDefault
.
Это негативно влияет напроизводительность со многих сторон. Рекурсивный вызов интерпретатора требовал сохранения регистров текущей функции и создания нового фрейма стека. Это увеличивало использование памяти, поскольку каждый рекурсивный вызов интерпретатора выделял собственные локальные переменные встеке и выполнял другие выделения вкуче. Кроме того, это приводило кплохой локальности кэша инструкций из‑за постоянных прыжков изцикла оценки байт‑кода и обратно.
Врелизе 3.11этобыло исправлено засчет устранения рекурсивного вызова интерпретатора. Теперь инструкция CALL
просто создает фрейм стека длявызываемой функции, азатем сразу начинает выполнение байт‑кода новой функции, непокидая цикла.
Вы можете ознакомиться собсуждением этого изменения в багтрекере CPython.
Специализация инструкции CALL
Хотя основное улучшение производительности этого бенчмарка связано свстраиванием функций, описанным выше, есть еще одно небольшое улучшение, касающееся выполнения вызовов функций винтерпретаторе— специализация инструкции CALL
.
Инструкция CALL
является универсальной длявыполнения всех типов вызываемых объектов. Приее обработке интерпретатору необходимо проверить тип вызываемого объекта, например, являетсяли он методом класса, методом экземпляра, функцией иличем‑то еще, и наоснове этого правильно вызвать нужный объект.
Специализация этой инструкции позволяет избежать всей этой дополнительной работы дляинтерпретатора, чтоможет улучшить производительность вкоротких циклах.
Заключение
Мы подробно рассмотрели производительность Python, сосредоточив внимание назатратах навызовов функций, вызовах встроенных функций и встраивании кода. Наши бенчмарки показали значительные улучшения производительности сPython 3.10доPython 3.14.0a0. Ниже краткий обзор причин этих улучшений:
Суперинструкции: Объединяя последовательные инструкции байт‑кода вединые суперинструкции, такие как
LOAD_FAST_LOAD_FAST
, CPython уменьшает затраты навыполнение отдельных инструкций байт‑кода. Это улучшение помогает какинтерпретатору, так и процессору работать более эффективно.Специализация инструкций байт‑кода: Новые специализированные инструкции байт‑кода (например,
BINARY_ADD_INT
) убирают необходимость вмедленной динамической диспетчеризации, ускоряя обычные операции.Оптимизация встроенных функций: Переход отстарого метода
tp_call
кболеебыстромуvectorcall
значительно повысил производительность встроенных функцийmin
иmax
.Встраивание вызовов функций Python‑to‑Python: Устранив старый способ обработки вызовов функций Python‑to‑Python (который включал громоздкие рекурсивные вызовы интерпретатора), новые версии, начиная сCPython 3.11, сделали эти вызовыбыстрее.
Вцелом, эти изменения демонстрируют постоянные улучшения скорости и эффективности Python. Однако перед тем какиспользовать результаты изэтой статьи, помните, чтосначала необходимо провести профилирование, чтобы найти самые медленные участки кода (закон Амдала).