ЗАМЕР
bench/comprehensions/cost.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 от версии к версии сравнивать можно: там не время, а имена
инструкций, наличие объектов кода и текст сообщений об ошибках.
Скрипт
147 строк"""Включение против цикла и против 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} раза.")