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

Адаптивный интерпретатор: как CPython переписывает сам себя по дороге

На втором выполнении инструкции интерпретатор подменяет её на узкую версию под те типы, которые увидел, — и держит в самом байт-коде счётчик и кеш, чтобы проверять, что предположение ещё в силе. Отсюда и то, почему одна строка, через которую ходят пять типов, дороже на половину, и почему включение JIT в 3.14 проиграло на всех четырёх коротких циклах, на которых его проверили.

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

TL;DR

Байт-код, который вы видите в dis, — не тот, который исполняется. Начиная с 3.11 интерпретатор во время работы переписывает инструкции в себе: увидев, что a + b складывает целые, он подменяет обобщённый BINARY_OP на BINARY_OP_ADD_INT, который умеет только целые и потому короче.

Ждать этого долго не приходится: на 3.12 и новее подстановка случается на втором выполнении — считает не строка исходника, а сама инструкция: счётчик живёт в её инлайн-кеше, и одна строка на несколько инструкций даёт несколько независимых счётчиков. На 3.11 — на восьмом.

Предположение проверяется на каждом проходе, и проверка стоит денег. Одна и та же строка o.x, через которую ходят пять разных типов вместо одного, обходится на 53 % дороже — при том что инструкция остаётся специализированной, а не откатывается.

Второй уровень (JIT) — отдельная вещь: он заменяет не одну инструкцию, а последовательность. В 3.14 он собран, но выключен по умолчанию, и на четырёх коротких циклах его включение сделало медленнее все четыре — от +10,7 % до +36,7 %, то есть на этом коде не «быстрее» вовсе.

Что вообще происходит

Механизм описан в PEP 659 и внутри самого проекта — во внутренней документации CPython, и вторая формулировка точнее:

Bytecode specialization … speeds up program execution by rewriting instructions based on runtime information. This is done by replacing a generic instruction with a faster version that works for the case that this program encounters.

перевод

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

InternalDocs/interpreter.md, тег v3.14.5

Ключевое слово — rewriting. Это не оптимизация компилятора: компилятор выдаёт обычный BINARY_OP, ничего не зная о типах. Переписывание происходит позже, во время исполнения, и делает его сам код: в Python/specialize.c на каждое семейство инструкций заведена своя функция _Py_Specialize_*.

Проверить это можно не читая исходников. У dis есть флаг adaptive, который показывает инструкции в их нынешнем виде:

PYTHON
import dis
 
def read(o):
    return o.x
 
class Point:
    def __init__(self):
        self.x = 1
 
def attrs(fn):
    return [
        i.opname
        for i in dis.get_instructions(fn, adaptive=True)
        if i.opname.startswith("LOAD_ATTR")
    ]
 
p = Point()
print("до прогрева:", attrs(read))
for _ in range(500):
    read(p)
print("после:      ", attrs(read))
до прогрева: ['LOAD_ATTR']
после:       ['LOAD_ATTR_INSTANCE_VALUE']

Одна и та же функция, один и тот же объект кода. Изменился он сам.

Где живёт счётчик

Чтобы решить, когда специализироваться, инструкции нужно место под счётчик, а специализированной версии — место под то, во что она верит: версию типа, смещение атрибута, указатель на дескриптор. Это место лежит прямо в массиве байт-кода, сразу за инструкцией:

The inline cache consists of one or more two-byte entries included in the bytecode array as additional words following the opcode/oparg pair.

перевод

Инлайн-кеш состоит из одной или нескольких двухбайтовых ячеек, включённых в массив байт-кода как дополнительные слова, следующие за парой opcode/oparg.

InternalDocs/interpreter.md, тег v3.14.5

И это тоже видно снаружи — флагом show_caches:

LOAD_ATTR_INSTANCE_VALUE 0 (x)
CACHE                    0 (counter: 832)
CACHE                    0 (version: 5833536)
CACHE                    0
CACHE                    0 (keys_version: 5833536)
CACHE                    0
CACHE                    0 (descr: 8595768128)
CACHE                    0
CACHE                    0
CACHE                    0

Девять двухбайтовых ячеек на одну инструкцию LOAD_ATTR — и первая из них по правилу семейства всегда счётчик. Отсюда, кстати, ответ на вопрос, который задают, увидев такой вывод впервые: CACHE — не инструкция. Интерпретатор её не исполняет, он через неё перешагивает; в массиве она лежит потому, что так данные оказываются рядом с кодом, который их читает.

Размер кеша одинаков у всех членов семейства — это условие корректности: LOAD_ATTR, LOAD_ATTR_SLOT и LOAD_ATTR_MODULE обязаны занимать в массиве одинаковое место, иначе подстановка сдвинула бы весь дальнейший код.

Что во что превращается

Одиннадцать однострочных операций, прогретых пятьюстами вызовами. Слева — что выдал компилятор, справа — что стало (Python 3.13.7):

операциядопосле
a + b, целыеBINARY_OPBINARY_OP_ADD_INT
a + b, вещественныеBINARY_OPBINARY_OP_ADD_FLOAT
a + b, строкиBINARY_OPBINARY_OP_ADD_UNICODE
seq[i], списокBINARY_SUBSCRBINARY_SUBSCR_LIST_INT
d[k], словарьBINARY_SUBSCRBINARY_SUBSCR_DICT
o.x, обычный атрибутLOAD_ATTRLOAD_ATTR_INSTANCE_VALUE
o.x, слот __slots__LOAD_ATTRLOAD_ATTR_SLOT
o.method()LOAD_ATTRLOAD_ATTR_METHOD_WITH_VALUES
f(n), функция на PythonCALLCALL_PY_EXACT_ARGS
a < b, целыеCOMPARE_OPCOMPARE_OP_INT
for x in списокFOR_ITERFOR_ITER_LIST

Обратите внимание на две строки подряд: o.x превращается в разные инструкции в зависимости от того, лежит атрибут в словаре экземпляра или в слоте. Это и есть «под тот случай, который встречается в этой программе» — специализируется не операция вообще, а конкретное место в конкретной функции.

Всего таких семейств на 3.13 пятнадцать, а вариантов внутри них — семьдесят четыре. Самое большое семейство — CALL: двадцать вариантов.

Когда

Ответ неожиданно скорый:

версиябазовых семействвариантовподстановка случается
3.11.151771на 8-м выполнении
3.12.31564на 2-м
3.13.71574на 2-м
3.14.71784на 2-м

Замер прямой: свежий объект кода выполняется по одному разу, и после каждого раза у него спрашивают имя инструкции. На 3.12 и новее оно меняется уже после второго прохода.

Практический вывод отсюда важнее самого числа. «Прогрев» в Python — это не тысячи итераций, как в JVM. Любая функция, вызванная дважды, уже работает на специализированных инструкциях. Микрозамер, который делает timeit с number=1000, меряет установившийся режим, а не разогрев, — и это хорошая новость для замеров и плохая для тех, кто рассчитывает «поймать» интерпретатор холодным.

Что если предположение не оправдалось

Внутренняя документация обещает откат:

The specialized instructions are responsible for checking that the special-case assumptions still apply, and de-optimizing back to the generic version if not.

перевод

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

InternalDocs/interpreter.md, тег v3.14.5

Проверим. Пять классов, объявленных одинаково, делают одно и то же; одна строка o.x в цикле; различается только то, сколько разных типов через неё проходит:

типов на месте вызованс на чтениеинструкция в концек одному типу
132,79LOAD_ATTR_INSTANCE_VALUE×1,00
241,00LOAD_ATTR_INSTANCE_VALUE×1,25
343,48LOAD_ATTR_INSTANCE_VALUE×1,33
550,07LOAD_ATTR_INSTANCE_VALUE×1,53

Здесь два наблюдения, и второе противоречит тому, чего я ждал.

Первое: полиморфизм места вызова стоит денег. Половина накладных расходов на ровном месте, при том что работа одна и та же и классы неразличимы. Это и есть цена проверки: специализированная инструкция сверяет версию типа, а на чужом типе сверка не сходится и приходится идти общим путём.

Второе: инструкция при этом не откатывается. Я ожидал увидеть в последней строке обычный LOAD_ATTR — и не увидел ни разу. За весь прогон инструкция переписывалась один раз (обобщённая → специализированная) и дальше не менялась, сколько типов через неё ни гоняй. То есть de-optimizing back to the generic version (откат к обобщённой версии) — это про то, что происходит внутри выполнения инструкции при несошедшейся проверке, а не про то, что в массиве байт-кода снова окажется обобщённый опкод. Отличить одно от другого можно только замером: пересказ PEP даёт обе картины одинаково правдоподобными.

Оговорка, без которой вывод был бы шире, чем измерение: это верно для семейства LOAD_ATTR и такого сценария. У BINARY_OP при смене типа наблюдалось другое поведение — специализированная инструкция сменялась на специализированную под новый тип (BINARY_OP_ADD_INTBINARY_OP_ADD_FLOAT).

Второй уровень

С 3.13 в CPython есть ещё один механизм, и его постоянно путают с первым. Разница названа во внутренней документации одной фразой:

Runtime optimization in this interpreter can only be done for one instruction at a time. The JIT is based on a mechanism to replace an entire sequence of bytecode instructions, and this enables optimizations that span multiple instructions.

перевод

Оптимизация во время выполнения в этом интерпретаторе возможна только по одной инструкции за раз. JIT основан на механизме замены целой последовательности инструкций байт-кода, и это открывает оптимизации, охватывающие несколько инструкций.

InternalDocs/jit.md, тег v3.14.5

Там же названо, что считается горячим: инструкция JUMP_BACKWARD — то есть конец итерации цикла — смотрит на счётчик в собственном инлайн-кеше и, перевалив порог, просит оптимизатор построить трассу.

Работает ли он у вас — вопрос не к номеру версии. На это отвечает sys._jit:

PYTHON
import sys
print(sys._jit.is_available(), sys._jit.is_enabled(), sys._jit.is_active())

На сборке, на которой снят этот раздел (3.14.7), — True False False: собран, но выключен. Включается переменной окружения PYTHON_JIT=1.

А раз сборка одна и та же и различается ровно одна переменная, сравнение по времени здесь законно — в отличие от сравнения версий между собой. Четыре коротких цикла, шесть независимых пар запусков, лучшее из одиннадцати повторов в каждом; процент в последней колонке — отношение лучших запусков двух режимов:

нагрузкабез JIT, мксс JIT, мксвывод
счётный цикл t += i * i981–10131144–1216+16,6 %, отрезки не пересекаются
цикл по списку с атрибутом520–541711–739+36,7 %, не пересекаются
вызов функции в цикле1018–10371169–1199+14,8 %, не пересекаются
словарь в цикле1520–15881683–1746+10,7 %, не пересекаются

В колонках не одно число, а размах шести запусков — и это не педантизм: машина дрейфует, и на одной паре запусков разница в четыре процента не значила бы ничего. Запуски чередовались (без JIT, с JIT, без JIT, …), чтобы дрейф попал в оба режима одинаково, а вывод сведён к одному вопросу: пересекаются отрезки или нет.

Не пересекаются ни у одной нагрузки: самый быстрый запуск с JIT остаётся медленнее самого медленного без него.

Пар взято шесть, а не три, и это не запас прочности ради красоты. Вывод здесь держится не на величине разницы, а на том, что отрезки не пересеклись ни разу; для такого утверждения трёх пар мало.

Чего эти числа НЕ значат: приговора JIT. Четыре коротких цикла на двух ядрах в контейнере — не pyperformance и не настоящая программа. Право они дают на одно утверждение: включение второго уровня само по себе выигрыша не гарантирует, и проверять его надо на своём коде, а не по номеру версии.

Что из этого следует для кода

Три вывода, каждый — прямое следствие измеренного выше.

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

__slots__ даёт другую инструкцию, а не только экономию памяти. LOAD_ATTR_SLOT против LOAD_ATTR_INSTANCE_VALUE — разные специализации, и выбор между ними делается один раз, при первом прогреве.

Микрозамеры меряют разогретый интерпретатор. Раз подстановка случается на втором выполнении, любой timeit с числом повторов больше двух работает уже на специализированном коде. Отдельно «мерить холодный старт» через timeit не получится — для этого нужен свежий объект кода на каждый замер.

История версий

ВерсияИзменениеЧто это значит для кода
3.11PEP 659: специализация появляется. Семнадцать семейств, семьдесят один вариант, подстановка на восьмом выполнении. Отдельным семейством живёт PRECALL — семнадцать вариантов только у него.
3.12PRECALL исчезает, его работа уходит в CALL; вариантов становится меньше (64), а покрытие — шире: COMPARE_OP и FOR_ITER, которые на 3.11 не специализировались вовсе, теперь дают COMPARE_OP_INT и FOR_ITER_LIST. Поиск метода получает свою специализацию (LOAD_ATTR_METHOD_WITH_VALUES) вместо специализации вызова. Прогрев сокращается с восьми выполнений до двух.
3.13Вариантов 74. Появляется второй уровень как опция сборки — экспериментальный JIT, по умолчанию выключенный. Проверять его наличие по номеру версии нельзя: это флаг сборки.
3.14Вариантов 84, семейств снова семнадцать; BINARY_SUBSCR сливается с BINARY_OP, и индексация теперь даёт BINARY_OP_SUBSCR_LIST_INT. Появляется sys._jit — способ спросить у интерпретатора, а не у документации, собран ли второй уровень и включён ли он сейчас.

Чем измерено

Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.

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

Утверждение

dis показывает байт-код, который исполняется

На самом деле

Показывает тот, что выдал компилятор. Чтобы увидеть исполняемый, нужен флаг: dis.get_instructions(fn, adaptive=True). После пятисот вызовов та же функция показывает LOAD_ATTR_INSTANCE_VALUE вместо LOAD_ATTR — объект кода тот же, изменился он сам.

Утверждение

Специализация — это разогрев, ей нужны тысячи итераций

На самом деле

На 3.12, 3.13 и 3.14 подстановка случается на ВТОРОМ выполнении самой инструкции (счётчик у неё, а не у строки исходника); на 3.11 — на восьмом. Замерено перебором: свежий объект кода выполняется по разу, и после каждого у него спрашивают имя инструкции. Это не JVM: функция, вызванная дважды, уже работает на специализированных инструкциях.

Утверждение

Инструкции CACHE в выводе dis — это накладные расходы на исполнение

На самом деле

Это вообще не инструкции. Ячейки инлайн-кеша лежат в массиве байт-кода следом за инструкцией, интерпретатор через них перешагивает, а не исполняет. У LOAD_ATTR их девять, по два байта каждая; первая по правилу семейства всегда счётчик — его значение show_caches печатает прямо.

Утверждение

При смене типа инструкция откатывается к обобщённой

На самом деле

Измерено: за прогон, в котором через одну строку прошли пять разных типов, инструкция переписывалась ОДИН раз — обобщённая на специализированную — и осталась LOAD_ATTR_INSTANCE_VALUE. Откат, о котором говорит внутренняя документация, происходит внутри выполнения инструкции при несошедшейся проверке; в массиве байт-кода обобщённый опкод обратно не появляется. Не бесплатна при этом сама несошедшаяся проверка: пять типов на одном месте против одного — плюс 53 %.

Утверждение

В 3.13 появился JIT, значит, Python стал быстрее

На самом деле

В 3.14.7 второй уровень собран, но выключен: sys._jit.is_available() — True, is_enabled() — False. Включение переменной PYTHON_JIT=1 на той же сборке сделало медленнее все четыре проверенных цикла: от +10,7 % на словаре до +36,7 % на обходе списка. Считалось по шести чередующимся парам запусков, и ни у одной нагрузки размахи не пересекаются, так что это не дрейф машины.

Источники и что читать дальше

7 ИСТОЧНИКОВ

  1. PEP 659 — Specializing Adaptive InterpreterPEP. Марк Шеннон, Informational, Python 3.11. Документ, которым специализация вошла в CPython. Отсюда и само название механизма, и деление на «прогрев — специализация — проверка предположения».https://peps.python.org/pep-0659/
  2. InternalDocs/interpreter.md — разделы Specialization и Inline cache entriesИсходный код CPython. Описание механизма изнутри проекта, а не в анонсе. Отсюда формулировка про переписывание инструкций: «replacing a generic instruction with a faster version that works for the case that this program encounters» (замена обобщённой инструкции на более быструю версию, работающую для того случая, который встречается в этой программе), и прямое утверждение про откат: «The specialized instructions are responsible for checking that the special-case assumptions still apply, and de-optimizing back to the generic version if not» (Специализированные инструкции сами отвечают за проверку того, что допущения частного случая ещё в силе, и за откат к обобщённой версии, если это не так). Там же — устройство инлайн-кеша и правило, что первая ячейка всегда счётчик. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/InternalDocs/interpreter.md
  3. InternalDocs/jit.md — второй уровеньИсходный код CPython. Граница между уровнями названа прямо: «Runtime optimization in this interpreter can only be done for one instruction at a time. The JIT is based on a mechanism to replace an entire sequence of bytecode instructions» (Оптимизация во время выполнения в этом интерпретаторе возможна только по одной инструкции за раз. JIT основан на механизме замены целой последовательности инструкций байт-кода). Оттуда же — что горячей объявляет себя инструкция JUMP_BACKWARD по счётчику в своём инлайн-кеше. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/InternalDocs/jit.md
  4. Python/specialize.c — функции _Py_Specialize_*Исходный код CPython. Место, где принимается решение о подстановке: по одной функции на семейство. Нужно, чтобы показать, что специализация — не свойство компилятора, а работа, выполняемая во время исполнения. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Python/specialize.c
  5. dis — adaptive и show_cachesОфициальная документация. Оба флага, на которых держится вся проверяемость статьи: adaptive показывает инструкцию в её нынешнем виде, show_caches — ячейки инлайн-кеша вместе со значением счётчика. Без них про специализацию можно было бы только пересказывать.https://docs.python.org/3.14/library/dis.html
  6. What's New In Python 3.13 — экспериментальный JITОфициальная документация. Момент, когда второй уровень появился как собираемая опция, и прямая оговорка, что по умолчанию он выключен.https://docs.python.org/3.13/whatsnew/3.13.html
  7. sys._jit — состояние второго уровня в текущей сборкеОфициальная документация. is_available / is_enabled / is_active. Единственный честный способ сказать, работает ли JIT прямо сейчас, вместо предположений по номеру версии.https://docs.python.org/3.14/library/sys.html