Сравнение библиотек параллелизма в Python: ThreadPoolExecutor, Joblib, Numba, Dask, PySpark и MPI4Py

Введение

Параллелизм в Python остаётся непростой темой. Принято различать I/O‑связанные задачи, где потоки бездействуют, ожидая внешнего события, и CPU‑связанные задачи, где большинство времени занято вычислениями. В CPython одновременное выполнение нескольких потоков ограничивает Global Interpreter Lock (GIL). Этот механизм предотвращает одновременное исполнение байткода, поэтому потоки не ускоряют CPU‑связанный код; они лишь полезны для перекрытия операций ввода‑вывода[1]. Документация concurrent.futures подтверждает, что ThreadPoolExecutor автоматически выбирает количество рабочих потоков, ориентируясь именно на I/O‑нагрузку[2]. Для достижения настоящего параллелизма следует использовать процессы, нативный код или распределённые вычисления.

Библиотеки

· ThreadPoolExecutor (модуль concurrent.futures) — реализация пула потоков. Хорошо подходит для перекрытия I/O‑операций, но не ускоряет чисто вычислительный код из‑за GIL[1]. По умолчанию использует число рабочих, пропорциональное количеству ядер, чтобы обеспечить несколько свободных потоков для I/O[2].

· Joblib — библиотека для параллельных вычислений, базирующаяся на loky‑процессах. В документации отмечается, что процессы допускают параллельное исполнение Python‑кода, но требуют сериализации данных, что создаёт накладные расходы; если целевая функция освобождает GIL (напр. C‑код), можно переключиться на потоковую схему prefer="threads"[3].

· Numba — JIT‑компилятор, который превращает функции Python с циклами и массивами NumPy в машинный код. С опцией nogil=True компилированные функции отпускают GIL, позволяя реально использовать несколько ядер[4]. Опция parallel=True с nopython=True включает автоматическое распараллеливание операций с известной параллельной семантикой[5].

· Dask — обеспечивает «блокированное» представление DataFrame: большая таблица разбивается на множество маленьких pandas‑таблиц, распределённых по потокам или машинам. Операция над Dask‑DataFrame запускает несколько pandas‑операций с учётом параллелизма и памяти[6], что удобно для таблиц, не помещающихся в RAM[7].

· PySpark — Python‑оболочка над Apache Spark. Предназначена для «real‑time» и распределённой обработки больших данных; поддерживает SQL, DataFrame, потоковую обработку, ML и другие компоненты кластера[8]. Работа с DataFrame позволяет применять SQL‑запросы или Python‑выражения и использовать полноценный движок Spark[9].

· MPI4Py — интерфейс MPI для Python. Даёт доступ к точечно‑точечным и коллективным коммуникациям между процессами и позволяет использовать несколько процессоров рабочего узла или кластера[10]. Поддерживает передачу любых сериализуемых объектов Python и эффективный обмен массивами NumPy.

В этой статье сравниваются перечисленные библиотеки на четырёх классах задач: тяжёлая арифметика (CPU‑bound), искусственная задержка (I/O‑bound), DataFrame‑трансформации и тест накладных расходов (много коротких задач). Тесты запускались на данных различных размеров (n_rows = 50 000, 100 000, 250 000 и 500 000) и с 1 или 2 рабочими. В итоговой таблице df_calc из файла rez.xlsx хранится медианное время работы, ускорение и эффективность по сравнению с одним рабочим. Ниже приведено обсуждение каждого теста.

CPU‑bound: тяжёлая арифметическая функция

Первый тест оценивает производительность вычислительных библиотек на функции, выполняющей большое число итераций. В скрипте использовалась JIT‑счётная функция, предполагающая интенсивные арифметические операции. Результаты представлены на рис. 1 для набора 500 000 строк (более мелкие наборы показывают аналогичную картину). Время (ось Y) измеряется в секундах, горизонтальная ось — количество рабочих.

Сравнение библиотек параллелизма в Python: ThreadPoolExecutor, Joblib, Numba, Dask, PySpark и MPI4Py

Наблюдения:

· Numba превосходит конкурентов — медианное время меньше 2 с, и увеличение числа рабочих почти не меняет результат. JIT‑компиляция превращает Python‑функцию в машинный код и отпускает GIL (nogil=True), что позволяет максимально задействовать ядра CPU[4].

· MPI4Py и Joblib показывают схожий уровень: около 40–45 с. Эти библиотеки используют процессы и поэтому способны распараллеливать CPU‑связанный код; однако накладные расходы (сериализация и обмен данными) увеличивают время. Средняя скорость улучшения ограничена — эффективность составляет ~0,74 (из таблицы df_calc).

· Dask и PySpark сильно уступают Numba и процессным решениям. Даже с двумя рабочими время медленно уменьшается: ~50 с для Dask и ~47 с для PySpark при 500 000 строках. Причина — необходимость распределять вычисления и собирать результат; при малом количестве узлов сетевой обмен не окупается.

· ThreadPoolExecutor — самый медленный участник: примерно 61 с, причём время почти не меняется с двумя потоками. Это подтверждает ограничения GIL: потоки не ускоряют CPU‑связанный код; документация подчёркивает, что пул потоков «предназначен для I/O‑задач»[2]. Поэтому для интенсивных вычислений лучше использовать процессы или JIT‑компиляторы.

I/O‑bound: имитация ожидания

Второй тест моделирует ввод‑вывод с помощью функции, вызывающей sleep (0,1 с). Здесь задача проста: перекрыть время ожидания, назначая другим потокам работу. На рис. 2 показаны результаты для 50 000 строк — увеличение набора практически не меняет тенденцию.

Сравнение библиотек параллелизма в Python: ThreadPoolExecutor, Joblib, Numba, Dask, PySpark и MPI4Py

Наблюдения:

· ThreadPoolExecutor и Joblib обеспечивают значительное ускорение: переход от одного к двум рабочим снижает медианное время примерно с 12,5 с до 7,1 с (joblib) и с 13,0 с до 7,2 с (threadpool). Это согласуется с теорией: когда поток блокируется на I/O, он освобождает GIL и другие потоки могут выполняться[1].

· Dask и PySpark также ускоряются с ростом числа рабочих, но остаются медленнее. При 50 000 строк PySpark тратит 14,5 с с одним рабочим и 9 с с двумя. Эти фреймворки запускают задачи через распределённый планировщик, что выгодно лишь при значительном числе узлов.

· Numba и MPI4Py показывают настолько малое время (<0,1 с), что график их практически не виден. В данном тесте они выполняют минимальные операции кроме sleep, поэтому накладные расходы процессов или JIT‑компиляции не проявляются. Однако стоит помнить, что JIT‑компилированные функции могут эффективно использовать время ожидания благодаря освобождению GIL[4].

Таким образом, для I/O‑связанных задач оптимально использовать многопоточность (ThreadPoolExecutor) или процессные библиотеки, если требуется сериализация. Использование тяжёлых распределённых фреймворков оправдано лишь при очень больших объёмах данных.

DataFrame‑BigData: преобразования таблиц

Третий тест исследует обработку больших таблиц DataFrame: groupby, join, сортировку и т. д. Используется искусственно созданная таблица с числом строк до 500 тысяч. На рис. 3 приведено время для максимального размера.

Сравнение библиотек параллелизма в Python: ThreadPoolExecutor, Joblib, Numba, Dask, PySpark и MPI4Py

Наблюдения:

· Dask и PySpark — единственные участники, у которых время существенно выше нуля. Dask выполняет работу за ~4,6 с, PySpark — ~15,9 с. Это ожидаемо: обе библиотеки используют распределённый DataFrame, разбивая данные на множество мелких pandas‑фрагментов или RDD и управляя их вычислением[6][8]. Накладные расходы увеличиваются с ростом числа строк.

· ThreadPoolExecutor, Joblib, Numba и MPI4Py практически не отображаются на графике: их время близко к нулю. Это связано с тем, что в тесте каждое задание — это один вызов функции DataFrame, а не распределённая обработка большого DataFrame. Эти библиотеки реализуют обычные Python‑циклы или процесcы, поэтому для операций groupby и join нет преимуществ; работой занимается сам pandas. Для действительно больших таблиц рекомендуется Dask или Spark, поскольку они масштабируют вычисления и экономно расходуют память[7].

Таким образом, выбор библиотеки зависит от объёма данных: для небольших таблиц pandas‑вызовы быстрее без дополнительных накладных расходов, а для огромных таблиц нужна распределённая обработка Dask или PySpark.

Много мелких задач: накладные расходы

Последний тест измеряет накладные расходы запуска тысяч очень коротких задач. Каждой задаче отводится минимальный фрагмент работы, и основную часть времени занимают создание и переключение задач. На рис. 4 представлены результаты для 500 тысяч задач.

Сравнение библиотек параллелизма в Python: ThreadPoolExecutor, Joblib, Numba, Dask, PySpark и MPI4Py

Наблюдения:

· Numba вновь оказывается безусловным лидером: время около 0,1 с и почти не зависит от числа рабочих. Объяснение простое: функции компилируются в машинный код, и цикл выполняется в одном потоке без дополнительных переключений, поэтому накладные расходы минимальны[11].

· Joblib демонстрирует умеренный рост времени с 0,3 с до 1,1 с, когда число рабочих удваивается. Накладные расходы на запуск процессов и передачу данных ощутимы, но остаются ниже, чем у Dask.

· ThreadPoolExecutor и MPI4Py относительно стабильны: около 3,7–3,8 с. Потоковая реализация создаёт контекст для каждой задачи, а MPI‑процессы требуют коммутации; при увеличении числа рабочих наблюдается лишь незначительное улучшение.

· Dask и PySpark показывают наихудший результат. Dask нуждается в планировании каждой из 500 000 задач, что приводит к заметному росту времени (от 1,8 с до 2,3 с), а PySpark — к 2,5–2,7 с. Накладные расходы распределённого планировщика перекрывают сами вычисления, поэтому эти библиотеки не подходят для большого числа коротких задач.

Обсуждение и рекомендации

Результаты тестов демонстрируют, что универсального «лучшего» решения не существует; выбор библиотеки зависит от характера задачи:

· I/O‑bound. При необходимости перекрыть операции ввода‑вывода достаточно использовать пул потоков (ThreadPoolExecutor) или joblib с предпочтением threads. Потоки освобождают GIL при блокирующих операциях, позволяя выполнять другие задачи параллельно[1].

· CPU‑bound. Для интенсивных вычислений лучше использовать JIT‑компилятор Numba с опцией nogil=True или parallel=True, позволяющей задействовать несколько ядер[12]. Процесс‑ориентированные библиотеки joblib и mpi4py тоже дают прирост, но накладные расходы делают их медленнее. ThreadPoolExecutor совершенно не подходит из‑за GIL[2].

· Обработка больших таблиц. Когда таблицы больше объёма оперативной памяти, pandas перестаёт справляться. В таких случаях следует предпочесть Dask DataFrame или PySpark, которые разбивают данные на части и распределяют вычисления[6][8]. Для небольших таблиц классические DataFrame‑операции быстрее, и использование тяжёлых распределённых фреймворков неоправданно.

· Много мелких задач. При большом числе коротких задач значимы накладные расходы запуска задач и планирования. Numba позволяет избавиться от них, скомпилировав код в нативный и выполнив его в цикле. Joblib и ThreadPoolExecutor справляются лучше, чем распределённые решения, но всё равно проигрывают JIT‑компилятору.

Заключение

Эксперимент показал, что выбор инструмента для параллельного программирования в Python должен быть осознанным. Потоки полезны для I/O‑задач, но бессильны для чисто вычислительных операций; процессы дают реальный параллелизм, но платят ценой сериализации; JIT‑компиляция Numba и освобождение GIL позволяют добиться наилучшей производительности на CPU‑связанных задачах. Dask и PySpark предназначены для ситуаций, когда данные не помещаются в память или требуется распределённый кластер, но они неэффективны для небольших задач. MPI4Py предоставляет гибкий низкоуровневый интерфейс для распараллеливания, но требует большего труда программиста.

Таким образом, оптимальная библиотека зависит от типа нагрузки: рассматривая характеристики задачи (CPU против I/O, объём данных, продолжительность подзадачи), можно подобрать соответствующий инструмент и получить максимальную производительность.

[1] Bypassing the GIL for Parallel Processing in Python – Real Python

[2] concurrent.futures — Launching parallel tasks — Python 3.14.2 documentation

[3] Embarrassingly parallel for loops — joblib 1.6.dev0 documentation

[4] [5] [12] Compiling Python code with @jit — Numba 0.52.0.dev0+274.g626b40e-py3.7-linux-x86_64.egg documentation

[6] [7] Dask DataFrame - parallelized pandas — Dask Tutorial documentation

[8] [9] PySpark Overview — PySpark 4.1.0 documentation

[10] MPI for Python — MPI for Python 4.1.1 documentation

[11] A ~5 minute guide to Numba — Numba 0.52.0.dev0+274.g626b40e-py3.7-linux-x86_64.egg documentation

Начать дискуссию