Deep Engineering

MEASUREMENT

bench/free-threading-races/lost_update.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/free-threading-races

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: замеры для статьи

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

Script

136 lines
"""Потерянные приращения: `+=` на общем объекте из нескольких потоков.

Четыре потока, каждый прибавляет единицу миллион раз. Итог должен быть
4 000 000; печатается, сколько приращений потеряно в каждом из пяти прогонов.

Три формы одной и той же операции:

  1. `c.n += 1`              — атрибут объекта;
  2. `d["n"] += 1`           — элемент словаря;
  3. `c.n = c.n + one()`     — между чтением и записью стоит вызов функции.

Третья форма нужна, чтобы отделить «гонки нет» от «гонку не видно». В сборке с
GIL поток может отдать блокировку только в точках проверки (`_CHECK_PERIODIC`
в Python/bytecodes.c): на входе в функцию, после вызова, на обратном переходе
цикла. Внутри `c.n += 1` таких точек нет, а внутри формы 3 — есть.

Блок 4 — два исправления и их цена по времени: блокировка вокруг приращения
и счётчик у каждого потока свой, сумма в конце.
"""
import threading
import time

from common import RUNS, THREADS, env_line, fmt_int, fmt_list, header, run_threads

N = 1_000_000
EXPECTED = N * THREADS


class Box:
    pass


def one():
    return 1


def attr_plus_equals():
    c = Box()
    c.n = 0

    def work():
        for _ in range(N):
            c.n += 1

    run_threads(work)
    return c.n


def dict_plus_equals():
    d = {"n": 0}

    def work():
        for _ in range(N):
            d["n"] += 1

    run_threads(work)
    return d["n"]


def call_between():
    c = Box()
    c.n = 0

    def work():
        for _ in range(N):
            c.n = c.n + one()

    run_threads(work)
    return c.n


def with_lock():
    c = Box()
    c.n = 0
    lock = threading.Lock()

    def work():
        for _ in range(N):
            with lock:
                c.n += 1

    run_threads(work)
    return c.n


def per_thread():
    totals = [0] * THREADS

    def work(i):
        n = 0
        for _ in range(N):
            n += 1
        totals[i] = n

    run_threads(work, args_for=lambda i: (i,))
    return sum(totals)


def series(fn):
    lost, seconds = [], []
    for _ in range(RUNS):
        t0 = time.perf_counter()
        got = fn()
        seconds.append(time.perf_counter() - t0)
        lost.append(EXPECTED - got)
    return lost, min(seconds)


def main():
    print(env_line())
    print(f"threads {THREADS}, increments per thread {fmt_int(N)}, expected {fmt_int(EXPECTED)}")

    # Заголовки без знаков операций: признак блока прогона в
    # scripts/validate-runs.mjs принимает только буквы, цифры и знаки
    # препинания, и заголовок с «+=» сборка молча не сверяла бы вовсе.
    for n, title, code, fn in [
        (1, "INCREMENTING AN ATTRIBUTE: LOSSES OVER FIVE RUNS", "c.n += 1", attr_plus_equals),
        (2, "INCREMENTING A DICT ITEM", 'd["n"] += 1', dict_plus_equals),
        (3, "A FUNCTION CALL BETWEEN READ AND WRITE", "c.n = c.n + one()", call_between),
    ]:
        header(n, title)
        print(f"operation: {code}")
        lost, best = series(fn)
        print(f"lost: {fmt_list(lost)}")
        print(f"share lost: {min(lost) / EXPECTED:.1%} to {max(lost) / EXPECTED:.1%}")
        print(f"best run time: {best:.3f} s")

    header(4, "FIXES: LOSSES AND COST")
    for label, fn in [("lock around +=", with_lock), ("per-thread counter", per_thread)]:
        lost, best = series(fn)
        print(f"{label:<20} lost {fmt_list(lost)}, best time {best:.3f} s")


if __name__ == "__main__":
    main()