Deep Engineering

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()