Deep Engineering

MEASUREMENT

bench/adaptive/jit.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/python/runtime/adaptive-interpreter
How to run it
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

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

Замеры для статьи «Адаптивный интерпретатор»

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

Script

115 lines
"""Второй уровень: даёт ли он что-нибудь на этой машине.

ЗАЧЕМ. Про JIT в 3.13–3.14 пишут много, и почти всё — пересказ анонсов. Здесь
единственный вопрос, на который можно ответить своими руками: включение второго
уровня на ТОЙ ЖЕ сборке меняет время или нет.

ПОЧЕМУ ЭТО СРАВНЕНИЕ ЗАКОННО, хотя общее правило замеров запрещает сравнивать
версии по времени. Правило запрещает сравнивать РАЗНЫЕ СБОРКИ: у них разный
компилятор и разные флаги. Здесь сборка одна и та же, и различается ровно одна
переменная окружения — `PYTHON_JIT`. Это и есть контролируемый опыт.

КАК ЗАПУСКАТЬ. Не дважды, а несколькими ЧЕРЕДУЮЩИМИСЯ парами: машина дрейфует,
и один и тот же режим от запуска к запуску различается на 8–17 %. Чередование
нужно, чтобы дрейф попал в оба режима одинаково.

    for i in 1 2 3; do
      python3.14 bench/adaptive/jit.py --tsv
      PYTHON_JIT=1 python3.14 bench/adaptive/jit.py --tsv
    done | sort

Сравнивать надо не средние, а МИНИМУМЫ по рангам: три минимума одного режима
против трёх минимумов другого, первый с первым, второй со вторым. Если знак
разницы одинаков во всех трёх позициях — это уже не дрейф.

ЧТО НЕЛЬЗЯ ИЗ ЭТОГО ВЫВОДИТЬ. Приговор JIT вообще. Здесь четыре коротких цикла
на одной машине; ни pyperformance, ни настоящей программы тут нет. Вывод, на
который эти числа дают право, ровно один: на таком коде и на этой машине
разницы либо нет, либо она не в пользу второго уровня.
"""

import sys
import timeit

REPEAT = 11


def jit_status() -> str:
    jit = getattr(sys, "_jit", None)
    if jit is None:
        return "второго уровня в этой сборке нет"
    parts = []
    for name in ("is_available", "is_enabled", "is_active"):
        fn = getattr(jit, name, None)
        if fn is not None:
            parts.append(f"{name}={fn()}")
    return ", ".join(parts)


WORKLOADS = {
    "счётный цикл (i*i)": (
        "def hot(n):\n    t = 0\n    for i in range(n):\n        t += i * i\n    return t\n",
        "hot(20000)",
    ),
    "цикл по списку с атрибутом": (
        "class P:\n"
        "    def __init__(self):\n"
        "        self.x = 1\n"
        "OBJ = [P() for _ in range(20000)]\n"
        "def hot(seq):\n"
        "    t = 0\n"
        "    for o in seq:\n"
        "        t += o.x\n"
        "    return t\n",
        "hot(OBJ)",
    ),
    "вызов функции в цикле": (
        "def inner(a):\n    return a + 1\n"
        "def hot(n):\n"
        "    t = 0\n"
        "    for i in range(n):\n"
        "        t = inner(t)\n"
        "    return t\n",
        "hot(20000)",
    ),
    "словарь в цикле": (
        "D = {i: i for i in range(1000)}\n"
        "def hot(n):\n"
        "    t = 0\n"
        "    for i in range(n):\n"
        "        t += D[i % 1000]\n"
        "    return t\n",
        "hot(20000)",
    ),
}


def main() -> None:
    tsv = "--tsv" in sys.argv
    mode = "on" if getattr(sys, "_jit", None) and sys._jit.is_enabled() else "off"

    if tsv:
        for label, (setup, stmt) in WORKLOADS.items():
            ns: dict = {}
            exec(setup, ns)
            times = timeit.repeat(stmt, globals=ns, repeat=REPEAT, number=50)
            print(f"{mode}\t{label}\t{min(times) / 50 * 1e6:.1f}")
        return

    print("PY", sys.version.split()[0])
    print("  JIT:", jit_status())
    print(f"  лучшее из {REPEAT} прогонов, микросекунды на один вызов hot()")
    print()

    for label, (setup, stmt) in WORKLOADS.items():
        ns: dict = {}
        exec(setup, ns)
        times = timeit.repeat(stmt, globals=ns, repeat=REPEAT, number=50)
        best = min(times) / 50 * 1e6
        spread = (max(times) - min(times)) / min(times) * 100
        print(f"  {label:<28} {best:8.1f} мкс   разброс по прогонам {spread:4.1f} %")


if __name__ == "__main__":
    main()