Deep Engineering
Продвинутый·Опубликовано·3.12 · 3.13 · 3.14·25 МИН

От исходника к байткоду: шесть шагов, которые Python делает до первой инструкции

Одна строка кода превращается в четырнадцать токенов, шесть инструкций и шестнадцать ячеек. Десять из шестнадцати не содержат кода вовсе — это место под кеш, которое dis не показывает. Из этой арифметики растёт всё остальное: и специализация, и кеш атрибутов, и указатель под нужным подвыражением в трассировке.

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

TL;DR

Одна строка def f(a): return a.x + 1 превращается в 14 токенов, дерево из восьми узлов, одну запись в таблице символов — и 6 инструкций, которые занимают 16 code units.

Десять ячеек из шестнадцати не содержат кода. Это место под inline-кеш: девять слотов у LOAD_ATTR и один у BINARY_OP. dis их не показывает.

В 3.14 та же функция занимает 40 байт вместо 32 — и разница объясняется целиком: у BINARY_OP стало пять слотов кеша вместо одного.

Зачем это знать?

Почти всё, что дальше происходит при исполнении, опирается на объект кода. Пока не видно, что инструкций больше, чем строк, а ячеек больше, чем инструкций, разговор про специализацию и кеш атрибутов вести не на чем: специализируется именно инструкция, а кеш живёт именно в этих ячейках.

Есть и практическая сторона. Отсюда становится понятно, почему UnboundLocalError случается там, где переменная «вроде бы есть»; почему трассировка в 3.11+ показывает указатель ровно под нужным подвыражением; и почему одинаковый на вид код иногда компилируется в разное число инструкций.

Начать честно стоит с предупреждения, которым открывается документация dis:

CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter.

—

Всё ниже — про CPython и про конкретные версии. Ни одно из этих чисел не является частью языка.

Модель в голове

Компилятор Python — это конвейер, а не одна функция. Текст проходит шесть станций, и на каждой из них он перестаёт быть тем, чем был.

  1. Текст — строка байтов.
  2. Токены — слова с координатами.
  3. Дерево — структура вместо последовательности.
  4. Таблица символов — кто здесь локальный, а кто глобальный.
  5. Граф и оптимизации — то, что можно выбросить, выбрасывается.
  6. Объект кода — то, что исполняется.

Шаг 3 с версии 3.9 делает PEG-парсер вместо прежнего LL(1) (PEP 617) — после этой замены грамматика перестала быть ограничением языка.

Ключевое свойство конвейера: координаты из шага 2 доезжают до шага 6. Номер строки и колонки, записанные при токенизации, попадают в объект кода и через годы всплывают в трассировке как указатель под нужным символом.

Четыре из шести шагов показывает отдельный модуль: tokenize, ast, symtable, dis; текст — это просто файл. Пятый, граф потока управления, из Python наблюдается только косвенно — и это стоит сказать прямо, а не нарисовать «примерную схему».

Граф — это тело функции, разрезанное на куски без ветвлений, со стрелками переходов между ними. По нему компилятор и видит, что недостижимо и что можно свернуть. Сам граф наружу не выставлен; единственная щель — флаг ast.PyCF_OPTIMIZED_AST, появившийся в 3.13, и про неё в разделе про два прохода.

Что реально видно на каждом шаге

Токены даёт tokenize. Четырнадцать штук на одну строку, и у каждого — пара координат. Именно эти координаты — предмет PEP 657.

Дерево даёт ast.parse. Обратите внимание на ctx=Load() у Name и Attribute: чтение и запись различаются уже здесь, задолго до байткода. Из этого различия потом вырастут разные опкоды.

Таблицу символов даёт symtable — модуль, о котором мало кто помнит, хотя он показывает самый неочевидный проход компилятора. Отдельный обход дерева, целиком до генерации кода, решает про каждое имя: локальное, глобальное, захваченное из внешней области. У нашей функции:

PYTHON
>>> st = symtable.symtable("def f(a): return a.x + 1", "<s>", "exec")
>>> f = st.get_children()[0]
>>> f.get_parameters()
('a',)
>>> [(s.get_name(), s.is_local(), s.is_parameter()) for s in f.get_symbols()]
[('a', True, True)]

Вот отсюда, а не из рантайма, берётся LOAD_FAST вместо LOAD_GLOBAL. И отсюда же — UnboundLocalError. Это можно не рассказывать, а показать:

PYTHON
x = 10
 
def f():
    print(x)   # UnboundLocalError
    x = 5
PYTHON
>>> st = symtable.symtable(open("unbound.py").read(), "unbound.py", "exec")
>>> for s in st.get_children()[0].get_symbols():
...     print(s.get_name(), s.is_local(), s.is_global())
print False True
x     True  False

x помечена локальной на этапе разбора, до единой исполненной строки — и помечена во всей функции сразу, включая print(x) выше присваивания. Спорить с этим при исполнении уже некому:

  File "unbound.py", line 4, in f
    print(x)
          ^
UnboundLocalError: cannot access local variable 'x' where it is not associated with a value

Заодно видно, что print в той же таблице помечен как is_global. Отсюда разные опкоды — и старый совет «положи встроенную функцию в локальную переменную перед горячим циклом». Совет проверяемый, так что проверим (3.13.7, минимум из семи прогонов по 2 000 000 вызовов):

нс на вызовопкоды
len(L) — глобальное имя14,83LOAD_GLOBAL LOAD_FAST CALL
f(L), где f = len — локальное13,47LOAD_FAST PUSH_NULL LOAD_FAST CALL

1,36 нс, около 10 %. Разница есть и она воспроизводима — но это тот случай, когда правильный вывод из числа противоположен ожидаемому: приём, который передаётся как заметная оптимизация, экономит 1,36 нс на вызов. Ради него портить читаемость незачем.

Объект кода: шесть инструкций и шестнадцать ячеек

Теперь главное число статьи.

PYTHON
>>> def f(a): return a.x + 1
>>> len(list(dis.get_instructions(f)))
6
>>> len(f.__code__.co_code) // 2
16

Шесть и шестнадцать. Разница — не ошибка и не выравнивание: десять ячеек занимает inline-кеш, место, которое инструкция резервирует под собственные данные о том, что она уже видела.

LOAD_ATTR резервирует девять ячеек, BINARY_OP — одну. Посмотреть таблицу целиком можно не читая исходники:

PYTHON
>>> import opcode
>>> {k: v for k, v in sorted(opcode._inline_cache_entries.items()) if v}
{'BINARY_OP': 1, 'BINARY_SUBSCR': 1, 'CALL': 3, 'COMPARE_OP': 1, ...
 'LOAD_ATTR': 9, 'LOAD_GLOBAL': 4, 'STORE_ATTR': 4, 'TO_BOOL': 3, ...}

Это подчёркивание в имени — предупреждение: приватное поле, из версии в версию меняется. Что и происходит: в 3.14 у BINARY_OP там уже 5.

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

Координаты, которые доехали до конца

PEP 657 добавил в объект кода четыре числа на инструкцию: начало и конец строки, начало и конец колонки. Видно через co_positions(), а работает так:

PYTHON
class O: pass
 
def boom(a, b, c, d):
    return a.x + b.y * c.z - d.w
 
o = O(); o.x = 1; o.y = 2; o.z = 3
boom(o, o, o, None)

На 3.12.3:

  File "pep657.py", line 4, in boom
    return a.x + b.y * c.z - d.w
                             ^^^
AttributeError: 'NoneType' object has no attribute 'w'

Указатель стоит под d.w, а не под всей строкой — хотя в строке четыре обращения к атрибутам и три операции. Это те самые координаты, записанные при токенизации.

На 3.13.7 в той же программе указатели появляются и на строке вызова:

    boom(o, o, o, None)
    ~~~~^^^^^^^^^^^^^^^

Цена названа в самом PEP и не спрятана: pyc-файлы стандартной библиотеки выросли на 22 %, с 28,4 до 34,7 МБ. За точность трассировки платят диском и памятью, и это осознанный размен.

Что делает оптимизатор

Сам граф потока управления из Python не виден: он живёт внутри compile.c, между _PyAST_Compile (строка 442 на теге v3.13.7) и optimize_and_assemble (строка 7689), и наружу не выставлен. Единственная щель появилась в 3.13 — про неё в разделе про два прохода ниже.

Зато его следствия измеримы полностью. Всё ниже — настоящий co_consts и настоящая цепочка опкодов на 3.13.7:

исходникчто осталось
return 2 + 3 * 4co_consts (None, 14), опкоды RESUME RETURN_CONST
return 1 и следом return 2co_consts (None, 1) — второй return исчез
if False: return 'never' / return 'always'RESUME NOP RETURN_CONST, 'never' нет в константах
return 'a' 'b' 'c'co_consts (None, 'abc')
return (1, 2, 3)один константный кортеж, а не три LOAD_CONST
return not not xLOAD_FAST TO_BOOL RETURN_VALUE — одна операция, не две

NOP на месте вырезанного if — не мусор. PEP 626 требует, чтобы у каждой исполняемой инструкции был номер строки и чтобы строки, которые исполнялись, не пропадали из отладочной информации; NOP держит эту бухгалтерию.

Обратите внимание, чего в таблице нет: ни одной оптимизации, которая переставляла бы вычисления местами или выбрасывала обращение к атрибуту. Компилятор Python сознательно почти ничего не знает о значениях — всё интересное происходит позже, в интерпретаторе.

Что изменилось в 3.12, 3.13 и 3.14

Числа получены одним и тем же скриптом на одной и той же функции.

3.12.33.13.73.14.7
len(co_code)323240
code units161620
sys.getsizeof(code)224232248
co_consts(None, 1)(None, 1)(1,)
опкодов в языке140150238
opcode.HAVE_ARGUMENT904443
опкодов с аргументом101107195

Ловушка в предпоследней строке. HAVE_ARGUMENT упал с 90 до 44, и легко прочитать это как «меньше инструкций стало принимать аргумент». Последняя строка показывает обратное: их стало больше, 101 → 107. HAVE_ARGUMENT — это просто граница в нумерации опкодов, и её сдвиг означает, что нумерацию переразложили, а не что что-то упростилось. Число, которое само по себе ничего не значит, — хороший повод не доверять однострочным сравнениям версий.

Строка «опкодов в языке» — из того же семейства: число измерено, но что именно добавилось в opcode.opname к 3.14, по нему не видно, и я это не проверял.

Про co_consts в 3.14. Он стал (1,) вместо (None, 1), а в дизассемблере вместо LOAD_CONST стоит LOAD_SMALL_INT. Долгое время здесь стояло признание, что причина не прочитана. Теперь она разобрана — в разделе про два прохода ниже, и объясняется она не одной причиной, а двумя сразу.

Про документацию. У компилятора CPython есть внутренняя документация — InternalDocs/compiler.md, code_objects.md, parser.md. Проверено запросами к обоим тегам: на v3.14.0 все три отдают 200, на v3.13.7 — 404. В 3.13 каталог InternalDocs/ содержит ровно один файл, string_interning.md. То есть статья про компилятор 3.13 обязана читать код, а не документ, и любая ссылка на InternalDocs/compiler.md применительно к 3.13 — ссылка на то, чего нет.

ВерсияИзменениеСтатус порядка
3.11PEP 657: co_positions(), указатель под подвыражением в трассировке
3.12PEP 709: comprehension встраивается в тело функции, отдельного объекта кода больше нет; PEP 701: f-строки получили собственные токены
3.13Нумерация опкодов переразложена (HAVE_ARGUMENT 90 → 44); указатели появились и на строке вызова
3.14LOAD_FAST_BORROW и LOAD_SMALL_INT; у BINARY_OP пять слотов кеша вместо одного; InternalDocs про компилятор наконец появились

Оптимизаторов два, и в 3.14 они поменялись работой

Сказанное выше — «пятый шаг не виден из Python вовсе» — с 3.13 перестало быть точным, и различие стоит того, чтобы его знать.

Проходов на самом деле два, и внутренняя документация компилятора перечисляет их отдельными шагами:

  1. Transform AST into an instruction sequence [Python/compile.c] 4. Construct a Control Flow Graph and apply optimizations to it [Python/flowgraph.c] 5. Emit bytecode based on the Control Flow Graph [Python/assemble.c]
перевод

3. Преобразовать AST в последовательность инструкций [Python/compile.c]. 4. Построить граф потока управления и применить к нему оптимизации [Python/flowgraph.c]. 5. Выпустить байткод по графу потока управления [Python/assemble.c]

— CPython — InternalDocs/compiler.md (тег v3.14.0)

До шага 3 работает ещё один проход — по самому дереву. На 3.13 он живёт в Python/ast_opt.c, на 3.14 файла с таким именем нет вовсе, а его место занял Python/ast_preprocess.c, про который та же документация говорит коротко: Preprocesses the AST before compiling (Предобрабатывает AST перед компиляцией). Переименование не косметическое.

Разделить два прохода наблюдением можно ровно одним способом — флагом ast.PyCF_OPTIMIZED_AST, добавленным в 3.13. Он возвращает дерево уже после прохода по AST и ещё до генерации инструкций (bench/source-to-bytecode/optimizer_passes.py).

3.13.7   дерево после прохода по AST:
   константная арифметика   Return(value=Constant(value=14))

3.14.7  то же дерево:
   константная арифметика   Return(value=BinOp(left=Constant(value=2), op=Add(),
                              right=BinOp(left=Constant(value=3), op=Mult(), right=Constant(value=4))))

На 3.13 2 + 3 * 4 свёрнуто до генерации инструкций. На 3.14 проход по AST не свернул ничего, а в байткоде всё равно стоит LOAD_SMALL_INT 14: свёртка произошла позже, на графе. Это подтверждается и кодом — в v3.14.0 у fold_binop в ast_preprocess.c осталась одна-единственная ветка (% со строкой слева), а в flowgraph.c появились fold_const_binop и fold_const_unaryop, которых на v3.13.7 там нет ни одного вхождения. Ограничители переехали дословно, теми же именами и значениями: MAX_INT_SIZE 128, MAX_COLLECTION_SIZE 256, MAX_STR_SIZE 4096, MAX_TOTAL_ITEMS 1024.

Что за проходом по AST осталось на 3.14. Он не исчез, а сузился до преобразований формы дерева: '%s' % (x,) по-прежнему становится JoinedStr, список в in по-прежнему превращается в кортеж. А вырезание недостижимого блока было работой графа на всех трёх версиях. С not not x → TO_BOOL осторожнее: на 3.12 он не сворачивался вовсе — в байткоде стоят два UNARY_NOT, — да и самого TO_BOOL там ещё нет, он появился в 3.13.

И то, что этот переезд наконец объясняет. Выше в разделе про версии сказано, что в 3.14 co_consts стал (1,) вместо (None, 1), и причина не была объяснена. Теперь она видна: на 3.14 в таблице констант остаются значения, на которые не смотрит ни одна инструкция.

3.14.7, константы-сироты:
   константная арифметика  co_consts=(2,)               лишнее: (2,)
   унарный минус           co_consts=(5, -5)            лишнее: (5,)
   умножение строки        co_consts=('a', 'aaaa…')     лишнее: ('a',)
   мёртвая ветка           co_consts=(False, 'always')  лишнее: (False,)

3.12.3 и 3.13.7: нет ни одной

Строк с сиротами скрипт печатает девять; выше приведены четыре. Это ровно то, чего следует ждать, если таблица констант заполняется при генерации инструкций, а свёртка идёт после неё, на графе: свёрнутый операнд остаётся в таблице сиротой. Второй половиной объяснения служит LOAD_SMALL_INT, которому co_consts не нужен вовсе, — отсюда и (1,) вместо (None, 1).

Границы свёртки при этом переезд не сдвинул: 'a' * 4096 сворачивается, а 'a' * 4097 нет; 2 ** 64 да, 2 ** 65 нет; (1, 2) * 128 да, (1, 2) * 129 нет — одинаково на 3.12, 3.13 и 3.14.

Чего мы не выясняли: по какой причине свёртку решили перенести. Факт снят запуском и сверен с кодом на трёх тегах; обсуждения, где это решалось, мы не искали, и объяснения «зачем» здесь нет.

Что с этим делать

Не считайте инструкции по выводу dis. Он показывает шесть там, где в памяти шестнадцать ячеек. Для «сколько занимает сам байт-код» есть len(co_code), для «сколько занимает сама структура объекта кода, без того, на что она ссылается» — sys.getsizeof.

Не сравнивайте версии по одному числу. HAVE_ARGUMENT — живой пример величины, которая изменилась вдвое, не изменившись по смыслу.

Не оптимизируйте то, что уже свернул компилятор. 2 + 3 * 4, соседние строковые литералы, кортежи из констант, not not — всё это уже одна константа или одна операция. Выносить их в переменную «для скорости» — работа впустую.

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

И главное. Те десять пустых ячеек — не накладные расходы, а рабочее место. Заполняет их специализирующий интерпретатор — тот самый механизм, ради которого ячейки и зарезервированы.

Чем измерено

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

Унести в работу

  • Не путать перевод с вызовом: файл переводится в байт-код один раз при загрузке, а не при каждом вызове функции.
  • Не выводить из dis, что функция состоит именно из этого: он печатает опкоды, а не то, что исполняется после адаптации.
  • Не завязываться на номера опкодов: они меняются между версиями, и сравнивать их между сборками бессмысленно.
  • Читать UnboundLocalError как решение компилятора, а не как порядок строк: имя стало локальным в момент компиляции функции, из-за присваивания где угодно в её теле.

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

Утверждение

«dis показывает, из чего состоит функция».

На самом деле

Показывает инструкции, но не ячейки. Для def f(a): return a.x + 1 он печатает 6 инструкций, а len(co_code) // 2 даёт 16 code units: десять из них — место под inline-кеш, девять у LOAD_ATTR и один у BINARY_OP. Для чтения так удобнее, но «сколько это занимает» по выводу dis посчитать нельзя.

Утверждение

«Python компилирует функцию при первом вызове».

На самом деле

При загрузке модуля, целиком и один раз. def — это исполняемая инструкция, которая создаёт объект функции из УЖЕ готового объекта кода: сам код лежит в co_consts внешнего объекта кода. Проверяется одной строкой: [c for c in outer.__code__.co_consts if isinstance(c, types.CodeType)] вернёт объект кода вложенной функции ещё до того, как outer хоть раз вызвали.

Утверждение

«Компилятор Python ничего не оптимизирует».

На самом деле

Оптимизирует, но только то, что видно без знания значений. Измерено на 3.13.7: 2 + 3 * 4 становится константой 14, недостижимый return исчезает из co_consts, ветка if False: вырезается (оставляя NOP ради нумерации строк по PEP 626), 'a' 'b' 'c' склеивается в 'abc', а not not x компилируется в одну инструкцию TO_BOOL. Чего он НЕ делает — не переставляет вычисления и не убирает обращения к атрибутам.

Утверждение

«HAVE_ARGUMENT упал с 90 до 44 — значит меньше инструкций принимают аргумент».

На самом деле

Наоборот: их стало больше, 101 → 107 → 195. HAVE_ARGUMENT — граница в нумерации опкодов, а не количество. Её сдвиг означает, что нумерацию переразложили. Хороший пример величины, по которой нельзя сравнивать версии, не зная, что она значит.

Утверждение

«UnboundLocalError возникает потому, что до присваивания ещё не дошли».

На самом деле

Дошли или нет — не имеет значения. Имя объявлено локальным на этапе построения таблицы символов, до исполнения первой строки, и локально оно во ВСЕЙ функции. Видно напрямую: symtable помечает такое имя is_local() == True для функции, в теле которой есть присваивание, — независимо от того, где это присваивание стоит.

Утверждение

«Положить len в локальную переменную перед циклом — заметная оптимизация».

На самом деле

Измерено на 3.13.7: 14,83 нс против 13,47 нс, разница 1,36 нс (около 10 %). Приём работает и воспроизводится, но экономит полторы наносекунды на вызов. Читаемость дороже.

Утверждение

«Comprehension — это скрытая вложенная функция».

На самом деле

Была до 3.12. PEP 709 её инлайнит: в co_consts функции с comprehension объектов кода ноль, тогда как обычный вложенный def даёт один. PEP заявляет 1,96× на микробенчмарке и 11 % на pyperformance.

Проверка знаний

Вопрос 1 из 5

dis показывает для функции 6 инструкций. Сколько байт занимает её co_code на CPython 3.13?

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

11 ИСТОЧНИКОВ

  1. PEP 617 — New PEG parser for CPythonPEP. Статус Final, Python 3.9. Замена LL(1)-парсера на PEG — отсюда то, что грамматика перестала быть ограничением языка.https://peps.python.org/pep-0617/
  2. PEP 701 — Syntactic formalization of f-stringsPEP. Статус Final, Python 3.12. f-строки перестали разбираться отдельным мини-парсером и получили собственные токены.https://peps.python.org/pep-0701/
  3. PEP 626 — Precise line numbers for debugging and other toolsPEP. Статус Final, Python 3.10. Вводит co_lines() и требование, чтобы каждая исполняемая инструкция имела номер строки.https://peps.python.org/pep-0626/
  4. PEP 657 — Include Fine Grained Error Locations in TracebacksPEP. Статус Final, Python 3.11. co_positions() — четыре числа на инструкцию: начало и конец строки, начало и конец колонки. Цена названа прямо: pyc стандартной библиотеки вырос на 22 %, с 28,4 до 34,7 МБ.https://peps.python.org/pep-0657/
  5. PEP 709 — Inlined comprehensionsPEP. Статус Final, Python 3.12. Comprehension перестал быть вложенной функцией. Заявленные числа — 1,96× на микробенчмарке и 11 % на pyperformance.https://peps.python.org/pep-0709/
  6. Python/compile.c — _PyAST_Compile и optimize_and_assembleИсходный код CPython. Строка 442 на теге v3.13.7 — вход всего компилятора. Строка 7689 — место, где CFG оптимизируется и собирается в объект кода. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/compile.c
  7. Include/cpython/code.h — _PyCode_DEFИсходный код CPython. Строка 74: макрос, которым объявлены поля объекта кода, включая co_code_adaptive — массив переменной длины в конце структуры. Строка 141 — сама PyCodeObject. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/cpython/code.h
  8. Objects/codeobject.c — _PyCode_NewИсходный код CPython. Строка 692: единственное место, где объект кода действительно создаётся. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/codeobject.c
  9. dis — дизассемблер байткода CPythonОфициальная документация. «CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter» (Особенность реализации CPython: байт-код — это деталь реализации интерпретатора CPython) — предупреждение из первого абзаца, с которого честно начинать любой разговор про байткод.https://docs.python.org/3.13/library/dis.html
  10. Модель данных — объекты кодаОфициальная документация. Перечень полей co_*, доступных из Python, и явное указание, что часть внутренностей из языка не видна.https://docs.python.org/3.13/reference/datamodel.html#code-objects
  11. symtable — доступ к таблицам символов компилятораОфициальная документация. Единственный публичный способ увидеть проход, который решает, локальная переменная или глобальная, — до того как сгенерирован хоть один опкод.https://docs.python.org/3.13/library/symtable.html