ЗАМЕР
bench/adaptive/specialize.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, ни настоящей программы тут нет. Право эти числа дают ровно на
одно утверждение: включение второго уровня само по себе выигрыша не гарантирует
— на всех четырёх нагрузках он проиграл.
Скрипт
223 строк"""Что интерпретатор переписывает в себе, когда код повторяется.
ЧТО ЗДЕСЬ ПРОВЕРЯЕТСЯ. Адаптивный интерпретатор (PEP 659) обычно пересказывают
одной фразой — «CPython подставляет быстрые версии инструкций». Фраза верная и
непроверяемая: ни какие инструкции, ни когда, ни что происходит, если
предположение не оправдалось, из неё не следует. Здесь всё четыре вопроса
заданы интерпретатору напрямую.
1. КАРТА СЕМЕЙСТВ. Сколько базовых инструкций умеют специализироваться и на
сколько вариантов каждая — читается из `opcode._specializations`.
2. ЧТО ВО ЧТО ПРЕВРАЩАЕТСЯ. Десяток однострочных операций прогревается, и для
каждой печатается инструкция ДО и ПОСЛЕ. Никаких предположений: то, что
вернул `dis` с `adaptive=True`.
3. КОГДА. Сколько раз должен выполниться код, чтобы подстановка случилась.
Счётчик прогрева в PEP не зафиксирован как часть языка — это внутреннее
число сборки, и меряется оно перебором.
4. ЧТО ЕСЛИ ПРЕДПОЛОЖЕНИЕ НЕ ОПРАВДАЛОСЬ. Тот же код, но типы на месте вызова
чередуются. Видно, во что превращается инструкция и сколько это стоит.
ЧЕГО ЗДЕСЬ НЕТ. Сравнения версий по времени: сборки различаются не только
версией языка. Сравнивать можно только строки внутри одного прогона.
python3.11 bench/adaptive/specialize.py
python3.12 bench/adaptive/specialize.py
python3.13 bench/adaptive/specialize.py
python3.14 bench/adaptive/specialize.py
"""
import dis
import opcode
import sys
def rule(title):
print()
print(title)
print("-" * len(title))
def attr_names(fn, prefix=""):
"""Инструкции функции в их ТЕКУЩЕМ виде — специализированном, если он есть."""
return [
i.opname
for i in dis.get_instructions(fn, adaptive=True)
if i.opname.startswith(prefix)
]
print("PY", sys.version.split()[0])
# ------------------------------------------------------------ 1. карта
rule("1. Сколько инструкций умеют специализироваться")
families = getattr(opcode, "_specializations", {})
specialized = getattr(opcode, "_specialized_opmap", None)
total_variants = sum(len(v) for v in families.values())
print(f" базовых инструкций с семейством: {len(families)}")
print(f" всего специализированных вариантов: {total_variants}")
if specialized is not None:
print(f" (opcode._specialized_opmap: {len(specialized)})")
print()
for base, kids in sorted(families.items(), key=lambda kv: (-len(kv[1]), kv[0])):
print(f" {base:<26} {len(kids):>2}")
# ------------------------------------------------------------ 2. что во что
rule("2. Что во что превращается после прогрева")
WARMUP = 500
class Point:
def __init__(self):
self.x = 1
def method(self):
return 1
class Slotted:
__slots__ = ("x",)
def __init__(self):
self.x = 1
def plain_call(n):
return n
def add_int(a, b):
return a + b
def add_float(a, b):
return a + b
def add_str(a, b):
return a + b
def sub_list(seq, i):
return seq[i]
def sub_dict(d, k):
return d[k]
def load_attr(o):
return o.x
def load_attr_slot(o):
return o.x
def call_method(o):
return o.method()
def call_function(f, n):
return f(n)
def compare_int(a, b):
return a < b
def for_iter_list(seq):
total = 0
for x in seq:
total += 1
return total
CASES = [
("a + b, целые", add_int, ("BINARY_OP",), (1, 2)),
("a + b, вещественные", add_float, ("BINARY_OP",), (1.0, 2.0)),
("a + b, строки", add_str, ("BINARY_OP",), ("a", "b")),
("seq[i], список", sub_list, ("BINARY_SUBSCR", "BINARY_OP"), ([1, 2, 3], 1)),
("d[k], словарь", sub_dict, ("BINARY_SUBSCR", "BINARY_OP"), ({"k": 1}, "k")),
("o.x, обычный атрибут", load_attr, ("LOAD_ATTR",), (Point(),)),
("o.x, слот __slots__", load_attr_slot, ("LOAD_ATTR",), (Slotted(),)),
("o.method(), метод", call_method, ("LOAD_ATTR", "CALL"), (Point(),)),
("f(n), функция на Python", call_function, ("CALL",), (plain_call, 1)),
("a < b, целые", compare_int, ("COMPARE_OP",), (1, 2)),
("for x in список", for_iter_list, ("FOR_ITER",), ([1, 2, 3],)),
]
print(f" прогрев — {WARMUP} вызовов на случай")
print()
print(f" {'операция':<26} {'до':<22} после")
for label, fn, prefixes, args in CASES:
before = []
for p in prefixes:
before += attr_names(fn, p)
for _ in range(WARMUP):
fn(*args)
after = []
for p in prefixes:
after += attr_names(fn, p)
b = before[0] if before else "-"
a = after[0] if after else "-"
mark = "" if a != b else " (не изменилась)"
print(f" {label:<26} {b:<22} {a}{mark}")
# ------------------------------------------------------------ 3. когда
rule("3. Сколько повторений нужно, чтобы подстановка случилась")
def make_probe():
"""Свежий объект кода на каждый замер: счётчик прогрева живёт в нём."""
ns = {}
exec("def probe(a, b):\n return a + b\n", ns)
return ns["probe"]
for label, args in (("целые", (1, 2)), ("вещественные", (1.0, 2.0)), ("строки", ("a", "b"))):
probe = make_probe()
base = attr_names(probe, "BINARY_OP")[0]
hit = None
for n in range(1, 2001):
probe(*args)
now = attr_names(probe, "BINARY_OP")[0]
if now != base:
hit = (n, now)
break
if hit:
print(f" {label:<14} специализация на {hit[0]:>4}-м выполнении -> {hit[1]}")
else:
print(f" {label:<14} за 2000 выполнений не специализировалась")
# ------------------------------------------------------------ 4. деоптимизация
rule("4. Что происходит, когда предположение не оправдалось")
mono = make_probe()
for _ in range(WARMUP):
mono(1, 2)
print(f" один тип на месте вызова: {attr_names(mono, 'BINARY_OP')[0]}")
poly = make_probe()
pairs = [(1, 2), (1.0, 2.0), ("a", "b")]
for i in range(WARMUP):
poly(*pairs[i % len(pairs)])
print(f" три типа вперемешку: {attr_names(poly, 'BINARY_OP')[0]}")
# И отдельно: специализированный код, которому один раз подсунули другой тип.
mixed = make_probe()
for _ in range(WARMUP):
mixed(1, 2)
was = attr_names(mixed, "BINARY_OP")[0]
for _ in range(WARMUP):
mixed(1.0, 2.0)
print(f" сначала целые, потом вещественные: {was} -> {attr_names(mixed, 'BINARY_OP')[0]}")