Deep Engineering

ЗАМЕР

bench/comprehensions/bytecode.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/comprehensions
Как запустить
for v in 3.11 3.12 3.13 3.14; do python$v bench/comprehensions/scope.py; done

Запись прогона

Замеры для урока «Включения: где проходит граница»

Скрипт Что показывает
scope.py где именно проходит граница области видимости: переменная не течёт, первый итерируемый вычисляется снаружи, тело класса даёт NameError там, где функция не даёт
walrus.py единственное, что ходит через границу наружу: := связывает имя в объемлющей области (PEP 572), плюс два запрета и заявленный мотив
silent.py две ошибки, которые не падают: подменённая константа в теле класса и утёкший морж
cost.py включение против цикла против map — и что на самом деле решает
bytecode.py что изменил PEP 709, чего не изменил, и почему «вынести метод в локальную» больше не помогает
for v in 3.11 3.12 3.13 3.14; do python$v bench/comprehensions/scope.py; done

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Главное, что здесь измерено

Граница проходит не по скобкам включения. Справочник: «aside from the iterable expression in the leftmost for clause, the comprehension is executed in a separate implicitly nested scope». Отсюда четыре строки подряд в одном теле класса, из которых работают первая и последняя:

[x for x in raw]                  -> [2, 3, 5]
[x * multiplier for x in raw]     -> NameError: name 'multiplier' is not defined
[x for x in raw if x > limit]     -> NameError: name 'limit' is not defined
[x + y for x in raw for y in raw] -> NameError: name 'raw' is not defined
обычный for/append по raw         -> [20, 30, 50]

Четвёртая строка — самая наглядная: raw в ней написано дважды, и первое вхождение видно, а второе нет.

Правило не менялось четыре версии подряд. PEP 709 (3.12) встроил включения в вызывающий код — MAKE_FUNCTION и вложенного объекта кода у них больше нет, — но область видимости осталась прежней. Проверено: на 3.11, 3.12, 3.13 и 3.14 scope.py печатает одни и те же три NameError.

Выбор «включение или цикл или map» почти ничего не стоит. На 3.13.7 разброс между тремя способами — 1,13 раза. Решает другое: убрать вызов кода на Python из тела даёт 1,7 раза, а заменить условие на Python вызовом filter(None, …) — ещё 1,8.

Приём «вынести метод в локальную» стоит дороже, чем не выносить. Около десяти процентов, устойчиво. Инструкций в теле цикла при этом поровну — девять и девять; разница в том, что out.append(...) компилируется в LOAD_ATTR с установленным младшим битом (форма, которая кладёт на стек метод и self сразу), а вызов через локальную переменную идёт обычным путём и подставляет self на каждом вызове.

Чего здесь нет и почему

Сравнения спискового включения с генераторным выражением. Оно разобрано в уроке «Итераторы и генераторы» — там же и про то, что PEP 709 генераторных выражений не касается. Повторять его здесь значило бы дать два разных набора чисел на один и тот же вопрос.

Времени для walrus.py и scope.py. Область видимости — вопрос семантики; наносекунды в этих разделах ничего бы не подкрепили.

Правило версий

Время между версиями не сравнивается. Числа 3.13.7 и 3.14.7 в cost.py приводятся рядом как два независимых результата; сравнивать можно только кратности внутри одного прогона. Наблюдения scope.py, walrus.py и bytecode.py от версии к версии сравнивать можно: там не время, а имена инструкций, наличие объектов кода и текст сообщений об ошибках.

Скрипт

114 строк
"""Что PEP 709 изменил, чего не изменил — и почему «вынести метод» подорожало.

ТРИ ВОПРОСА, НА КОТОРЫЕ ОТВЕЧАЕТ ЭТОТ СКРИПТ.

  1. ЧТО ИЗМЕНИЛОСЬ В 3.12. PEP 709 встроил списковые, словарные и
     множественные включения в вызывающий код: отдельного объекта кода у них
     больше нет. Видно по `co_consts` и по отсутствию `MAKE_FUNCTION`.

  2. ЧЕГО ОН НЕ ИЗМЕНИЛ. Область видимости. PEP говорит, что изоляция
     переменных сохраняется — теперь через сохранение и восстановление на
     стеке вместо вызова функции. Проверяется не байт-кодом, а `scope.py`:
     на всех четырёх версиях включение в теле класса даёт один и тот же
     `NameError`.

  3. ПОЧЕМУ ПРИЁМ «ВЫНЕСТИ МЕТОД В ЛОКАЛЬНУЮ» БОЛЬШЕ НЕ ПОМОГАЕТ. Совет
     старый и когда-то был верен. Замер (`cost.py`) даёт обратный знак около
     десяти процентов, и объяснение — здесь: инструкций в теле цикла ровно
     столько же, но вызов идёт другим путём.
"""
import dis
import sys

print("PY", sys.version.split()[0])


def rule(title):
    print()
    print(title)
    print("-" * len(title))


def opnames(code):
    return [i.opname for i in dis.get_instructions(code)]


# ------------------------------------------------ 1. что изменил PEP 709
rule("1. Встраивание включений (PEP 709, 3.12)")

CASES = [
    ("списковое включение", "x = [i for i in src]"),
    ("словарное включение", "x = {i: i for i in src}"),
    ("множественное включение", "x = {i for i in src}"),
    ("генераторное выражение", "x = (i for i in src)"),
]
for label, src in CASES:
    code = compile(src, "<bench>", "exec")
    names = opnames(code)
    has_make = "MAKE_FUNCTION" in names
    nested = [c for c in code.co_consts if hasattr(c, "co_name")]
    print(
        f"  {label:<26} MAKE_FUNCTION: {'есть' if has_make else 'нет ':<5}"
        f"  вложенный объект кода: {nested[0].co_name if nested else 'нет'}"
    )

print()
print("  Генераторное выражение PEP 709 не касается — у него отдельная функция")
print("  и отдельный кадр остались. Три остальных встроены.")


# ------------------------------------- 3. почему «вынести метод» дороже
rule("2. Почему «вынести метод в локальную» стало дороже")


def by_loop(src, f):
    out = []
    for x in src:
        out.append(f(x))
    return out


def by_loop_hoisted(src, f):
    out = []
    append = out.append
    for x in src:
        append(f(x))
    return out


def loop_body(fn):
    """Инструкции между FOR_ITER и JUMP_BACKWARD — то, что исполняется на
    каждом элементе. Всё остальное отрабатывает один раз и в счёт не идёт."""
    names = opnames(fn)
    start = names.index("FOR_ITER") + 1
    end = names.index("JUMP_BACKWARD")
    return names[start:end]


for fn in (by_loop, by_loop_hoisted):
    body = loop_body(fn)
    print(f"  {fn.__name__:<18} {len(body)} инструкций в теле цикла")
    print(f"  {'':<18} {' '.join(body)}")

print()
n_plain = len(loop_body(by_loop))
n_hoist = len(loop_body(by_loop_hoisted))
print(f"  Инструкций в теле цикла поровну — {n_plain} и {n_hoist}. Разница не в")
print("  количестве, а в том, ЧТО стоит на месте вызова.")
print()
print("  `out.append(...)` компилируется в LOAD_ATTR с установленным младшим")
print("  битом аргумента. Документация dis описывает эту форму так: «if")
print("  STACK[-1] has a method with the correct name, the bytecode pushes the")
print("  unbound method and STACK[-1]. STACK[-1] will be used as the first")
print("  argument (self) by CALL». То есть self кладётся на стек здесь же, и")
print("  объекта связанного метода не возникает вовсе.")
print()
print("  `append(...)` через локальную переменную — обычный вызов обычного")
print("  объекта: PUSH_NULL плюс вызов уже связанного метода, которому self")
print("  придётся подставить самому, на каждом вызове.")
print()
print("  Замер (cost.py) даёт разницу около десяти процентов не в пользу")
print("  «оптимизации». Совет вынести метод в локальную пришёл из времён, когда")
print("  поиск атрибута был непредсказуемо дорогим; с тех пор подешевел он, а")
print("  не альтернатива ему.")