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

От исходника к байткоду: шесть шагов, которые 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. Объект кода — то, что исполняется.

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

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

Текст

открыть файл
def f(a): return a.x + 1

Одна строка. Дальше её длина уже ни на что не влияет — считаются токены, узлы и инструкции.

CPython 3.13.7

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

Токены даёт 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 %. Разница есть и она воспроизводима — но это тот случай, когда правильный вывод из числа противоположен ожидаемому: приём, который передаётся как заметная оптимизация, стоит полторы наносекунды на вызов. Ради него портить читаемость незачем.

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

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

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

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

def f(a): return a.x + 1

Каждая клетка — один code unit, два байта: опкод и аргумент. Серые клетки — CACHE: место под inline-кеш, которое занимает предыдущая инструкция. dis их не показывает.

  1. 0RESUME
  2. 2LOAD_FAST
  3. 4LOAD_ATTR
  4. 6·
  5. 8·
  6. 10·
  7. 12·
  8. 14·
  9. 16·
  10. 18·
  11. 20·
  12. 22·
  13. 24LOAD_CONST
  14. 26BINARY_OP
  15. 28·
  16. 30RETURN_VALUE
инструкций у dis
6
слотов кеша
10
code units всего
16
len(co_code)
32 Б

Девять слотов подряд после LOAD_ATTR — это его inline-кеш: версия типа, дескриптор, смещение значения. Один слот после BINARY_OP. Итого десять клеток из шестнадцати заняты не кодом.

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), и наружу не выставлен.

Зато его следствия измеримы полностью. Всё ниже — настоящий 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'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

Числа получены одним и тем же скриптом (bench/bytecode_bench.py) на одной и той же функции.

3.12.33.13.73.14.0rc2
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 — это просто граница в нумерации опкодов, и её сдвиг означает, что нумерацию переразложили, а не что что-то упростилось. Число, которое само по себе ничего не значит, — хороший повод не доверять однострочным сравнениям версий.

Что честно не объяснено. В 3.14 co_consts стал (1,) вместо (None, 1), а в дизассемблере вместо LOAD_CONST стоит LOAD_SMALL_INT. Факт измерен; причину в flowgraph.c я не прочитал, поэтому объяснения здесь нет. Если встретите статью, которая уверенно объясняет это в двух словах, — стоит проверить, откуда взято.

Про документацию. У компилятора 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 про компилятор наконец появились

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

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

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

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

Помните, что имена решаются до исполнения. Присваивание где угодно в теле функции делает имя локальным на всю функцию — это решение таблицы символов, а не рантайма. Отсюда 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» — предупреждение из первого абзаца, с которого честно начинать любой разговор про байткод.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