ЗАМЕР
bench/adaptive/cost.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/python/runtime/adaptive-interpreter
- Как запустить
python3.11 bench/adaptive/specialize.py python3.12 bench/adaptive/specialize.py python3.13 bench/adaptive/specialize.py python3.14 bench/adaptive/specialize.py python3.13 bench/adaptive/cost.py python3.14 bench/adaptive/cost.py python3.14 bench/adaptive/jit.py PYTHON_JIT=1 python3.14 bench/adaptive/jit.py
Запись прогона
Замеры для статьи «Адаптивный интерпретатор»
| Скрипт | Что делает |
|---|---|
specialize.py |
карта семейств инструкций, что во что превращается после прогрева, на каком выполнении это случается, что происходит при смене типа |
cost.py |
цена места вызова, через которое проходит один тип против пяти: одна и та же работа, различается только полиморфизм |
jit.py |
второй уровень включён и выключен на ОДНОЙ сборке — четыре коротких цикла |
python3.11 bench/adaptive/specialize.py
python3.12 bench/adaptive/specialize.py
python3.13 bench/adaptive/specialize.py
python3.14 bench/adaptive/specialize.py
python3.13 bench/adaptive/cost.py
python3.14 bench/adaptive/cost.py
python3.14 bench/adaptive/jit.py
PYTHON_JIT=1 python3.14 bench/adaptive/jit.py
Что здесь важно прочитать правильно
specialize.py не меряет время. Он печатает то, что интерпретатор
сообщает о себе сам: имя инструкции до прогрева и после, размер семейства,
номер выполнения, на котором подстановка случилась. Такие утверждения между
версиями сравнимы — это счёт, а не секунды, — и прогон по четырём версиям
сделан именно ради сравнения.
cost.py сравнивает только строки внутри своего прогона. Пять классов
объявлены одинаково и делают одно и то же; различается лишь число типов,
проходящих через одну строку o.x. Чтение идёт внутри цикла, а не через вызов
на каждый элемент: иначе разница утонула бы в цене самого вызова.
jit.py — единственное место, где сравнение по времени законно между
запусками. Общее правило замеров запрещает сравнивать РАЗНЫЕ сборки; здесь
сборка одна, и различается ровно одна переменная окружения PYTHON_JIT. Это
контролируемый опыт, а не сравнение версий.
Запускать чередующимися парами, а не один раз. Машина дрейфует: один и тот же режим от запуска к запуску различается на несколько процентов, и на одной паре разница в четыре процента не значит ничего.
for i in 1 2 3 4 5 6; do
python3.14 bench/adaptive/jit.py --tsv
PYTHON_JIT=1 python3.14 bench/adaptive/jit.py --tsv
done
Критерий — пересечение размахов, а не порог. Шесть минимумов одного режима дают отрезок, шесть минимумов другого — второй отрезок. Если отрезки не пересекаются, разница есть; если пересекаются — сказать нечего. Порог пришлось бы выбирать, и выбор был бы подгонкой под эти четыре строки.
Что получилось на 3.14.7, шесть чередующихся пар (мкс на вызов hot()):
| нагрузка | без JIT | с JIT | вывод |
|---|---|---|---|
| счётный цикл | 981–1013 | 1144–1216 | +16,6 %, не пересекаются |
| цикл по списку с атрибутом | 520–541 | 711–739 | +36,7 %, не пересекаются |
| вызов функции в цикле | 1018–1037 | 1169–1199 | +14,8 %, не пересекаются |
| словарь в цикле | 1520–1588 | 1683–1746 | +10,7 %, не пересекаются |
Здесь же — почему пар шесть, а не три, как сказано выше. Прежняя редакция таблицы снималась на 3.14.0rc2 и давала пёструю картину: на вызове функции второй уровень выигрывал (−7,5 %), на счётном цикле и обходе списка проигрывал, на словаре размахи пересекались. На финальной 3.14.7 выигрыша нет ни на одной нагрузке, и размахи не пересекаются нигде. Когда меняется не число, а вывод, трёх пар мало.
Старые числа не приписаны рядом и не сохранены для сравнения намеренно: rc2
собран Clang 20.1.4, а 3.14.7 — Clang 22.1.3, и ставить их времена в одну
таблицу запрещает правило из bench/README.md: время между версиями не
сравнивается.
Что этими числами доказать НЕЛЬЗЯ
Приговор второму уровню. Здесь четыре коротких цикла на двух ядрах; ни
pyperformance, ни настоящей программы тут нет. Право эти числа дают ровно на
одно утверждение: включение второго уровня само по себе выигрыша не гарантирует
— на всех четырёх нагрузках он проиграл.
Скрипт
123 строк"""Во что обходится место вызова, на котором предположение не держится.
ЗАЧЕМ ЭТОТ ЗАМЕР. Специализацию легко показать через `dis`: инструкция была
одна, стала другая. Гораздо труднее показать, что она СТОИТ, — а без этого
рассказ про адаптивный интерпретатор остаётся описанием устройства, из которого
для читателя ничего не следует.
ЧЕСТНОЕ СРАВНЕНИЕ. Сравнивать «сложение целых» с «сложением строк» нельзя:
разная работа. Здесь сравнивается ОДНА И ТА ЖЕ работа — чтение атрибута `x` —
на местах вызова, различающихся только тем, сколько разных типов через них
проходит. Классы `A`, `B`, `C`, `D`, `E` объявлены одинаково и делают одно и то
же; отличается лишь то, сколько их видит одна строка кода.
1. ЦЕНА ПОЛИМОРФИЗМА. Один тип, два типа вперемешку, пять типов вперемешку.
2. СКОЛЬКО РАЗ ИНСТРУКЦИЯ МЕНЯЕТСЯ. Место вызова опрашивается на каждом шаге:
сколько раз интерпретатор переписал инструкцию за прогон.
3. ПОСЛЕДНЯЯ ИНСТРУКЦИЯ. Чем всё закончилось.
ЧЕГО ЗДЕСЬ НЕТ. Сравнения версий по времени. Строки одного прогона сравнимы
между собой, потому что меряются подряд в одном процессе; колонки разных
версий — нет.
python3.13 bench/adaptive/cost.py
python3.14 bench/adaptive/cost.py
"""
import dis
import sys
import timeit
REPEAT = 7
NUMBER = 200_000
def make_classes(n):
"""n одинаковых классов: работа одна и та же, типы разные."""
out = []
for i in range(n):
ns = {}
exec(f"class C{i}:\n def __init__(self):\n self.x = 1\n", ns)
out.append(ns[f"C{i}"])
return out
CLASSES = make_classes(5)
def make_reader():
"""Свежий объект кода: специализация живёт в нём, а не в имени.
Чтение идёт внутри цикла, а не через вызов на каждый элемент: иначе цену
самого вызова (несколько десятков наносекунд) пришлось бы вычитать, и
разница между случаями утонула бы в ней. Здесь во всех случаях выполняется
один и тот же цикл по списку одной и той же длины — различается только
последовательность типов, проходящих через ОДНУ строку `o.x`.
"""
ns = {}
exec(
"def read_all(objects):\n"
" total = 0\n"
" for o in objects:\n"
" total += o.x\n"
" return total\n",
ns,
)
return ns["read_all"]
def opname(fn):
for i in dis.get_instructions(fn, adaptive=True):
if i.opname.startswith("LOAD_ATTR"):
return i.opname
return "-"
LENGTH = 5000
def measure(kinds: int, rounds: int = 60):
"""Читаем o.x на месте вызова, через которое проходит `kinds` типов."""
read_all = make_reader()
objects = [CLASSES[i % kinds]() for i in range(LENGTH)]
changes = 0
previous = opname(read_all)
for _ in range(rounds):
read_all(objects)
now = opname(read_all)
if now != previous:
changes += 1
previous = now
times = timeit.repeat(
"read_all(objects)",
globals={"read_all": read_all, "objects": objects},
repeat=REPEAT,
number=NUMBER // LENGTH,
)
per_access = min(times) / (NUMBER // LENGTH * LENGTH) * 1e9
return per_access, changes, previous
def main() -> None:
print("PY", sys.version.split()[0])
print(f"лучшее из {REPEAT} прогонов по {NUMBER:,} чтений атрибута в цикле".replace(",", " "))
print()
print(f" {'типов на месте вызова':<24} {'нс/чтение':>10} {'переписей':>10} инструкция в конце")
base = None
for kinds in (1, 2, 3, 5):
ns, changes, last = measure(kinds)
if base is None:
base = ns
print(f" {kinds:<24} {ns:>10.2f} {changes:>10} {last} ×{ns / base:.2f}")
print()
print("Классы объявлены одинаково и делают одно и то же — различается только")
print("число типов, проходящих через ОДНУ строку кода.")
if __name__ == "__main__":
main()