Цикл интерпретатора: где живёт фрейм и как раздаётся управление
Байт-код готов — кто его исполняет? Один цикл на C, где фрейм вызова лежит не отдельным объектом в куче, а куском на стеке данных потока, а следующая инструкция выбирается тремя разными способами в зависимости от того, как собран Python. Отсюда и предел рекурсии, и то, что `f_locals` в 3.13 перестал быть снимком.
Полное техническое изложение
TL;DR
- Готовый байт-код кто-то должен исполнять. Этим занят один цикл на C — он берёт опкод за опкодом и делает, что каждый предписывает. Один на все вызовы, а не по циклу на функцию.
- Фрейм вызова — не отдельный объект в куче. Локальные и стек значений лежат компактным куском на стеке потока: вызвали — сдвинули указатель вперёд, вернулись — назад. Без выделения памяти на каждый вызов.
- Поэтому предел рекурсии (по умолчанию 1000) — это отдельный счётчик Python-фреймов, а не предел системного стека.
- Детали привязаны к версии: в 3.14 загрузка локальной подешевела (
LOAD_FAST_BORROW), а с 3.13f_localsу функции стал не снимком, а «живой» — запись в него доходит до фрейма.
Кто исполняет байт-код
В прошлой статье исходник превратился в байт-код. Но байт-код сам себя не выполняет — его крутит один цикл на C внутри интерпретатора: взять опкод, сделать по нему, перейти к следующему. Один и тот же цикл обслуживает все вызовы: когда одна функция зовёт другую, не запускается второй цикл — тот же цикл начинает крутить новый фрейм.
Где лежит фрейм
Легко подумать, что каждый вызов создаёт «объект-фрейм» в куче. Это не так. Локальные переменные, стек значений и служебные поля вызова лежат компактным куском на стеке потока. Вызвали функцию — указатель сдвинулся вперёд; вернулись — назад. Никакого выделения памяти и сборки мусора на каждый вызов.
Тот «объект-фрейм», который видно в трассировке ошибки, надстраивается поверх лениво — только если он кому-то реально понадобился.
Отсюда понятно, почему предел рекурсии — это отдельный счётчик (по умолчанию 1000), а не предел системного стека: считаются именно Python-фреймы.
Что меняется от версии к версии
Сам набор инструкций и поведение фрейма подвижны — на конкретные номера опкодов в коде завязываться нельзя. Два заметных изменения:
Загрузка локальной подешевела (3.14). Одна и та же строка return a + 1
раньше грузила a через LOAD_FAST, а в 3.14 — через LOAD_FAST_BORROW: на
самой частой операции стало меньше лишней работы.
f_locals стал «живым» (3.13). Раньше f_locals у функции отдавал снимок —
копию локальных, запись в которую до самих переменных не доходила. С 3.13 это
«живой» объект: записали в него — изменилась настоящая переменная. Это нужно
отладчикам, которые меняют локальные на лету.
Что унести
- Байт-код исполняет один цикл на C, общий на все вызовы.
- Фрейм — кусок на стеке потока, а не объект в куче; поэтому вызов дёшев, а предел рекурсии — отдельный счётчик.
- Детали (номера опкодов, поведение
f_locals) зависят от версии — на них нельзя полагаться как на неизменные.
TL;DR
Байт-код из прошлой статьи кто-то должен исполнить. Этим занят один цикл на
C — _PyEval_EvalFrameDefault в Python/ceval.c: гигантский switch по
опкодам. Он один на все вызовы, а не по циклу на функцию.
Фрейм — не объект в куче. Локальные, стек значений и служебные поля лежат в
компактной структуре _PyInterpreterFrame, которую кладут куском на стек
данных потока, а не выделяют в куче на каждый вызов. Объект-фрейм, который
видит traceback, надстраивается над ней лениво.
Диспетчер бывает трёх видов. Простой switch, computed goto (переход
прямо на код опкода, без возврата в голову цикла) и — с 3.14 — экспериментальный
tail-call интерпретатор из маленьких C-функций. Какой именно, решает сборка.
Числа, привязанные к версии. HAVE_ARGUMENT: 90 → 44 → 43 (3.12 → 3.13 →
3.14). Загрузка локальной: LOAD_FAST → LOAD_FAST_BORROW в 3.14. f_locals у
функции: снимок-dict → write-through FrameLocalsProxy с 3.13 (PEP 667).
Один цикл на все вызовы
После компиляции (прошлая статья) у нас есть объект кода с байт-кодом. Исполняет
его не «виртуальная машина» в общем смысле, а конкретная функция на C —
_PyEval_EvalFrameDefault в Python/ceval.c. Внутри неё — цикл: взять
следующий опкод, сделать то, что он предписывает, перейти к следующему.
Важно, что этот цикл один. Когда Python-функция вызывает другую
Python-функцию, C-стек не углубляется на один кадр _PyEval_EvalFrameDefault за
другим (так было давно). Вместо этого тот же цикл разворачивает новый фрейм и
продолжает крутиться. Отсюда практическое следствие: предел рекурсии в Python
(sys.getrecursionlimit(), по умолчанию 1000) — это не предел C-стека, а
отдельный счётчик, который считает Python-фреймы.
Цикл, к тому же, не высечен в камне. PEP 523 (3.6) ввёл _PyFrameEvalFunction —
указатель на функцию исполнения фрейма, который можно подменить. Этим крючком
цепляются отладчики и JIT: они ставят свою функцию вместо стандартной. То есть
«цикл интерпретатора» — точка расширения, а не константа.
Фрейм лежит не в куче
Интуиция «каждый вызов создаёт объект-фрейм в куче» неверна, и это меняет всю
картину стоимости вызова. Данные исполнения — локальные переменные, стек
значений, ссылка на объект кода, указатель на предыдущий фрейм — лежат в
структуре _PyInterpreterFrame (Include/internal/pycore_frame.h). Её кладут
куском на стек данных потока: при вызове указатель просто сдвигается вперёд,
при возврате — назад. Ни malloc, ни сборки мусора на каждый вызов.
Полноценный объект-фрейм (types.FrameType, тот, что виден в трассировке и в
sys._getframe()) — надстройка. Он создаётся лениво, только если кто-то
действительно попросил фрейм как объект. Обычный вызов, который никто не
интроспектирует, этого объекта не платит.
Отсюда и предел рекурсии как отдельный счётчик, и то, что «Python медленный» из-за вызовов — правда лишь отчасти: сам разворот фрейма дёшев, дорого — то, что на каждый опкод цикл проходит через диспетчер.
Как выбирается следующая инструкция
Сердце цикла — диспетчер: код, который по номеру опкода передаёт управление на его обработку. В CPython он бывает трёх видов, и какой соберётся, зависит от компилятора и флагов сборки.
Простой switch. Переносимый вариант: один switch (opcode) на все случаи.
После каждого опкода управление возвращается в голову цикла и снова уходит в
switch. Работает везде, но лишний переход на каждой инструкции.
computed goto. Если компилятор умеет метки как значения (GCC, Clang),
CPython строит таблицу переходов и после опкода прыгает прямо на код
следующего, минуя голову цикла. Меньше переходов и лучше предсказание ветвей у
процессора — оттого этот вариант и берут по умолчанию, где он доступен.
Tail-call интерпретатор (3.14, экспериментально). Третий вариант: каждый
опкод — отдельная маленькая C-функция, а переход к следующему опкоду сделан
хвостовым вызовом такой функции. Здесь честная граница темы: по нему нет
ни PEP, ни InternalDocs — только раздел What's New 3.14, который называет его
a new type of interpreter
(новый тип интерпретатора) и даёт единственное число — a geometric mean of 3-5% faster on the standard
(в среднем геометрическом на 3–5 % быстрее на стандартном наборе тестов pyperformance benchmark suitepyperformance) относительно базы
Python 3.14 built with Clang 19, without this new interpreter
(Python 3.14, собранный Clang 19, без этого нового интерпретатора). Своими руками
без пересборки это число не проверить: оно взято из What's New, а не измерено
нами.
Общее у всех трёх: снаружи, из Python, они неотличимы — меняется только скорость раздачи управления, но не поведение опкодов.
Набор опкодов подвижен
Раз диспетчер — это разбор по номеру опкода, стоит знать, что сам набор опкодов
меняется от версии к версии, и завязываться на конкретные номера в коде
нельзя. Две величины показывает запуск bench/interpreter-loop/frames.py:
3.12 3.13 3.14
HAVE_ARGUMENT 90 44 43
именованных опкодов 140 150 238
HAVE_ARGUMENT — граница, ниже которой опкоды идут без аргумента; она сдвигается
(инструменты вроде dis берут её из opcode, а не зашивают). Число именованных
опкодов растёт за счёт специализированных форм — тех самых, которыми
занимается адаптивный интерпретатор (следующая статья). В 3.14 их заметно
больше.
Изменения видны и на одной строке кода. Вот def one(a): return a + 1 на разных
версиях:
3.12 / 3.13: RESUME LOAD_FAST LOAD_CONST BINARY_OP RETURN_VALUE
3.14: RESUME LOAD_FAST_BORROW LOAD_SMALL_INT BINARY_OP RETURN_VALUE
LOAD_FAST в 3.14 стал LOAD_FAST_BORROW — загрузка локальной больше не трогает
счётчик ссылок там, где значение и так живёт во фрейме и никуда не денется до
конца инструкции. А LOAD_CONST для мелких целых стал отдельным
LOAD_SMALL_INT. Оба изменения — про то, чтобы на самой частой операции делать
меньше работы.
f_locals больше не снимок
Самое заметное снаружи изменение цикла за последние версии — поведение
f_locals. Раньше обращение к локальным фрейма как к словарю отдавало снимок:
копию, запись в которую до фрейма не доходила. PEP 667 (3.13) это изменил.
Проверяется запуском:
def g():
x = 1
loc = sys._getframe().f_locals
loc["x"] = 99 # запись в f_locals
return x- До 3.13:
g()возвращает1.f_locals— снимок-dict, запись в него на настоящую локальную не влияла. - С 3.13:
g()возвращает99.f_localsтеперьFrameLocalsProxy— write-through: запись доходит до самого фрейма.
Это ровно то, ради чего PEP 667 и делали: чтобы отладчики и инструменты, которые
меняют локальные через f_locals, действительно их меняли, а не тихо писали в
копию. Тип объекта тоже разный: dict до 3.13, FrameLocalsProxy — с 3.13.
Где ленивость кончается
Ленивость фрейма, о которой сказано выше, — не абсолютная. У неё есть точное условие, и знать его полезно, потому что нарушается оно инструментами, которые включают на проде.
Сначала — подтверждение самой ленивости, тождеством, а не рассуждением
(bench/interpreter-loop/frame_materialization.py):
1. Материализация по требованию
тип, который возвращает sys._getframe() : frame
два вызова в одном кадре — один объект : True
sys._getframe(1) is _getframe().f_back : True
Объект строится по требованию и переиспользуется — второй sys._getframe()
достаёт уже готовый. Это видно и по цене (3.13.7):
2. пустой вызов 19,4 нс
+ первый sys._getframe() (материализация) 38,4
+ второй sys._getframe() (объект уже есть) 7,9
Первая строка после «пустого вызова» и есть цена надстройки, и платится она один раз на кадр. Вторую строку как число читать нельзя: это разность двух шумных замеров, и на повторных прогонах она гуляет от отрицательных значений до пятнадцати наносекунд. Показывает она одно — второе обращение существенно дешевле первого.
А теперь условие. sys.setprofile и sys.settrace получают frame первым
аргументом каждого события. Значит, объект обязан существовать на каждый
вызов, и лениво тут уже ничего не выходит:
3. без хуков 17,8 нс ×1,0
sys.setprofile 187,8 ×10,6
sys.settrace (только call) 181,3 ×10,2
sys.settrace (+ построчно) 262,0 ×14,7
вызовов target(): 5, разных объектов-фреймов у профайлера: 5
Пять вызовов — пять разных объектов-фреймов: переиспользования нет, потому что кадры разные. Десятикратная цена вызова — это и есть «ленивость выключена», и она объясняет, почему профилирование на проде меняет не только абсолютные числа, но и соотношения: дороже становится вызов, а не работа.
Множители держатся на всех трёх версиях, но не в одном коридоре: на 3.13 и 3.14 это примерно от ×10 до ×15, на 3.12 заметно больше — построчная трассировка там доходила до ×22. Сама по себе построчная трассировка дороже вызовной везде: 262,0 против 181,3 нс на 3.13.
Второй способ материализовать цепочку — исключение. На глубине 5 + 1 в traceback семь звеньев, и каждое держит объект-фрейм: отсюда и цена глубокой трассировки, и то, почему пойманное и сохранённое исключение удерживает всю цепочку кадров.
И третий случай, полезный на практике: у генератора gi_frame — тот же объект
frame, два обращения дают один и тот же, а после исчерпания там None. То
есть по gi_frame можно понять, жив генератор или доработал.
Оговорка о методе: посчитать материализованные фреймы через gc.get_objects()
нельзя — с 3.11 фреймы не лежат в списках поколений сборщика, и счёт даёт ноль и
с хуками, и без. Замер наблюдает появление объекта, его тождество и его цену.
Инструментация и JIT сидят на той же механике
Два механизма надстроены прямо над циклом и потому дёшевы, пока выключены.
sys.monitoring (PEP 669, 3.12). Мониторинг событий (вызов, возврат,
строка, ветвление) переиспользует ту же подмену инструкций, что и специализация:
пока событие никого не интересует, в байт-коде нет ничего лишнего. Отсюда
«monitoring нулевого воздействия» в названии PEP.
JIT (PEP 744, статус Draft). В поставке 3.13+ есть экспериментальный
copy-and-patch JIT: он собирается опционально и включается только для «горячих»
участков, которые адаптивный интерпретатор уже наметил. Статус PEP — Draft,
не Final: это направление, а не финальная гарантия языка. Наличие исполнителя для
участка кода видно из Python через _opcode.get_executor().
Что стоит унести
Интерпретатор — одна C-функция с циклом, общая на все фреймы. Новый вызов Python-функции не углубляет C-стек на кадр интерпретатора, а разворачивает фрейм в том же цикле. Поэтому предел рекурсии — отдельный счётчик, а не предел стека C.
Фрейм — кусок на стеке данных потока, а не объект в куче. Объект-фрейм для трассировки надстраивается лениво. Разворот фрейма дёшев; дорого стоит проход через диспетчер на каждый опкод.
Диспетчер бывает трёх видов, и это свойство сборки. switch, computed goto, и с 3.14 — экспериментальный tail-call. Снаружи из Python они неотличимы.
Набор опкодов и поведение фрейма привязаны к версии. HAVE_ARGUMENT
сдвигается, опкодов становится больше, LOAD_FAST → LOAD_FAST_BORROW,
f_locals из снимка стал write-through proxy. Завязываться на номера опкодов
нельзя; читать f_locals как снимок — тоже.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Байт-код из прошлой статьи кто-то должен исполнить. Этим занят один цикл на C —
_PyEval_EvalFrameDefaultвPython/ceval.c: гигантскийswitchпо опкодам. Он один на все вызовы, а не по циклу на функцию. - Фрейм — не объект в куче. Локальные, стек значений и служебные поля лежат в компактной структуре
_PyInterpreterFrame, которую кладут куском на стек данных потока, а не выделяют в куче на каждый вызов. Объект-фрейм, который видитtraceback, надстраивается над ней лениво. - Диспетчер бывает трёх видов. Простой
switch,computed goto(переход прямо на код опкода, без возврата в голову цикла) и — с 3.14 — экспериментальный tail-call интерпретатор из маленьких C-функций. Какой именно, решает сборка. - Числа, привязанные к версии.
HAVE_ARGUMENT: 90 → 44 → 43 (3.12 → 3.13 → 3.14). Загрузка локальной:LOAD_FAST→LOAD_FAST_BORROWв 3.14.f_localsу функции: снимок-dict→ write-throughFrameLocalsProxyс 3.13 (PEP 667).
На самом деле
- Данные вызова лежат в структуре
_PyInterpreterFrameкуском на стеке данных потока: вызвали — сдвинули указатель, вернулись — сдвинули назад. Полноценный объект-фрейм (types.FrameType) надстраивается лениво, только если его реально запросили. Обычный вызов, который никто не интроспектирует, объекта в куче не платит. - Нет: цикл интерпретатора один на все вызовы и не углубляет C-стек на кадр на каждый Python-вызов.
sys.getrecursionlimit()(по умолчанию 1000) — отдельный счётчик Python-фреймов, который существует именно потому, что по C-стеку это не отследить. - PEP 523 (3.6) ввёл
_PyFrameEvalFunction— цикл можно подменить на свой; этим цепляются отладчики и JIT. Более того, сам диспетчер собирается в одном из трёх видов (switch,computed goto, tail-call с 3.14) в зависимости от компилятора и флагов. - До 3.13 это был снимок: запись в него до фрейма не доходила (
xоставался прежним). С 3.13 (PEP 667) этоFrameLocalsProxy— write-through: запись меняет настоящую локальную. Проверено запуском:loc["x"]=99даёт1до 3.13 и99с 3.13. - Набор опкодов меняется каждую версию:
HAVE_ARGUMENT— 90 → 44 → 43 (3.12 → 3.13 → 3.14), а число именованных опкодов растёт (140 → 150 → 238) за счёт специализированных форм. Инструменты берут границу из модуляopcode, а не зашивают её.
Что разобрано
- Один цикл на все вызовы
- Фрейм лежит не в куче
- Как выбирается следующая инструкция
- Набор опкодов подвижен
- `f_locals` больше не снимок
- Где ленивость кончается
- Инструментация и JIT сидят на той же механике
- Что стоит унести
Частые заблуждения
«Каждый вызов функции создаёт объект-фрейм в куче».
Данные вызова лежат в структуре _PyInterpreterFrame куском на стеке данных потока: вызвали — сдвинули указатель, вернулись — сдвинули назад. Полноценный объект-фрейм (types.FrameType) надстраивается лениво, только если его реально запросили. Обычный вызов, который никто не интроспектирует, объекта в куче не платит.
«Предел рекурсии — это предел системного стека C».
Нет: цикл интерпретатора один на все вызовы и не углубляет C-стек на кадр на каждый Python-вызов. sys.getrecursionlimit() (по умолчанию 1000) — отдельный счётчик Python-фреймов, который существует именно потому, что по C-стеку это не отследить.
«Цикл интерпретатора — неизменная константа CPython».
PEP 523 (3.6) ввёл _PyFrameEvalFunction — цикл можно подменить на свой; этим цепляются отладчики и JIT. Более того, сам диспетчер собирается в одном из трёх видов (switch, computed goto, tail-call с 3.14) в зависимости от компилятора и флагов.
«f_locals — это словарь локальных, и в него можно писать».
До 3.13 это был снимок: запись в него до фрейма не доходила (x оставался прежним). С 3.13 (PEP 667) это FrameLocalsProxy — write-through: запись меняет настоящую локальную. Проверено запуском: loc["x"]=99 даёт 1 до 3.13 и 99 с 3.13.
«Номера опкодов стабильны, на них можно завязываться».
Набор опкодов меняется каждую версию: HAVE_ARGUMENT — 90 → 44 → 43 (3.12 → 3.13 → 3.14), а число именованных опкодов растёт (140 → 150 → 238) за счёт специализированных форм. Инструменты берут границу из модуля opcode, а не зашивают её.
Проверка знаний
Python-функция вызывает другую Python-функцию. Что происходит с C-стеком интерпретатора?
Источники и что читать дальше
8 ИСТОЧНИКОВ
- CPython — Python/ceval.c, функция _PyEval_EvalFrameDefaultИсходный код CPython. Сам цикл интерпретатора: один гигантский switch по опкодам, который исполняет байт-код фрейма. Отсюда факт статьи — интерпретатор один на все фреймы, а не по циклу на вызов; новый вызов Python-функции не рекурсивно зовёт C-функцию, а разворачивает новый фрейм в том же цикле. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/ceval.c
- CPython — Include/internal/pycore_frame.h, структура _PyInterpreterFrameИсходный код CPython. Определение фрейма исполнения. Ключевое для статьи: это компактная структура, которую кладут на стек данных потока кусками (chunk), а не полноценный объект в куче на каждый вызов. Объект-фрейм (types.FrameType), который видит трассировка, создаётся лениво поверх неё. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/internal/pycore_frame.h
- PEP 523 — Adding a frame evaluation API to CPythonPEP. Дино Виланд, Брет Кэннон; Final, Python 3.6. Ввёл _PyFrameEvalFunction: цикл интерпретатора можно подменить на свой. Отсюда крючок, которым цепляются отладчики и JIT — цикл не единственный неизменный.https://peps.python.org/pep-0523/
- PEP 667 — Consistent views of namespacesPEP. Марк Шеннон, Тянь Гао; Final, Python 3.13. Причина того, что f_locals у функции перестал быть снимком-dict и стал write-through proxy (FrameLocalsProxy): запись в него доходит до самого фрейма. Проверено запуском — см.
bench/interpreter-loop/frames.py.https://peps.python.org/pep-0667/ - PEP 669 — Low Impact Monitoring for CPythonPEP. Марк Шеннон; Final, Python 3.12. sys.monitoring: инструментация переиспользует ту же механику подмены инструкций (quickening), что и специализация, — поэтому мониторинг «нулевой стоимости», пока не включён.https://peps.python.org/pep-0669/
- PEP 744 — JIT CompilationPEP. Брандт Бухер, Сэвэннах Остроушко; статус **Draft** (не Final — указывать обязательно). Описывает copy-and-patch JIT, который собирается опционально и включается только для «горячих» участков. Отсюда оговорка статьи: JIT в поставке есть, но экспериментальный.https://peps.python.org/pep-0744/
- What's New in Python 3.14 — экспериментальный tail-call интерпретаторОфициальная документация. Единственный первоисточник по третьему варианту диспетчера: «a new type of interpreter … uses tail calls between small C functions» (новый тип интерпретатора … использует хвостовые вызовы между небольшими C-функциями). Там же — единственное называемое число: «a geometric mean of 3-5% faster on the standard pyperformance benchmark suite» (в среднем геометрическом на 3–5 % быстрее на стандартном наборе тестов pyperformance), база — «Python 3.14 built with Clang 19, without this new interpreter» (Python 3.14, собранный Clang 19, без этого нового интерпретатора). Своими руками без пересборки не воспроизвести.https://docs.python.org/3.14/whatsnew/3.14.html
- dis и opcode — псевдоинструкции, HAVE_ARGUMENT, специализированные опкодыОфициальная документация. Откуда числа про набор опкодов: HAVE_ARGUMENT — граница опкодов без аргумента (90 на 3.12, 44 на 3.13, 43 на 3.14), и число именованных опкодов растёт (140 → 150 → 238) за счёт специализированных форм. Проверено запуском.https://docs.python.org/3.14/library/dis.html