Deep Engineering

MEASUREMENT

bench/gil/measure.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/concurrency/gil
How to run it
python3.13  bench/gil/measure.py
python3.13t bench/gil/measure.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

Замеры для статьи «GIL: что это на самом деле»

Ничего не моделируется — только прогоны на живых интерпретаторах. Каждый скрипт печатает JSON в поток вывода; summary.json — собранный вручную свод тех прогонов, из которых взяты числа статьи.

Почему этот каталог появился поздно

Скрипты лежали в tmp/, а tmp/ стоит в .gitignore — то есть в репозитории их не было вовсе, и числа статьи нельзя было перезапустить ни на другой машине, ни на этой же через полгода. Правило «числа в статьях берутся отсюда, а не из головы» для статей в этот момент было не выполнено, хотя для уроков выполнялось. Перенос — исправление именно этого.

Скрипт Что меряет
measure.py базовое: сборка, sys.getswitchinterval(), счёт в один и два потока, ожидание, zlib
freethreading_cost.py цена свободной сборки на однопоточном коде и там, где она проигрывает
shared_vs_owned.py общий объект против собственного; объекты собираются внутри потока и из значений времени выполнения
owner_thread.py один рабочий поток, объект чужой против своего — проверка biased reference counting (PEP 703)
interval_not_enforced.py «5 мс» — пожелание, а не гарантия: медиана ожидания против интервала на коротких и на длинных операциях
timeline.py кто владел интерпретатором в каждый момент — данные для визуализации

Запускать на каждом интерпретаторе отдельно:

python3.13  bench/gil/measure.py
python3.13t bench/gil/measure.py

Что этими замерами сравнивать нельзя

Общее правило в силе и здесь: время между версиями не сравнивается. Но у статьи про GIL есть собственная оговорка, и её надо держать в голове отдельно.

Строки вида «+14,4 %» получены сравнением двух сборок — обычной и свободнопоточной. Формально это то же самое сравнение между сборками, которое правило запрещает: подтвердить, что обе собраны одинаково и различаются ровно --disable-gil, нечем — свободнопоточного интерпретатора на машине замеров нет.

Поэтому такие проценты читаются как наблюдение на одной машине, а не как цена свободных потоков вообще. Именно так они и подписаны в тексте статьи. У официальной оценки («1–8 % на pyperformance») другое происхождение и другой набор нагрузок, и статья прямо говорит, что не опровергает её, а показывает худший случай.

Script

141 lines
"""Замеры для статьи про GIL. Ничего не моделируется — только прогоны.

Запускать на каждом интерпретаторе отдельно:
    python3.13  gil-measure.py
    python3.13t gil-measure.py
"""
import json, os, statistics, sys, threading, time, zlib

REPS = 5

def build():
    try:
        gil = sys._is_gil_enabled()
    except AttributeError:
        gil = True
    return {
        "version": sys.version.split()[0],
        "gil_enabled": bool(gil),
        "switch_interval_default": sys.getswitchinterval(),
        "cpu_count": os.cpu_count(),
    }

def spin(n):
    # Чистый python-байткод: GIL не отпускается.
    x = 0
    for i in range(n):
        x += i * i
    return x

def timed(fn, *a):
    t = time.perf_counter()
    fn(*a)
    return time.perf_counter() - t

def run_threads(target, args_list):
    ts = [threading.Thread(target=target, args=a) for a in args_list]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    return time.perf_counter() - t0

def cpu_bound(n=4_000_000):
    """Одна и та же работа: один поток против двух."""
    one = min(run_threads(spin, [(n,)]) for _ in range(REPS))
    two = min(run_threads(spin, [(n // 2,), (n // 2,)]) for _ in range(REPS))
    return {"work": n, "one_thread_s": one, "two_threads_s": two,
            "speedup": one / two}

def io_bound(sleep=0.05, k=8):
    """sleep отпускает GIL — потоки складываются."""
    def nap(): time.sleep(sleep)
    seq = min(sum(timed(nap) for _ in range(k)) for _ in range(3))
    par = min(run_threads(lambda: nap(), [() for _ in range(k)]) for _ in range(3))
    return {"calls": k, "sleep_each_s": sleep,
            "sequential_s": seq, "threads_s": par, "speedup": seq / par}

def c_extension(size=6_000_000):
    """zlib.compress отпускает GIL внутри C-кода (Py_BEGIN_ALLOW_THREADS)."""
    data = bytes(range(256)) * (size // 256)
    def work(): zlib.compress(data, 6)
    one = min(run_threads(lambda: work(), [()]) for _ in range(3))
    two = min(run_threads(lambda: work(), [(), ()]) for _ in range(3))
    return {"bytes": len(data), "one_thread_s": one, "two_threads_s": two,
            "speedup_vs_double_work": (2 * one) / two}

def latency_under_load(interval, duration=1.5):
    """Классический вопрос: как долго ждёт «быстрый» поток, пока «медленный» считает.

    Оба потока — python-байткод. Замеряется, насколько поздно просыпается
    поток, который хотел получить управление немедленно.
    """
    old = sys.getswitchinterval()
    sys.setswitchinterval(interval)
    stop = threading.Event()
    lags = []

    def hog():
        while not stop.is_set():
            spin(200_000)

    def probe():
        while not stop.is_set():
            t0 = time.perf_counter()
            time.sleep(0)          # отдать GIL и попросить его обратно
            lags.append((time.perf_counter() - t0) * 1000)

    h = threading.Thread(target=hog); p = threading.Thread(target=probe)
    h.start(); p.start()
    time.sleep(duration)
    stop.set(); h.join(); p.join()
    sys.setswitchinterval(old)
    lags.sort()
    return {"switch_interval_s": interval, "samples": len(lags),
            "median_ms": statistics.median(lags),
            "p95_ms": lags[int(len(lags) * 0.95)],
            "max_ms": lags[-1]}

def ownership_timeline(duration=0.30, nthreads=2):
    """Кто владел интерпретатором в каждый момент.

    Каждый поток крутит счётчик и раз в N итераций записывает
    (id потока, время). Между двумя соседними отметками одного потока
    интерпретатор принадлежал ему.
    """
    stop = threading.Event()
    marks = []
    lock = threading.Lock()

    def worker(tid):
        local = []
        x = 0
        while not stop.is_set():
            for _ in range(2000):
                x += 1
            local.append((tid, time.perf_counter()))
        with lock:
            marks.extend(local)

    ts = [threading.Thread(target=worker, args=(i,)) for i in range(nthreads)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    time.sleep(duration)
    stop.set()
    for t in ts: t.join()
    marks.sort(key=lambda m: m[1])
    marks = [(tid, round((t - t0) * 1000, 4)) for tid, t in marks]

    # Сколько раз владение переходило от одного потока к другому.
    switches = sum(1 for a, b in zip(marks, marks[1:]) if a[0] != b[0])
    return {"duration_ms": round((time.perf_counter() - t0) * 1000, 2),
            "marks": len(marks), "handoffs": switches,
            "sample": marks[:400]}

out = {"build": build()}
out["cpu_bound"] = cpu_bound()
out["io_bound"] = io_bound()
out["c_extension"] = c_extension()
out["latency"] = [latency_under_load(i) for i in (0.005, 0.05, 0.0005)]
out["timeline"] = ownership_timeline()
print(json.dumps(out, ensure_ascii=False))