Deep Engineering

ЗАМЕР

bench/free-threading-races/gil_reenable.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/concurrency/free-threading-races

Запись прогона

Гонки без GIL: замеры для статьи

Проверяется утверждение: код, который в сборке с GIL годами работал правильно, в свободной сборке ломается — хотя гонка в нём была всегда. GIL не делал операции атомарными; он лишь не давал потоку уступить управление в середине короткой последовательности инструкций.

Один и тот же код запускается на трёх интерпретаторах:

сборка что это
3.14.10 обычная сборка, GIL включён
3.14.10t свободная сборка той же версии, GIL выключен
3.15.2t свободная сборка следующей версии: в ней исправлена гонка итератора range (cpython#129068)

Как запустить

uv python install 3.14.10 3.14.10t 3.15.2t
./run.sh        # записывает runs/<сборка>.txt

Нужен компилятор C (cc): gil_reenable.py собирает два крошечных расширения из ext/ под тот интерпретатор, которым запущен.

Скрипт Что меряет
bytecode.py инструкции между чтением и записью в двух формах приращения и есть ли среди них точка, где поток может уступить GIL
lost_update.py сколько приращений из 4 000 000 теряется: c.n += 1, d["n"] += 1, c.n = c.n + one(); два исправления и их цена
check_then_act.py ленивый кеш «проверил — записал»: два отдельных показателя — лишние вызовы make() (extra make() calls) и ключи, для которых потоки получили разные объекты (keys split); setdefault и блокировка. Раскол считается по самим объектам: каждый полученный объект удерживается до конца опыта, id сравниваются после join всех потоков
shared_iterator.py один итератор range или списка на четыре потока: повторы, пропуски, значения вне источника; очередь как исправление
gil_reenable.py импорт расширения многофазной инициализации без слота Py_mod_gil включает GIL для всего процесса: предупреждение и ускорение до и после (однофазный модуль заявляет поддержку вызовом PyUnstable_Module_SetGIL; здесь не меряется)
context_flags.py thread_inherit_context и context_aware_warnings: тот же код даёт другой результат

runs/3.14.10t-old-flags.txt — context_flags.py в свободной сборке с ключами -X thread_inherit_context=0 -X context_aware_warnings=0: так возвращается поведение сборки с GIL.

Машина замеров

2 vCPU (Intel Xeon; модель и частота — во второй строке каждой записи, текущие записи сняты на 2.10GHz), Linux, интерпретаторы из python-build-standalone (uv python install). Числа потерь от прогона к прогону разные — это и есть содержание замера, поэтому печатаются все пять прогонов, а не среднее. Времена — лучшее из пяти.

Скрипт

124 строк
"""Одно неготовое расширение включает GIL обратно — для всего процесса.

Два крошечных расширения из ext/: legacy.c не объявляет слот Py_mod_gil,
ready.c объявляет Py_MOD_GIL_NOT_USED. Скрипт собирает оба под текущий
интерпретатор и в отдельном процессе на каждый случай меряет одно и то же:
ускорение счёта на чистом Python в два потока против одного — до импорта
расширения и после.

Отдельный процесс нужен потому, что GIL включается на весь процесс и обратно
не выключается: второй случай в том же процессе мерил бы уже последствия
первого.

Случаи:
  ready            — готовое расширение;
  legacy           — неготовое;
  legacy, PYTHON_GIL=0 — неготовое, но GIL принудительно оставлен выключенным.

В сборке с GIL проверка не имеет смысла: печатается строка об этом.
"""
import os
import subprocess
import sys
import sysconfig
import threading
import time
import warnings

from common import env_line, header

HERE = os.path.dirname(os.path.abspath(__file__))
REPS = 5
WORK = 3_000_000


def crunch(n):
    x = 0
    for i in range(n):
        x += i * i
    return x


def speedup():
    """Лучшее время одного потока на всей работе / лучшее время двух на половинах.

    Берутся лучшие из REPS, а не медиана отношений: на двух виртуальных ядрах
    соседний процесс то и дело замедляет один из прогонов, и отношение двух
    случайных замеров давало «×2,5» — больше числа ядер, чего не бывает.
    """
    ones, twos = [], []
    for _ in range(REPS):
        t0 = time.perf_counter()
        crunch(2 * WORK)
        ones.append(time.perf_counter() - t0)
        threads = [threading.Thread(target=crunch, args=(WORK,)) for _ in range(2)]
        t0 = time.perf_counter()
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        twos.append(time.perf_counter() - t0)
    return min(ones) / min(twos)


def build(name):
    out_dir = os.path.join(HERE, "ext", "build-" + sysconfig.get_config_var("SOABI"))
    os.makedirs(out_dir, exist_ok=True)
    target = os.path.join(out_dir, name + sysconfig.get_config_var("EXT_SUFFIX"))
    subprocess.run(
        ["cc", "-O2", "-fPIC", "-shared", "-I", sysconfig.get_paths()["include"],
         os.path.join(HERE, "ext", name + ".c"), "-o", target],
        check=True,
    )
    return out_dir


def child(name, out_dir):
    """Выполняется в дочернем процессе: замер до импорта, импорт, замер после."""
    crunch(WORK)  # прогрев: первый проход идёт до специализации байткода
    before = (sys._is_gil_enabled(), speedup())
    sys.path.insert(0, out_dir)
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        __import__(name)
    after = (sys._is_gil_enabled(), speedup())
    print(f"  before the import: GIL {'on' if before[0] else 'off'}, speedup ×{before[1]:.2f}")
    print(f"  after the import:  GIL {'on' if after[0] else 'off'}, speedup ×{after[1]:.2f}")
    for w in caught:
        print(f"  warning {w.category.__name__}: {w.message}")
    if not caught:
        print("  no warnings")


def main():
    if len(sys.argv) == 3 and sys.argv[1] == "--child":
        child(sys.argv[2], os.environ["BENCH_EXT_DIR"])
        return

    print(env_line())
    if not sysconfig.get_config_var("Py_GIL_DISABLED"):
        print("GIL-enabled build: nothing to switch on, the check does not apply")
        return

    out_dir = build("legacy")
    build("ready")
    print(f"work: {WORK * 2:,} iterations in one thread against two threads of {WORK:,}; best of {REPS} runs")

    for n, title, name, extra_env in [
        (1, "EXTENSION THAT DECLARES IT CAN RUN WITHOUT THE GIL", "ready", {}),
        (2, "EXTENSION WITHOUT THE SLOT", "legacy", {}),
        (3, "EXTENSION WITHOUT THE SLOT, GIL FORCED OFF", "legacy", {"PYTHON_GIL": "0"}),
    ]:
        header(n, title)
        env = dict(os.environ, BENCH_EXT_DIR=out_dir, **extra_env)
        env.pop("PYTHON_GIL", None) if not extra_env else None
        res = subprocess.run(
            [sys.executable, os.path.abspath(__file__), "--child", name],
            env=env, cwd=HERE, capture_output=True, text=True, check=True,
        )
        sys.stdout.write(res.stdout)


if __name__ == "__main__":
    main()