Адаптивный интерпретатор: как CPython переписывает сам себя по дороге
На втором выполнении инструкции интерпретатор подменяет её на узкую версию под те типы, которые увидел, — и держит в самом байт-коде счётчик и кеш, чтобы проверять, что предположение ещё в силе. Отсюда и то, почему одна строка, через которую ходят пять типов, дороже на половину, и почему включение JIT в 3.14 проиграло на всех четырёх коротких циклах, на которых его проверили.
Полное техническое изложение
TL;DR
- Байт-код, который показывает
dis, — не тот, что исполняется. Начиная с 3.11 интерпретатор во время работы переписывает инструкции в себе: увидел, чтоa + bскладывает целые, — подменил обобщённыйBINARY_OPна узкийBINARY_OP_ADD_INT. - Ждать этого не приходится: на 3.12 и новее подмена случается на втором выполнении самой инструкции (счётчик у неё, а не у строки исходника), на 3.11 — на восьмом. Это не «разогрев», как в JVM.
- Специализируется конкретное место в конкретной функции, а не операция вообще:
o.xдаёт разные инструкции в зависимости от того, лежит атрибут в словаре экземпляра или в слоте__slots__. - Предположение проверяется на каждом проходе, и проверка не бесплатна: одна строка, через которую ходят пять типов вместо одного, дороже на 53 %.
- JIT (второй уровень) — отдельная вещь и в 3.14 выключен по умолчанию. Его включение на той же сборке сделало все четыре проверенных цикла медленнее, а не быстрее.
Интерпретатор правит себя по ходу дела
Обычная картина такая: компилятор превратил исходник в байт-код, и дальше цикл интерпретатора этот байт-код крутит. Правдива она ровно до Python 3.11.
С 3.11 добавился третий участник. Пока код исполняется, интерпретатор смотрит,
что именно через него проходит, и подменяет инструкции на более узкие. Было
BINARY_OP — «сложи что угодно с чем угодно, разбирайся по ходу». Стало
BINARY_OP_ADD_INT — «сложи два целых», без разбирательств.
Проверяется это одной строкой, без чтения исходников CPython: у dis есть флаг
adaptive, который показывает инструкции в их нынешнем виде.
def attrs(fn):
return [
i.opname
for i in dis.get_instructions(fn, adaptive=True)
if i.opname.startswith("LOAD_ATTR")
]
print(attrs(read)) # ['LOAD_ATTR']
for _ in range(500):
read(p)
print(attrs(read)) # ['LOAD_ATTR_INSTANCE_VALUE']Функция та же, объект кода тот же. Изменился он сам.
Это происходит почти сразу
Слово «специализация» тянет за собой ожидание долгого разогрева — так работает JVM, где горячий код компилируется после тысяч итераций. В Python не так.
Замер простой: свежий кусок кода выполняется по одному разу, и после каждого раза у него спрашивают, как теперь называется инструкция.
| версия | подмена случается |
|---|---|
| 3.11.15 | на 8-м выполнении |
| 3.12.3 | на 2-м |
| 3.13.7 | на 2-м |
| 3.14.7 | на 2-м |
Практический вывод: функция, вызванная дважды, уже работает на
специализированных инструкциях. В частности, любой timeit меряет
установившийся режим, а не разогрев.
Специализируется место, а не операция
Это важнее, чем кажется. Специализация записана не «для сложения вообще», а для
одной конкретной строки в одной конкретной функции. Поэтому одно и то же
обращение o.x даёт разные инструкции:
| что читаем | во что превращается |
|---|---|
| атрибут в словаре экземпляра | LOAD_ATTR_INSTANCE_VALUE |
атрибут в слоте __slots__ | LOAD_ATTR_SLOT |
| метод | LOAD_ATTR_METHOD_WITH_VALUES |
Отсюда неочевидное следствие: __slots__ — это не только про экономию памяти.
Он ещё и меняет инструкцию, которой читается атрибут.
Проверка стоит денег
Специализированная инструкция обязана убедиться, что предположение ещё в силе, — иначе она отдала бы неверный ответ. Эта сверка выполняется на каждом проходе, и если через одно место ходят разные типы, она регулярно не сходится.
Пять классов, объявленных одинаково, делают одно и то же. Различается только
число типов, проходящих через одну строку o.x:
| типов | нс на чтение | к одному типу |
|---|---|---|
| 1 | 32,79 | ×1,00 |
| 2 | 41,00 | ×1,25 |
| 5 | 50,07 | ×1,53 |
Половина сверху на ровном месте — на этом замере: пять классов через одно
o.x. Лечится это не аннотациями типов, а разделением горячего кода так, чтобы
через одно место ходил один тип. Только сначала профиль: без него неизвестно,
это ли место горячее.
А JIT?
С 3.13 в CPython есть и второй механизм — JIT. Он делает другое: заменяет не одну инструкцию, а целую последовательность.
Главное, что о нём стоит знать: по номеру версии судить о нём нельзя. В 3.14 он собран, но выключен; спросить у интерпретатора можно так:
import sys
print(sys._jit.is_available(), sys._jit.is_enabled()) # True FalseВключается переменной окружения PYTHON_JIT=1. На четырёх коротких циклах,
прогнанных на одной и той же сборке по шесть раз в каждом режиме, включение
сделало медленнее все четыре: от +10,7 % на словаре до +36,7 % на
обходе списка. На этом коде и этой машине «включил JIT — стало быстрее» не
подтвердилось.
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.
Специализация байт-кода… ускоряет выполнение программы, переписывая инструкции на основе сведений, полученных во время работы. Делается это заменой обобщённой инструкции на более быструю версию, работающую для того случая, который встречается в этой программе.
Ключевое слово — rewriting. Это не оптимизация компилятора: компилятор
выдаёт обычный BINARY_OP, ничего не зная о типах. Переписывание происходит
позже, во время исполнения, и делает его сам код: в Python/specialize.c на
каждое семейство инструкций заведена своя функция _Py_Specialize_*.
Проверить это можно не читая исходников. У dis есть флаг adaptive, который
показывает инструкции в их нынешнем виде:
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.
И это тоже видно снаружи — флагом 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_OP | BINARY_OP_ADD_INT |
a + b, вещественные | BINARY_OP | BINARY_OP_ADD_FLOAT |
a + b, строки | BINARY_OP | BINARY_OP_ADD_UNICODE |
seq[i], список | BINARY_SUBSCR | BINARY_SUBSCR_LIST_INT |
d[k], словарь | BINARY_SUBSCR | BINARY_SUBSCR_DICT |
o.x, обычный атрибут | LOAD_ATTR | LOAD_ATTR_INSTANCE_VALUE |
o.x, слот __slots__ | LOAD_ATTR | LOAD_ATTR_SLOT |
o.method() | LOAD_ATTR | LOAD_ATTR_METHOD_WITH_VALUES |
f(n), функция на Python | CALL | CALL_PY_EXACT_ARGS |
a < b, целые | COMPARE_OP | COMPARE_OP_INT |
for x in список | FOR_ITER | FOR_ITER_LIST |
Обратите внимание на две строки подряд: o.x превращается в разные инструкции
в зависимости от того, лежит атрибут в словаре экземпляра или в слоте. Это и
есть «под тот случай, который встречается в этой программе» — специализируется
не операция вообще, а конкретное место в конкретной функции.
Всего таких семейств на 3.13 пятнадцать, а вариантов внутри них — семьдесят
четыре. Самое большое семейство — CALL: двадцать вариантов.
Когда
Ответ неожиданно скорый:
| версия | базовых семейств | вариантов | подстановка случается |
|---|---|---|---|
| 3.11.15 | 17 | 71 | на 8-м выполнении |
| 3.12.3 | 15 | 64 | на 2-м |
| 3.13.7 | 15 | 74 | на 2-м |
| 3.14.7 | 17 | 84 | на 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.
Специализированные инструкции сами отвечают за проверку того, что допущения частного случая ещё в силе, и за откат к обобщённой версии, если это не так.
Проверим. Пять классов, объявленных одинаково, делают одно и то же; одна
строка o.x в цикле; различается только то, сколько разных типов через неё
проходит:
| типов на месте вызова | нс на чтение | инструкция в конце | к одному типу |
|---|---|---|---|
| 1 | 32,79 | LOAD_ATTR_INSTANCE_VALUE | ×1,00 |
| 2 | 41,00 | LOAD_ATTR_INSTANCE_VALUE | ×1,25 |
| 3 | 43,48 | LOAD_ATTR_INSTANCE_VALUE | ×1,33 |
| 5 | 50,07 | LOAD_ATTR_INSTANCE_VALUE | ×1,53 |
Здесь два наблюдения, и второе противоречит тому, чего я ждал.
Первое: полиморфизм места вызова стоит денег. Половина накладных расходов на ровном месте, при том что работа одна и та же и классы неразличимы. Это и есть цена проверки: специализированная инструкция сверяет версию типа, а на чужом типе сверка не сходится и приходится идти общим путём.
Второе: инструкция при этом не откатывается. Я ожидал увидеть в последней
строке обычный LOAD_ATTR — и не увидел ни разу. За весь прогон инструкция
переписывалась один раз (обобщённая → специализированная) и дальше не
менялась, сколько типов через неё ни гоняй. То есть de-optimizing back to the generic version
(откат к обобщённой версии) — это про то, что происходит
внутри выполнения инструкции при несошедшейся проверке, а не про то, что в
массиве байт-кода снова окажется обобщённый опкод. Отличить одно от другого можно только замером: пересказ PEP
даёт обе картины одинаково правдоподобными.
Оговорка, без которой вывод был бы шире, чем измерение: это верно для семейства
LOAD_ATTR и такого сценария. У BINARY_OP при смене типа наблюдалось другое
поведение — специализированная инструкция сменялась на специализированную под
новый тип (BINARY_OP_ADD_INT → BINARY_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 основан на механизме замены целой последовательности инструкций байт-кода, и это открывает оптимизации, охватывающие несколько инструкций.
Там же названо, что считается горячим: инструкция JUMP_BACKWARD — то есть
конец итерации цикла — смотрит на счётчик в собственном инлайн-кеше и, перевалив
порог, просит оптимизатор построить трассу.
Работает ли он у вас — вопрос не к номеру версии. На это отвечает sys._jit:
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 * i | 981–1013 | 1144–1216 | +16,6 %, отрезки не пересекаются |
| цикл по списку с атрибутом | 520–541 | 711–739 | +36,7 %, не пересекаются |
| вызов функции в цикле | 1018–1037 | 1169–1199 | +14,8 %, не пересекаются |
| словарь в цикле | 1520–1588 | 1683–1746 | +10,7 %, не пересекаются |
В колонках не одно число, а размах шести запусков — и это не педантизм: машина дрейфует, и на одной паре запусков разница в четыре процента не значила бы ничего. Запуски чередовались (без JIT, с JIT, без JIT, …), чтобы дрейф попал в оба режима одинаково, а вывод сведён к одному вопросу: пересекаются отрезки или нет.
Не пересекаются ни у одной нагрузки: самый быстрый запуск с JIT остаётся медленнее самого медленного без него.
Пар взято шесть, а не три, и это не запас прочности ради красоты. Вывод здесь держится не на величине разницы, а на том, что отрезки не пересеклись ни разу; для такого утверждения трёх пар мало.
Чего эти числа НЕ значат: приговора JIT. Четыре коротких цикла на двух ядрах в
контейнере — не pyperformance и не настоящая программа. Право они дают на одно
утверждение: включение второго уровня само по себе выигрыша не гарантирует,
и проверять его надо на своём коде, а не по номеру версии.
Что из этого следует для кода
Три вывода, каждый — прямое следствие измеренного выше.
Однотипные места вызова дешевле разнотипных. Не потому, что «Python любит
типы», а потому, что специализированная инструкция сверяет ровно одно
предположение. Функция, через которую в горячем цикле ходят пять разных классов,
платит половину сверху — и лечится это не аннотациями, а разделением на два
места вызова. Полтора раза — это результат замера LOAD_ATTR на пяти классах,
а не цена полиморфизма вообще: у другого семейства инструкций и другой нагрузки
число будет своим. И порядок работ обычный: сначала профиль, потом правка формы
вызова, а не наоборот.
__slots__ даёт другую инструкцию, а не только экономию памяти.
LOAD_ATTR_SLOT против LOAD_ATTR_INSTANCE_VALUE — разные специализации, и
выбор между ними делается один раз, при первом прогреве.
Микрозамеры меряют разогретый интерпретатор. Раз подстановка случается на
втором выполнении, любой timeit с числом повторов больше двух работает уже на
специализированном коде. Отдельно «мерить холодный старт» через timeit не
получится — для этого нужен свежий объект кода на каждый замер.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.11 | PEP 659: специализация появляется. Семнадцать семейств, семьдесят один вариант, подстановка на восьмом выполнении. Отдельным семейством живёт PRECALL — семнадцать вариантов только у него. | |
| 3.12 | PRECALL исчезает, его работа уходит в 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, — не тот, который исполняется. Начиная с 3.11 интерпретатор во время работы переписывает инструкции в себе: увидев, чтоa + bскладывает целые, он подменяет обобщённыйBINARY_OPнаBINARY_OP_ADD_INT, который умеет только целые и потому короче. - Ждать этого долго не приходится: на 3.12 и новее подстановка случается на втором выполнении — считает не строка исходника, а сама инструкция: счётчик живёт в её инлайн-кеше, и одна строка на несколько инструкций даёт несколько независимых счётчиков. На 3.11 — на восьмом.
- Предположение проверяется на каждом проходе, и проверка стоит денег. Одна и та же строка
o.x, через которую ходят пять разных типов вместо одного, обходится на 53 % дороже — при том что инструкция остаётся специализированной, а не откатывается. - Второй уровень (JIT) — отдельная вещь: он заменяет не одну инструкцию, а последовательность. В 3.14 он собран, но выключен по умолчанию, и на четырёх коротких циклах его включение сделало медленнее все четыре — от +10,7 % до +36,7 %, то есть на этом коде не «быстрее» вовсе.
На самом деле
- Показывает тот, что выдал компилятор. Чтобы увидеть исполняемый, нужен флаг:
dis.get_instructions(fn, adaptive=True). После пятисот вызовов та же функция показываетLOAD_ATTR_INSTANCE_VALUEвместоLOAD_ATTR— объект кода тот же, изменился он сам. - На 3.12, 3.13 и 3.14 подстановка случается на ВТОРОМ выполнении самой инструкции (счётчик у неё, а не у строки исходника); на 3.11 — на восьмом. Замерено перебором: свежий объект кода выполняется по разу, и после каждого у него спрашивают имя инструкции. Это не JVM: функция, вызванная дважды, уже работает на специализированных инструкциях.
- Это вообще не инструкции. Ячейки инлайн-кеша лежат в массиве байт-кода следом за инструкцией, интерпретатор через них перешагивает, а не исполняет. У
LOAD_ATTRих девять, по два байта каждая; первая по правилу семейства всегда счётчик — его значениеshow_cachesпечатает прямо. - Измерено: за прогон, в котором через одну строку прошли пять разных типов, инструкция переписывалась ОДИН раз — обобщённая на специализированную — и осталась
LOAD_ATTR_INSTANCE_VALUE. Откат, о котором говорит внутренняя документация, происходит внутри выполнения инструкции при несошедшейся проверке; в массиве байт-кода обобщённый опкод обратно не появляется. Не бесплатна при этом сама несошедшаяся проверка: пять типов на одном месте против одного — плюс 53 %. - В 3.14.7 второй уровень собран, но выключен:
sys._jit.is_available()— True,is_enabled()— False. Включение переменнойPYTHON_JIT=1на той же сборке сделало медленнее все четыре проверенных цикла: от +10,7 % на словаре до +36,7 % на обходе списка. Считалось по шести чередующимся парам запусков, и ни у одной нагрузки размахи не пересекаются, так что это не дрейф машины.
По версиям
- 3.11
- PEP 659: специализация появляется. Семнадцать семейств, семьдесят один вариант, подстановка на восьмом выполнении. Отдельным семейством живёт
PRECALL— семнадцать вариантов только у него.< - 3.12
PRECALLисчезает, его работа уходит в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 ИСТОЧНИКОВ
- PEP 659 — Specializing Adaptive InterpreterPEP. Марк Шеннон, Informational, Python 3.11. Документ, которым специализация вошла в CPython. Отсюда и само название механизма, и деление на «прогрев — специализация — проверка предположения».https://peps.python.org/pep-0659/
- 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
- 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
- Python/specialize.c — функции _Py_Specialize_*Исходный код CPython. Место, где принимается решение о подстановке: по одной функции на семейство. Нужно, чтобы показать, что специализация — не свойство компилятора, а работа, выполняемая во время исполнения. Тег CPython 3.14.5.https://github.com/python/cpython/blob/v3.14.5/Python/specialize.c
- dis — adaptive и show_cachesОфициальная документация. Оба флага, на которых держится вся проверяемость статьи: adaptive показывает инструкцию в её нынешнем виде, show_caches — ячейки инлайн-кеша вместе со значением счётчика. Без них про специализацию можно было бы только пересказывать.https://docs.python.org/3.14/library/dis.html
- What's New In Python 3.13 — экспериментальный JITОфициальная документация. Момент, когда второй уровень появился как собираемая опция, и прямая оговорка, что по умолчанию он выключен.https://docs.python.org/3.13/whatsnew/3.13.html
- sys._jit — состояние второго уровня в текущей сборкеОфициальная документация. is_available / is_enabled / is_active. Единственный честный способ сказать, работает ли JIT прямо сейчас, вместо предположений по номеру версии.https://docs.python.org/3.14/library/sys.html