MEASUREMENT
bench/source-to-bytecode/optimizer_passes.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/python/runtime/source-to-bytecode
- How to run it
for v in 3.12 3.13 3.14; do python$v bench/source-to-bytecode/optimizer_passes.py; done
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Наблюдения для статьи «От исходника к байткоду»
| Скрипт | Что показывает |
|---|---|
optimizer_passes.py |
что оптимизаций две, кто из них что свернул, и как это распределение изменилось между 3.13 и 3.14 |
for v in 3.12 3.13 3.14; do python$v bench/source-to-bytecode/optimizer_passes.py; done
Каталог появился 27.08.2026. Числа про состав объекта кода лежат уровнем выше,
в bench/bytecode_bench.py; сюда вынесено то, что этот скрипт не покрывает, —
разделение проходов оптимизатора.
Зачем это понадобилось
В статье пятый шаг конвейера назван одним пунктом — «Граф и оптимизации» — и там же сказано, что наблюдать его нечем. Первое неточно, второе перестало быть верным в 3.13.
Оптимизаций в CPython две, на разных представлениях и в разных файлах:
| проход | где живёт | что делает |
|---|---|---|
| по AST | Python/ast_opt.c (3.11–3.13) → Python/ast_preprocess.c (3.14); точка входа _PyAST_Optimize → _PyAST_Preprocess |
работает на дереве, до генерации инструкций |
| по CFG | Python/flowgraph.c (появился в 3.12) |
граф базовых блоков, peephole, недостижимый код |
Разделить их из Python можно ровно одним способом: флагом
ast.PyCF_OPTIMIZED_AST, добавленным в 3.13. Он возвращает дерево после
первого прохода и до второго. Что свёрнуто в этом дереве — свернул проход по
AST; что свёрнуто в байткоде, но не в дереве, — свернул проход по CFG. На 3.12
флага нет, и разделить проходы наблюдением там нельзя; скрипт печатает прочерк.
Запись прогонов: 27.08.2026
3.13.7
ast.PyCF_OPTIMIZED_AST: True
случай | свёрнуто в AST | что в co_consts / опкодах
константная арифметика | да | (None, 14) RESUME RETURN_CONST(14)
унарный минус | да | (None, -5) RESUME RETURN_CONST(-5)
литеральный кортеж | да | (None, (1, 2, 3)) RESUME RETURN_CONST((1, 2, 3))
умножение строки | да | (None, 'aaaa...') RESUME RETURN_CONST('aaaa...')
список в `in` → кортеж | да | (None, (1, 2, 3)) RESUME LOAD_FAST(x) LOAD_CONST((1,2,3)) CONTAINS_OP
формат `%` | да | (None,) RESUME LOAD_FAST(x) CONVERT_VALUE(str) FORMAT_SIMPLE
двойное отрицание | нет | (None,) RESUME LOAD_FAST(x) TO_BOOL RETURN_VALUE
мёртвая ветка | да | (None, 'always') RESUME NOP RETURN_CONST('always')
слишком большая степень | нет | (None, 2, 100000) RESUME LOAD_CONST(2) LOAD_CONST(100000) BINARY_OP(**)
деление на ноль | нет | (None, 1, 0) RESUME LOAD_CONST(1) LOAD_CONST(0) BINARY_OP(/)
Константы, на которые не смотрит ни одна инструкция: нет ни одной
Дерево после прохода по AST, дословно:
константная арифметика Return(value=Constant(value=14))
унарный минус Return(value=Constant(value=-5))
литеральный кортеж Return(value=Constant(value=(1, 2, 3)))
список в `in` → кортеж Return(value=Compare(left=Name(id='x'), ops=[In()],
comparators=[Constant(value=(1, 2, 3))]))
формат `%` Return(value=JoinedStr(values=[FormattedValue(
value=Name(id='x'), conversion=115)]))
3.14.7
случай | свёрнуто в AST | что в co_consts / опкодах
константная арифметика | нет | (2,) RESUME LOAD_SMALL_INT(14) RETURN_VALUE
унарный минус | нет | (5, -5) RESUME LOAD_CONST(-5) RETURN_VALUE
литеральный кортеж | нет | (1, (1, 2, 3)) RESUME LOAD_CONST((1, 2, 3)) RETURN_VALUE
умножение строки | нет | ('a', 'aaaa...') RESUME LOAD_CONST('aaaa...') RETURN_VALUE
список в `in` → кортеж | да | (1, (1, 2, 3)) RESUME LOAD_FAST_BORROW(x) LOAD_CONST((1,2,3)) …
формат `%` | да | (None,) RESUME LOAD_FAST_BORROW(x) CONVERT_VALUE(str) …
двойное отрицание | нет | (None,) RESUME LOAD_FAST_BORROW(x) TO_BOOL RETURN_VALUE
мёртвая ветка | да | (False, 'always') RESUME NOP LOAD_CONST('always') RETURN_VALUE
Константы, на которые не смотрит ни одна инструкция:
константная арифметика co_consts=(2,) лишнее: (2,)
унарный минус co_consts=(5, -5) лишнее: (5,)
литеральный кортеж co_consts=(1, (1, 2, 3)) лишнее: (1,)
умножение строки co_consts=('a', 'aaaa...') лишнее: ('a',)
мёртвая ветка co_consts=(False, 'always') лишнее: (False,)
слишком большая степень co_consts=(2, 100000) лишнее: (2,)
деление на ноль co_consts=(1,) лишнее: (1,)
Границы свёртки, одинаковые на 3.12, 3.13 и 3.14
return 'a' * 4096 свёрнуто: True
return 'a' * 4097 свёрнуто: False
return 2 ** 64 свёрнуто: True
return 2 ** 65 свёрнуто: False
return (1, 2) * 128 свёрнуто: True
return (1, 2) * 129 свёрнуто: False
Что показали прогоны
Главное: между 3.13 и 3.14 свёртка константной арифметики переехала из AST в
CFG. На 3.13 2 + 3 * 4 уже свёрнуто в дереве (Constant(value=14)); на
3.14 дерево после прохода по AST содержит исходный BinOp, а константа
появляется только в байткоде. Байткод при этом в обоих случаях свёрнут — снаружи
разницы не видно, и именно поэтому статья, которая сливает проходы в один шаг,
не ошибается в результате, но не может объяснить, что изменилось.
Это видно и в исходнике: fold_binop/fold_unaryop из Python/ast_opt.c
(3.13) в 3.14 отсутствуют, а в Python/flowgraph.c (3.14) появились
fold_const_binop и fold_const_unaryop. Ограничители переехали дословно:
MAX_INT_SIZE 128, MAX_COLLECTION_SIZE 256, MAX_STR_SIZE 4096,
MAX_TOTAL_ITEMS 1024 — те же имена и те же значения в обоих файлах. Последний
блок прогона это подтверждает снаружи: граница «свернётся / не свернётся» на
3.12, 3.13 и 3.14 стоит в одном и том же месте.
Что осталось за проходом по AST в 3.14. Не всё: '%s' % (x,) по-прежнему
переписывается в JoinedStr (это optimize_format, единственная ветка,
оставшаяся в fold_binop файла ast_preprocess.c), список в in по-прежнему
становится кортежем, и мёртвая ветка if False по-прежнему видна в дереве
свёрнутой в Constant(value=False). Проход не исчез — он сузился до
преобразований формы дерева, а вычисление константных выражений отдал графу.
Побочное наблюдение, объясняющее старую заметку статьи. В статье честно
записано, что на 3.14 co_consts стал (1,) вместо (None, 1) и причина не
прочитана. Прогон показывает больше: на 3.14 в co_consts остаются константы,
на которые не смотрит ни одна инструкция — 2 для 2 + 3 * 4, 'a' для
'a' * 1000, False для вырезанной ветки. На 3.12 и 3.13 таких остатков нет
ни одного. Это ровно то, чего и следует ждать, если таблица констант
заполняется при генерации инструкций, а свёртка происходит после неё, на
графе: свёрнутый операнд остаётся в таблице сиротой. Плюс мелкие целые в 3.14
грузятся LOAD_SMALL_INT, которому co_consts не нужен вовсе.
Чего в скрипте нет и что не проверено
- Времени. Здесь ничего не меряется секундомером: предмет — состав
co_constsи цепочка опкодов. - Наблюдения самого CFG. Граф потока управления наружу по-прежнему не
выставлен;
PyCF_OPTIMIZED_ASTпоказывает только границу между проходами, а не внутренности второго. - 3.11 не покрыта.
Python/flowgraph.cтам ещё нет (файл появился в 3.12), и деление на два прохода в том виде, в каком его описывает эта страница, к 3.11 неприменимо. - Причина переезда не проверена по обсуждению. Факт снят запуском и сверен
с исходником на тегах
v3.13.7иv3.14.7; ссылку на issue или PR, где это решение принималось, я не искал и здесь её нет.
Script
165 lines"""Два разных прохода оптимизатора, а не один шаг.
В конвейере компилятора CPython оптимизаций **две**, и живут они в разных
файлах и на разных представлениях:
* **проход по AST** — `Python/ast_opt.c` в 3.11–3.13, переименован в
`Python/ast_preprocess.c` в 3.14 (`_PyAST_Optimize` → `_PyAST_Preprocess`);
* **проход по графу потока управления (CFG)** — `Python/flowgraph.c`,
появился в 3.12; там же peephole.
Разделить их из Python можно ровно одним способом — флагом
`ast.PyCF_OPTIMIZED_AST` (добавлен в 3.13): он возвращает дерево **после**
прохода по AST и **до** генерации инструкций. Всё, что видно в этом дереве
свёрнутым, свернул проход по AST; всё, что свёрнуто в байткоде, но не в дереве,
свернул проход по CFG.
Главное наблюдение скрипта: **между 3.13 и 3.14 свёртка константной арифметики
переехала из AST в CFG**. На 3.13 `2 + 3 * 4` уже свёрнуто в дереве; на 3.14
дерево содержит исходный `BinOp`, а константа появляется только в байткоде.
for v in 3.12 3.13 3.14; do python$v bench/source-to-bytecode/optimizer_passes.py; done
На 3.12 флага нет, и колонка «после прохода по AST» честно пуста: разделить
проходы на этой версии наблюдением нельзя.
"""
import ast
import dis
import sys
CASES = [
("константная арифметика", "return 2 + 3 * 4"),
("унарный минус", "return -5"),
("литеральный кортеж", "return (1, 2, 3)"),
("умножение строки", "return 'a' * 1000"),
("список в `in` → кортеж", "return x in [1, 2, 3]"),
("формат `%`", "return '%s' % (x,)"),
("двойное отрицание", "return not not x"),
("мёртвая ветка", "if False:\n return 'never'\n return 'always'"),
("слишком большая степень", "return 2 ** 100000"),
("слишком длинный сдвиг", "return 1 << 100000"),
("деление на ноль", "return 1 / 0"),
]
HAS_OPTIMIZED_AST = hasattr(ast, "PyCF_OPTIMIZED_AST")
def function_of(source):
src = "def f(x):\n " + source + "\n"
module = compile(src, "<probe>", "exec")
return src, next(k for k in module.co_consts if hasattr(k, "co_consts"))
def optimized_ast(src):
"""Дерево после прохода по AST (3.13+). До 3.13 наблюдать нечем."""
if not HAS_OPTIMIZED_AST:
return None
tree = compile(src, "<probe>", "exec", ast.PyCF_OPTIMIZED_AST)
return tree.body[0].body[0]
def folded_in_ast(node):
"""Свёрнуто ли выражение уже в дереве: осталась одна Constant."""
if node is None:
return "—"
for sub in ast.walk(node):
if isinstance(sub, (ast.BinOp, ast.UnaryOp)):
return "нет"
if isinstance(sub, ast.Tuple) and all(
isinstance(e, ast.Constant) for e in sub.elts
):
return "нет" # кортеж ещё строится поэлементно
return "да"
def shape(code):
ops = []
for ins in dis.get_instructions(code):
arg = ins.argrepr
if not arg and ins.arg is not None and ins.opname == "LOAD_SMALL_INT":
arg = str(ins.arg) # в 3.14 argrepr у него пуст
ops.append(ins.opname + (f"({arg})" if arg else ""))
return ops
def short(value, width=42):
text = repr(value)
return text if len(text) <= width else text[: width - 3] + "..."
def unreferenced_consts(code):
"""Константы в co_consts, на которые не смотрит ни одна инструкция.
Их наличие означает, что таблица констант была заполнена ДО свёртки, то
есть свёртка произошла позже — на графе, а не на дереве.
"""
used = set()
if code.co_consts and code.co_consts[0] is None:
used.add(0) # до 3.14 None в нулевом слоте — соглашение, а не остаток
for ins in dis.get_instructions(code):
if ins.opname in ("LOAD_CONST", "RETURN_CONST", "KW_NAMES",
"LOAD_CONST_IMMORTAL", "LOAD_CONST_MORTAL"):
if isinstance(ins.arg, int):
used.add(ins.arg)
return [c for i, c in enumerate(code.co_consts) if i not in used]
def main():
print("PY", sys.version.split()[0])
print("ast.PyCF_OPTIMIZED_AST:", HAS_OPTIMIZED_AST)
print()
print("1. Кто свернул: проход по AST или проход по CFG")
print(f"{'случай':<26} | {'свёрнуто в AST':<14} | что в co_consts / опкодах")
print("-" * 100)
for label, source in CASES:
src, code = function_of(source)
in_ast = folded_in_ast(optimized_ast(src))
ops = shape(code)
print(f"{label:<26} | {in_ast:<14} | {short(code.co_consts, 34):<34} "
f"{' '.join(ops)[:46]}")
print()
if HAS_OPTIMIZED_AST:
print(" «да» — свернул проход по AST (ast_opt.c / ast_preprocess.c);")
print(" «нет» + свёрнутый байткод — свернул проход по CFG (flowgraph.c).")
else:
print(" На этой версии колонка пуста: PyCF_OPTIMIZED_AST добавлен в 3.13.")
print()
print("2. Дерево после прохода по AST, дословно")
for label, source in CASES[:6]:
src, _ = function_of(source)
node = optimized_ast(src)
dumped = ast.dump(node) if node is not None else "(флага нет)"
print(f" {label:<26} {dumped[:110]}")
print()
print("3. Константы, на которые не смотрит ни одна инструкция")
print(" (признак того, что таблица заполнялась до свёртки)")
any_left = False
for label, source in CASES:
_, code = function_of(source)
leftovers = unreferenced_consts(code)
if leftovers:
any_left = True
print(f" {label:<26} co_consts={short(code.co_consts, 34):<34}"
f" лишнее: {short(tuple(leftovers), 30)}")
if not any_left:
print(" нет ни одной — на этой версии свёртка успевает до заполнения таблицы")
print()
print("4. Границы свёртки: у обоих проходов они одни и те же")
print(" MAX_INT_SIZE 128 бит, MAX_STR_SIZE 4096 символов,")
print(" MAX_COLLECTION_SIZE 256 элементов — эти имена и значения")
print(" переехали из ast_opt.c (3.13) в flowgraph.c (3.14) без изменений.")
for source in ("return 'a' * 4096", "return 'a' * 4097",
"return 2 ** 64", "return 2 ** 65",
"return (1, 2) * 128", "return (1, 2) * 129"):
_, code = function_of(source)
folded = not any(o.startswith("BINARY_OP") for o in shape(code))
print(f" {source:<24} свёрнуто: {folded}")
if __name__ == "__main__":
main()