Deep Engineering

ЗАМЕР

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]}")