Deep Engineering

MEASUREMENT

bench/comprehensions/cost.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/interview/python/comprehensions
How to run it
for v in 3.11 3.12 3.13 3.14; do python$v bench/comprehensions/scope.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

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

Скрипт Что показывает
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 от версии к версии сравнивать можно: там не время, а имена инструкций, наличие объектов кода и текст сообщений об ошибках.

Script

147 lines
"""Включение против цикла и против map — и что на самом деле решает.

ЧТО ИЗМЕРЯЕТСЯ. Спор «включение или цикл или map» ведут так, будто выбор между
ними что-то стоит. Здесь он измерен — и оказывается, что разброс между всеми
тремя укладывается в четверть, а главный множитель лежит совсем в другом месте:
происходит ли на каждый элемент вызов кода на Python.

Поэтому строк здесь два набора:

  1. ТРИ СПОСОБА ОДНОГО И ТОГО ЖЕ, все с вызовом `f(x)` на элемент.
  2. ТЕ ЖЕ ОБЪЁМЫ БЕЗ ВЫЗОВА — выражение вместо функции и `filter(None, …)`
     вместо условия на Python.

Отдельная строка — приём «вынести метод в локальную переменную». Он остался в
советах с тех времён, когда поиск атрибута был дороже; измерение показывает
обратный знак, а дизассемблер объясняет почему (`bytecode.py`).

ВСЁ МЕРЯЕТСЯ ВНУТРИ ФУНКЦИЙ, а не на модульном уровне. Иначе `out` и `append`
оказались бы глобальными именами, и сравнение «в локальной или нет» потеряло бы
смысл ровно в том месте, ради которого делается.

ОБЩЕЕ ПРАВИЛО ЗАМЕРОВ: время между версиями не сравнивается вообще.
Сравнивается только измеренное внутри одного запуска одного интерпретатора —
все кратности ниже такие.
"""
import sys
import timeit

N = 2_000
REPEAT = 7
SIZE = 1_000

SETUP = f"""
src = list(range({SIZE}))

def f(x):
    return x + 1

def by_comprehension(src, f):
    return [f(x) for x in src]

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 by_map(src, f):
    return list(map(f, src))

def by_expression(src):
    return [x + 1 for x in src]

def by_comprehension_filter(src):
    return [x for x in src if x]

def by_filter_none(src):
    return list(filter(None, src))
"""

WITH_CALL = [
    ("включение [f(x) for x in src]", "by_comprehension(src, f)"),
    ("цикл с out.append(...)", "by_loop(src, f)"),
    ("цикл, append в локальной", "by_loop_hoisted(src, f)"),
    ("list(map(f, src))", "by_map(src, f)"),
]
WITHOUT_CALL = [
    ("включение [x + 1 for x in src]", "by_expression(src)"),
    ("включение с условием if x", "by_comprehension_filter(src)"),
    ("list(filter(None, src))", "by_filter_none(src)"),
]


def best(stmt):
    return min(timeit.repeat(stmt, setup=SETUP, number=N, repeat=REPEAT)) / N


# ЗАЧЕМ ВЕСЬ ЗАМЕР ПОВТОРЯЕТСЯ ЦЕЛИКОМ.
#
# Несущее утверждение урока — не «включение быстрее цикла», а «разброс между
# тремя способами меньше, чем шатание самого прибора, поэтому выбирать надо по
# читаемости». Проверить это одиночным прогоном нельзя: он печатает три числа
# и молчит о том, повторимы ли они и в том ли порядке. Поэтому весь замер
# прогоняется OUTER раз, и внизу печатается, укладывается ли разброс между
# тремя способами в собственное шатание каждого из них.
OUTER = 5

print("PY", sys.version.split()[0], f"| лучшее из {REPEAT} прогонов, список из {SIZE}")

ALL_ROWS = WITH_CALL + WITHOUT_CALL
series = {label: [] for label, _ in ALL_ROWS}
for _ in range(OUTER):
    for label, stmt in ALL_ROWS:
        series[label].append(best(stmt))
results = {label: min(vals) for label, vals in series.items()}

print()
print("  С вызовом функции на каждый элемент:")
for label, _ in WITH_CALL:
    print(f"    {label:<32} {results[label] * 1e6:8.1f} мкс")

print()
print("  Без вызова кода на Python:")
for label, _ in WITHOUT_CALL:
    print(f"    {label:<32} {results[label] * 1e6:8.1f} мкс")

base = results["включение [f(x) for x in src]"]
print()
print("  Относительно включения с вызовом:")
for label in results:
    print(f"    {label:<32} x{results[label] / base:5.2f}")

# Три способа — включение, цикл и map. Строка «append в локальной» из этого
# счёта исключена намеренно: это не четвёртый способ написать то же самое, а
# приём оптимизации, и меряется он отдельно.
THREE = [
    "включение [f(x) for x in src]",
    "цикл с out.append(...)",
    "list(map(f, src))",
]
spread = max(results[k] for k in THREE) / min(results[k] for k in THREE)
hoist = results["цикл, append в локальной"] / results["цикл с out.append(...)"]
print()
print(f"  РАЗМАХ ПО {OUTER} ПОВТОРАМ ВСЕГО ЗАМЕРА, в кратностях к включению:")
ratios = {label: sorted(v / base for v in vals) for label, vals in series.items()}
for label in THREE:
    lo, hi = ratios[label][0], ratios[label][-1]
    print(f"    {label:<32} x{lo:.2f} .. x{hi:.2f}   размах {hi - lo:.2f}")
worst_spread = max(ratios[k][-1] - ratios[k][0] for k in THREE)
gap = max(ratios[k][0] for k in THREE) - min(ratios[k][0] for k in THREE)
print(f"    расстояние между крайними способами   {gap:.2f}")
print(f"    наибольший размах одного способа      {worst_spread:.2f}")
print(f"    различим ли выбор способа прибором    {'да' if gap > worst_spread else 'нет'}")

print()
print(f"  Разброс между тремя способами с вызовом: {spread:.2f} раза.")
print(f"  «append в локальной» против обычного цикла: x{hoist:.2f} — приём стоит дороже.")
print(f"  Убрать вызов: {base / results['включение [x + 1 for x in src]']:.2f} раза.")
print(f"  Убрать код на Python совсем: {results['включение с условием if x'] / results['list(filter(None, src))']:.2f} раза.")