Deep Engineering

MEASUREMENT

bench/gil/shared_vs_owned.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

62 lines
"""Третий заход: общий объект против по-настоящему собственного.

Прошлая версия этого замера была неверной, и это стоит записать: список
создавался как ["строка"] * 16, то есть все 16 элементов ссылались на ОДИН
строковый литерал, общий для обоих потоков через константы кода. «Собственных»
объектов в том тесте не было ни одного.

Здесь объекты для каждого потока собираются внутри потока и из значений,
вычисленных в рантайме, — интернирование литералов до них не дотягивается.
Проверка того, что объекты действительно разные, делается по id().
"""
import json, sys, threading, time

def make_private(seed, n=16):
    # str(number) считается в рантайме, поэтому объекты разные в каждом потоке.
    return [("o" + str(seed * 1000 + i)) * 3 for i in range(n)]

def loop(items, k):
    x = 0
    for i in range(k):
        x += len(items[i & 15])
    return x

def bench(make_items, k, nthreads, reps=5):
    times = []
    ids = []
    for _ in range(reps):
        barrier = threading.Barrier(nthreads)
        res = [0.0] * nthreads
        def run(t):
            items = make_items(t)
            ids.append(id(items[0]))
            barrier.wait()          # старт одновременный, иначе меряется разъезд
            t0 = time.perf_counter()
            loop(items, k)
            res[t] = time.perf_counter() - t0
        ts = [threading.Thread(target=run, args=(i,)) for i in range(nthreads)]
        for t in ts: t.start()
        for t in ts: t.join()
        times.append(max(res))      # столько заняла работа целиком
    return min(times), len(set(ids))

K = 2_000_000
SHARED = make_private(0)            # один набор на всех

out = {"version": sys.version.split()[0],
       "gil": getattr(sys, "_is_gil_enabled", lambda: True)(),
       "iterations_per_thread": K}

one, _ = bench(lambda t: SHARED, K, 1)
two_shared, uniq_shared = bench(lambda t: SHARED, K, 2)
two_priv, uniq_priv = bench(lambda t: make_private(t + 1), K, 2)

out["one_thread_s"] = one
out["two_threads_shared_s"] = two_shared
out["two_threads_private_s"] = two_priv
out["speedup_shared"] = (2 * one) / two_shared
out["speedup_private"] = (2 * one) / two_priv
out["distinct_objects_shared_case"] = uniq_shared   # ожидается 1
out["distinct_objects_private_case"] = uniq_priv    # ожидается 2 на прогон
print(json.dumps(out, ensure_ascii=False))