CPython 3.13 был выпущен две недели назад (п.п. относительно оригинальной публикации) и стал одним из наиболее сфокусированных на производительности релизов за последнее время. Пробежавшись по release notes, я заметил несколько фич, которые могли бы повлиять на производительность:

  • CPython теперь может запускаться в режиме free‑threaded с отключенным глобальным локом интерпретатора (GIL)

  • Был добавлен новый JIT‑компилятор

  • Аллокатор mimalloc теперь доступен в CPython из коробки

В этой статье мы сфокусируемся на free‑threaded режиме и посмотрим, как его использовать и как он может влиять на производительность.

Про JIT и mimalloc мы поговорим в одной из следующих статей.

Free-threaded CPython

Free‑threading — экспериментальная фича в Python 3.13, которая позволяет CPython выполняться без глобального лока интерпретатора (GIL). GIL это мьютекс, который не дает нескольким потокам одновременно исполнять байткод Python. Подобный подход упрощает управление памятью в CPython и делает C API более понятным. Но тем самым он также становится одним из основных препятствий на пути к эффективному использованию современных многоядерных процессоров.

The Multiprocessing Workaround

Классическим подходом к этой проблеме стало использование модуля multiprocessing, который запускает отдельные процессы Python, что само по себе работает, но имеет несколько значительных ограничений:

  1. Оверхед по памяти: Каждый процесс требует запуска отдельного интерпретатора Python и своего адресного пространства. Для приложений, обрабатывающих большое количество данных это может быстро привести к боттлнеку.

  2. Затраты на общение между процессами: Процессы не могут иметь общую память. Данные требуется сериализовать и десереализовать при передаче между процессами, что добавляет оверхед и усложняет реализацию.

  3. Время запуска: Создание новых процессов значительно дольше создания новых тредов, что не очень практично для задач, требующих частого создания воркеров.

Реальное влияние: на примере PageRank

Чтобы увидеть эти ограничения на практике, давайте попробуем реализовать алгоритм PageRank, который использовался Google для ранних версий их поискового движка. Это идеальный пример, поскольку:

  1. Требователен к производительности (операции с матрицами)

  2. Работает с большими датасетами (web graph)

  3. Может сильно выиграть от многопоточности

Наивная многопоточная реализация на Python 3.12 или ниже упрется в GIL на этапе работы с матрицами, в то время как реализация на multiprocessing пострадает от:

  • оверхеда памяти при копировании графа в каждый процесс

  • затрат на передачу частичных результатов между процессами

  • сложностей управления состоянием

Перед тем, как мы начнем, важно сказать, что мы не будем фокусироваться на специфике самого алгоритма PageRank — нас в первую очередь интересует параллелизация — так что мы не будем вдаваться в детали самого алгоритма.

Давайте взглянем, как реализовать его в разных моделях многопоточности.

Реализация по учебнику (однопоточная)

def pagerank_single(matrix: np.ndarray, num_iterations: int) -> np.ndarray:
    """Single-threaded PageRank implementation"""
    size = matrix.shape[0]
    # Initialize scores
    scores = np.ones(size) / size

    for _ in range(num_iterations):
        new_scores = np.zeros(size)
        for i in range(size):
            # Get nodes that point to current node
            incoming = np.where(matrix[:, i])[0]
            for j in incoming:
                # Add score contribution from incoming node
                new_scores[i] += scores[j] / np.sum(matrix[j]) 
        # Apply damping factor
        scores = (1 - DAMPING) / size + DAMPING * new_scores 

    return scores

Строки под последними двумя комментариями содержат наиболее требовательную к производительности часть алгоритма. Первая рассчитывает вклад входящих узлов в счет, в то время как вторая применяет коэффициент демпфирования и включает новый счет в итоговый результат.

Параллелизация первой окажет наибольшее влияние на производительность, а также легче в реализации, поскольку мы можем разделить диапазон и использовать несколько потоков для вычисления массива new_scores.

Многопоточная реализация

При использовании многопоточности мы начнем с разделения матрицы на несколько частей:

chunk_size = size // num_threads
chunks = [(i, min(i + chunk_size, size)) for i in range(0, size, chunk_size)]

Каждый поток будет обрабатывать свою часть, обновляя новый счет:

def _thread_worker(
    matrix: np.ndarray,
    scores: np.ndarray,
    new_scores: np.ndarray,
    start_idx: int,
    end_idx: int,
    lock: threading.Lock,
):
    size = matrix.shape[0]
    local_scores = np.zeros(size)

    for i in range(start_idx, end_idx):
        incoming = np.where(matrix[:, i])[0]
        for j in incoming:
            local_scores[i] += scores[j] / np.sum(matrix[j])

    with lock: 
        new_scores += local_scores 

Обратите внимание, что для избежания состояний гонки обновление массива new_scores происходит с использованием лока. Это может стать боттлнеком, если держать лок слишком долго, но на практике параллелизация первой части алгоритма уже даст достаточный прирост.

И наконец, мы скармливаем части каждому из потоков:

new_scores = np.zeros(size)
lock = threading.Lock() 
with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor: 
    # Process chunks in parallel
    futures = executor.map( 
        lambda args: _thread_worker(*args), # starmap isn't available on ThreadPoolExecutor
        [ 
            (matrix, scores, new_scores, start_idx, end_idx, lock) 
            for start_idx, end_idx in chunks 
        ], 
    ) 
new_scores = (1 - DAMPING) / size + DAMPING * new_scores
scores = new_scores

Реализация с помощью модуля multiprocessing

По сути, реализация с использованием multiprocessing очень похожа на threading, так что сфокусируемся на отличиях:

  • Каждый воркер будет возвращать массив local_scores вместо обновления массива new_scores, так как процессы не могут напрямую передавать данные из памяти друг другу. Данные из local_scores будут собираться в new_scores в основном процессе:

# Combine results
new_scores = sum(chunk_results)

Несмотря на то, что данный подход должен работать быстрее потоков, он все еще вызывает оверхед от коммуникации между процессами, что может оказывать сильное влияние при работе с большими датасетами.

  • Вместо ThreadPoolExecutor мы будем использовать multiprocessing.Pool. API у них очень похожи, но multiprocessing.Pool создает пул процессов вместо потоков:

with multiprocessing.Pool(processes=num_processes) as pool: 
    # Process chunks in parallel
    chunk_results = pool.starmap(_process_chunk, chunks) 
    # Combine results
    new_scores = sum(chunk_results)
    new_scores = (1 - DAMPING) / size + DAMPING * new_scores
    scores = new_scores

Измерение производительности

Для измерения изменений производительности нам потребуется соответствующий тест. В первую очередь нам нужно сгенерировать данные для теста:

def create_test_graph(size: int) -> np.ndarray:
    # Fixed seed
    np.random.seed(0) 
    # Create random adjacency matrix with ~5 outgoing edges per node
    matrix = np.random.choice([0, 1], size=(size, size), p=[1 - 5/size, 5/size])
    # Find nodes with no outgoing edges
    zero_outdegree = ~matrix.any(axis=1)
    zero_indices = np.where(zero_outdegree)[0]
    # For each node with no outgoing edges, add a random edge
    if len(zero_indices) > 0:
        random_targets = np.random.randint(0, size, size=len(zero_indices))
        matrix[zero_indices, random_targets] = 1

    return matrix

Для обеспечения воспроизводимости результатов при нескольких запусках мы используем фиксированный seed. Это важно при сравнении производительности разных реализаций. В тесте мы создаем выдуманные соединения между страницами для создания реалистичного графа, но сами математические операции не отличались бы от пустой матрицы, до тех пор пока размер не отличается.

Затем мы используем pytest-codspeed, плагин для pytest, чтобы измерить производительность разных реализаций с различными параметрами и сборками/версиями CPython.

В первую очередь определим кейсы бенчмарка:

@pytest.mark.parametrize(
    "pagerank",
    [
        pagerank_single,
        partial(pagerank_multiprocess, num_processes=8),
        partial(pagerank_multithread, num_threads=8),
    ],
    ids=["single", "8-processes", "8-threads"],
)
@pytest.mark.parametrize(
    "graph",
    [
        create_test_graph(100),
        create_test_graph(1000),
        create_test_graph(2000),
    ],
    ids=["XS", "L", "XL"],
)
def test_pagerank(
    benchmark: BenchmarkFixture,
    pagerank: PagerankFunc,
    graph: np.ndarray,
):
    benchmark(pagerank, graph, num_iterations=10)

В нем мы тестируем 3 реализации с тремя разными размерами графа. benchmark предоставляется pytest-codspeed и измеряет время выполнения функции pagerank с предоставленными аргументами.

Затем создадим воркфлоу GitHub Actions для измерения производительности с разными билдами CPython на инфраструктуре CodSpeed:

on:
  push:
jobs:
  codspeed:
    runs-on: codspeed-macro # requests a CodSpeed Macro runner for the jobs
    strategy:
      matrix:
        python-version: ["3.12", "3.13"]
        include:
          - { python-version: "3.13t", gil: "1" } 
          - { python-version: "3.13t", gil: "0" } 
    env:
      UV_PYTHON: ${{ matrix.python-version }}
    steps:
      - uses: actions/checkout@v4
      - name: Install uv
        uses: astral-sh/setup-uv@v3
      - name: Install CPython & dependencies
        run: uv sync --all-extras
      - name: Run benchmarks
        uses: CodSpeedHQ/action@v3
        env:
          PYTHON_GIL: ${{ matrix.gil }}
        with:
          run: uv run pytest --codspeed --codspeed-max-time 10 -vs src/tests.py

В нем мы выполняем бенчмарк на трех версиях Python: 3.12, 3.13 и 3.13 с поддержкой free threading (3.13t), как с GIL, так и без. Запуск 3.13 с поддержкой free threading и без нее позволит оценить ее влияние на производительность даже когда GIL включен.

Используемые сборки python загружаются uv напрямую с python-build-standalone и собраны с использованием GCC 6.3.0 20170516.

Запуск будет происходить на раннерах CodSpeed Macro - ARM64 bare-metal инстансы с 16 ядрами и 32 ГБ ОЗУ на каждый запуск.

Результаты

В оригинальной статье графики интерактивные.
В оригинальной статье графики интерактивные.
  • Без включения новых опций сборки версии 3.12 и 3.13 показывают себя почти одинаково. Видно, что реализация на multithreading значительно медленнее даже однопоточной реализации из‑за оверхеда на коммуникацию между процессами.

  • Как и ожидалось, реализация на threading работает быстрее всего на 3.13t без GIL, поскольку GIL больше не ограничивает параллельное выполнение потоков.

  • Несмотря на это, при запуске free threaded сборки как с GIL, так и без, наблюдается значительное замедление других реализаций. Это в основном вызвано тем, что free‑threaded сборке требуется отключить специализованный адаптивный интерпретатор, явно снижая производительность других реализаций. Этот оверхед должен стать ниже в версии 3.14, в которой этот интерпретатор должен стать потокобезопасным и может быть снова включен. После чего можно будет без раздумий мигрировать на free‑threaded сборку для большей части многопоточных приложений — будет интересно замерить производительность, когда это наконец наступит.

Что касается других размеров графов, результаты крайне схожи с показанными выше и выводы не отличаются — на основе данных замеров можно сказать, что использование free‑threaded сборки CPython 3.13 может значительно влиять на производительность многопоточных приложений и дает неплохую альтернативу multiprocessing. Но стоит учитывать, что данная фича все еще экспериментальная и не совсем готова к использованию на проде из‑за замедления работы Python в целом, но в целом это явный шаг в правильном направлении!

Примечание

Данный бенчмарк не включает в себя субинтерпретаторы — еще один способ параллелизации Python‑кода, добавленный в версии 3.12. Они показывают себя медленнее остальных способов в большинстве ситуаций, поскольку задачи передачи данных и общения между процессами еще не были полностью решены. Когда работа над этим завершится, это может стать хорошей альтернативой multiprocessing.

Полный код данного теста доступен в репозитории.

Комментарии (2)


  1. anger32
    11.11.2024 18:40

    Процессы не могут иметь общую память

    Процессы могут иметь общую память: shm_open() / POSIX. Никакой сериализации для этого не требуется, разве что синхронизация на уровне IPC. Если же разработчики py-модуля в это не смогли - это исключительно их решения и дизайн.


    1. NeX
      11.11.2024 18:40