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, что само по себе работает, но имеет несколько значительных ограничений:
Оверхед по памяти: Каждый процесс требует запуска отдельного интерпретатора Python и своего адресного пространства. Для приложений, обрабатывающих большое количество данных это может быстро привести к боттлнеку.
Затраты на общение между процессами: Процессы не могут иметь общую память. Данные требуется сериализовать и десереализовать при передаче между процессами, что добавляет оверхед и усложняет реализацию.
Время запуска: Создание новых процессов значительно дольше создания новых тредов, что не очень практично для задач, требующих частого создания воркеров.
Реальное влияние: на примере PageRank
Чтобы увидеть эти ограничения на практике, давайте попробуем реализовать алгоритм PageRank, который использовался Google для ранних версий их поискового движка. Это идеальный пример, поскольку:
Требователен к производительности (операции с матрицами)
Работает с большими датасетами (web graph)
Может сильно выиграть от многопоточности
Наивная многопоточная реализация на 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.
Полный код данного теста доступен в репозитории.
anger32
Процессы могут иметь общую память: shm_open() / POSIX. Никакой сериализации для этого не требуется, разве что синхронизация на уровне IPC. Если же разработчики py-модуля в это не смогли - это исключительно их решения и дизайн.
NeX
добавили уже давно же https://docs.python.org/3/library/multiprocessing.shared_memory.html