От исходника к байткоду: шесть шагов, которые Python делает до первой инструкции
Одна строка кода превращается в четырнадцать токенов, шесть инструкций и шестнадцать ячеек. Десять из шестнадцати не содержат кода вовсе — это место под кеш, которое dis не показывает. Из этой арифметики растёт всё остальное: и специализация, и кеш атрибутов, и указатель под нужным подвыражением в трассировке.
Полное техническое изложение
TL;DR
- Python не исполняет ваш текст. Он сначала переводит его в список коротких команд — и уже команды исполняет.
- Перевод происходит один раз, когда файл загружается, а не при каждом вызове.
- Одна строка кода превращается в шесть команд. А места в памяти они занимают как шестнадцать: десять ячеек оставлены пустыми — про запас, для ускорения.
- Из этого перевода растёт всё остальное: и почему трассировка показывает
стрелочку под нужным местом, и почему
UnboundLocalErrorслучается там, где переменная «вроде бы есть».
Никто не исполняет ваш текст
Есть распространённая картинка: Python читает файл строку за строкой и делает то, что написано. Так работает не Python, а сильно упрощённый калькулятор.
На самом деле между вашим файлом и работой стоит переводчик. Он один раз читает текст целиком, разбирает его и выдаёт список коротких команд — таких, что каждая делает ровно одну простую вещь: «положи значение переменной», «возьми у объекта атрибут», «сложи два верхних значения».
Дальше исполняются уже команды. Текст больше никто не читает.
Шесть станций конвейера
Переводчик работает не одним махом, а по шагам. Каждый шаг превращает материал во что-то другое.
- Текст. Просто строка символов.
- Слова. Текст режется на кусочки:
def,f,(,a,). У каждого запоминается, где он стоял — строка и колонка. Это пригодится в самом конце. - Дерево. Список слов превращается в структуру: вот функция, у неё имя, список аргументов и тело, а в теле — возврат суммы.
- Список имён. Отдельный проход решает про каждое имя: оно своё, местное, или взято снаружи. Именно здесь, а не при исполнении.
- Уборка. Всё, что можно посчитать заранее, считается заранее; всё, до чего нельзя добраться, выбрасывается.
- Команды. То, что останется, и будет исполняться.
Переключайте вкладки на схеме ниже — там настоящий вывод каждого шага для одной маленькой функции.
Текст
открыть файлdef f(a): return a.x + 1
Одна строка. Дальше её длина уже ни на что не влияет — считаются токены, узлы и инструкции.
Уборка бывает довольно щедрой
Некоторые вещи можно посчитать, ещё не запуская программу. Python это делает.
def g():
return 2 + 3 * 4В готовых командах никакого умножения нет — там сразу число 14. Умножение произошло один раз, при переводе.
Так же исчезают:
- код после
return— до него всё равно не дойти; - ветка
if False:— вместе со всем, что внутри; - соседние строки в кавычках:
'a' 'b' 'c'становится одной строкой'abc'.
Отсюда практический вывод: выносить 2 + 3 * 4 в отдельную переменную «чтобы
не считалось каждый раз» — работа впустую. Оно и так не считается.
Шесть команд, шестнадцать ячеек
Вот число, которое удивляет. Для одной строки
def f(a): return a.x + 1получается шесть команд. А места они занимают как шестнадцать.
Десять ячеек пустые. Они оставлены про запас: пока программа работает, Python подсматривает, что происходит на самом деле — какого типа объект, где у него лежит нужный атрибут, — и записывает подсмотренное в эти ячейки. В следующий раз ту же работу можно не делать заново.
def f(a): return a.x + 1Каждая клетка — один code unit, два байта: опкод и аргумент. Серые клетки — CACHE: место под inline-кеш, которое занимает предыдущая инструкция. dis их не показывает.
- 0RESUME
- 2LOAD_FAST
- 4LOAD_ATTR
- 6·
- 8·
- 10·
- 12·
- 14·
- 16·
- 18·
- 20·
- 22·
- 24LOAD_CONST
- 26BINARY_OP
- 28·
- 30RETURN_VALUE
- инструкций у dis
- 6
- слотов кеша
- 10
- code units всего
- 16
- len(co_code)
- 32 Б
Девять слотов подряд после LOAD_ATTR — это его inline-кеш: версия типа, дескриптор, смещение значения. Один слот после BINARY_OP. Итого десять клеток из шестнадцати заняты не кодом.
Больше всего запаса просит команда «возьми атрибут» — девять ячеек. Это не случайность: обращение к атрибуту в Python самая частая операция, и ускорять имеет смысл прежде всего её.
Куда доезжают координаты слов
Помните, на втором шаге у каждого слова запомнили, где оно стояло? Это не бухгалтерия ради бухгалтерии.
def boom(a, b, c, d):
return a.x + b.y * c.z - d.wЕсли d окажется не тем, что ожидалось, Python покажет:
return a.x + b.y * c.z - d.w
^^^
AttributeError: 'NoneType' object has no attribute 'w'
Стрелочки стоят ровно под d.w — не под всей строкой, хотя в ней четыре
обращения к атрибутам. Координаты, записанные в самом начале, доехали до конца
и всплыли в момент ошибки.
Бесплатным это не бывает: файлы с готовыми командами для стандартной библиотеки выросли из-за этого на 22 %. Разработчики Python посчитали, что точная стрелочка того стоит.
Почему переменная бывает «уже локальной»
Тот самый шаг со списком имён объясняет знаменитую загадку:
x = 10
def f():
print(x) # UnboundLocalError
x = 5Кажется, что до x = 5 ещё не дошли, значит print(x) должен взять
внешнюю x. Но решение о том, местная переменная или внешняя, принимается
не при исполнении, а на четвёртом шаге конвейера — при переводе, когда
видно всю функцию целиком.
Есть присваивание где-нибудь в теле — значит имя местное во всей функции, включая строки выше присваивания. Спорить с этим при исполнении уже некому.
Три вещи, которые стоит запомнить
Перевод происходит один раз. Не при каждом вызове функции, а при загрузке файла.
Часть работы делается заранее. Арифметика на константах, склейка строк, выброс недостижимого кода — всё это до старта.
Пустые ячейки — не расточительство, а рабочее место. Именно в них интерпретатор складывает то, что подсмотрел, чтобы во второй раз работать быстрее.
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 — это конвейер, а не одна функция. Текст проходит шесть станций, и на каждой из них он перестаёт быть тем, чем был.
- Текст — строка байтов.
- Токены — слова с координатами.
- Дерево — структура вместо последовательности.
- Таблица символов — кто здесь локальный, а кто глобальный.
- Граф и оптимизации — то, что можно выбросить, выбрасывается.
- Объект кода — то, что исполняется.
Ключевое свойство конвейера: координаты из шага 2 доезжают до шага 6. Номер строки и колонки, записанные при токенизации, попадают в объект кода и через годы всплывают в трассировке как указатель под нужным символом.
Пять из шести шагов наблюдаемы из самого Python — есть модуль, который их показывает. Шестой, граф потока управления, не наблюдаем никак, и это стоит сказать прямо, а не нарисовать «примерную схему».
Текст
открыть файлdef f(a): return a.x + 1
Одна строка. Дальше её длина уже ни на что не влияет — считаются токены, узлы и инструкции.
Что реально видно на каждом шаге
Токены даёт tokenize. Четырнадцать штук на одну строку, и у каждого —
пара координат. Именно эти координаты — предмет PEP 657.
Дерево даёт ast.parse. Обратите внимание на ctx=Load() у Name и
Attribute: чтение и запись различаются уже здесь, задолго до байткода. Из
этого различия потом вырастут разные опкоды.
Таблицу символов даёт symtable — модуль, о котором мало кто помнит, хотя
он показывает самый неочевидный проход компилятора. Отдельный обход дерева,
целиком до генерации кода, решает про каждое имя: локальное, глобальное,
захваченное из внешней области. У нашей функции:
>>> 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. Это можно не рассказывать, а показать:
x = 10
def f():
print(x) # UnboundLocalError
x = 5>>> 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 Falsex помечена локальной на этапе разбора, до единой исполненной строки — и
помечена во всей функции сразу, включая 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,83 | LOAD_GLOBAL LOAD_FAST CALL |
f(L), где f = len — локальное | 13,47 | LOAD_FAST PUSH_NULL LOAD_FAST CALL |
1,36 нс, около 10 %. Разница есть и она воспроизводима — но это тот случай, когда правильный вывод из числа противоположен ожидаемому: приём, который передаётся как заметная оптимизация, стоит полторы наносекунды на вызов. Ради него портить читаемость незачем.
Объект кода: шесть инструкций и шестнадцать ячеек
Теперь главное число статьи.
>>> 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 их не показывает.
- 0RESUME
- 2LOAD_FAST
- 4LOAD_ATTR
- 6·
- 8·
- 10·
- 12·
- 14·
- 16·
- 18·
- 20·
- 22·
- 24LOAD_CONST
- 26BINARY_OP
- 28·
- 30RETURN_VALUE
- инструкций у dis
- 6
- слотов кеша
- 10
- code units всего
- 16
- len(co_code)
- 32 Б
Девять слотов подряд после LOAD_ATTR — это его inline-кеш: версия типа, дескриптор, смещение значения. Один слот после BINARY_OP. Итого десять клеток из шестнадцати заняты не кодом.
LOAD_ATTR резервирует девять ячеек, BINARY_OP — одну. Посмотреть таблицу
целиком можно не читая исходники:
>>> 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(), а работает так:
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 * 4 | co_consts (None, 14), опкоды RESUME RETURN_CONST |
return 1 и следом return 2 | co_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 x | LOAD_FAST TO_BOOL RETURN_VALUE — одна операция, не две |
NOP на месте вырезанного if — не мусор. PEP 626 требует, чтобы у каждой
исполняемой инструкции был номер строки и чтобы строки, которые исполнялись,
не пропадали из отладочной информации; NOP держит эту бухгалтерию.
Обратите внимание, чего в таблице нет: ни одной оптимизации, которая переставляла бы вычисления местами или выбрасывала обращение к атрибуту. Компилятор Python сознательно почти ничего не знает о значениях — всё интересное происходит позже, в интерпретаторе.
Что изменилось в 3.12, 3.13 и 3.14
Числа получены одним и тем же скриптом (bench/bytecode_bench.py) на одной и
той же функции.
| 3.12.3 | 3.13.7 | 3.14.0rc2 | |
|---|---|---|---|
len(co_code) | 32 | 32 | 40 |
| code units | 16 | 16 | 20 |
sys.getsizeof(code) | 224 | 232 | 248 |
co_consts | (None, 1) | (None, 1) | (1,) |
| опкодов в языке | 140 | 150 | 238 |
opcode.HAVE_ARGUMENT | 90 | 44 | 43 |
| опкодов с аргументом | 101 | 107 | 195 |
Ловушка в предпоследней строке. 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.11 | PEP 657: co_positions(), указатель под подвыражением в трассировке | |
| 3.12 | PEP 709: comprehension инлайнится, отдельного объекта кода больше нет; PEP 701: f-строки получили собственные токены | |
| 3.13 | Нумерация опкодов переразложена (HAVE_ARGUMENT 90 → 44); указатели появились и на строке вызова | |
| 3.14 | LOAD_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.
Проверка знаний
dis показывает для функции 6 инструкций. Сколько байт занимает её co_code на CPython 3.13?
Источники и что читать дальше
11 ИСТОЧНИКОВ
- PEP 617 — New PEG parser for CPythonPEP. Статус Final, Python 3.9. Замена LL(1)-парсера на PEG — отсюда то, что грамматика перестала быть ограничением языка.https://peps.python.org/pep-0617/
- PEP 701 — Syntactic formalization of f-stringsPEP. Статус Final, Python 3.12. f-строки перестали разбираться отдельным мини-парсером и получили собственные токены.https://peps.python.org/pep-0701/
- PEP 626 — Precise line numbers for debugging and other toolsPEP. Статус Final, Python 3.10. Вводит co_lines() и требование, чтобы каждая исполняемая инструкция имела номер строки.https://peps.python.org/pep-0626/
- 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/
- PEP 709 — Inlined comprehensionsPEP. Статус Final, Python 3.12. Comprehension перестал быть вложенной функцией. Заявленные числа — 1,96× на микробенчмарке и 11 % на pyperformance.https://peps.python.org/pep-0709/
- 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
- 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
- Objects/codeobject.c — _PyCode_NewИсходный код CPython. Строка 692: единственное место, где объект кода действительно создаётся. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/codeobject.c
- dis — дизассемблер байткода CPythonОфициальная документация. «CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter» — предупреждение из первого абзаца, с которого честно начинать любой разговор про байткод.https://docs.python.org/3.13/library/dis.html
- Модель данных — объекты кодаОфициальная документация. Перечень полей co_*, доступных из Python, и явное указание, что часть внутренностей из языка не видна.https://docs.python.org/3.13/reference/datamodel.html#code-objects
- symtable — доступ к таблицам символов компилятораОфициальная документация. Единственный публичный способ увидеть проход, который решает, локальная переменная или глобальная, — до того как сгенерирован хоть один опкод.https://docs.python.org/3.13/library/symtable.html