И Снова О Gil В Python



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

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

Эти электрические процессы создают электрическое поле, которое довольно легко измерить.

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

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

За время работы в этой области я приобрел некоторый опыт использования различных вычислительных технологий.

На некоторые вопросы, которые могут быть интересны не только мне, я постараюсь ответить в рамках данной публикации.



Коротко о научном Python

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

Если отбросить ряд откровенно маргинальных технологий, то я курсировал между C++ и MATLAB. Так продолжалось до тех пор, пока я не открыл для себя Scientific Python [1].

Scientific Python — это набор библиотек языка Python для научных вычислений и научной визуализации.

В своей работе я использую следующие пакеты, которые покрывают примерно 90% моих потребностей:

Имя Описание
NumPy Одна из базовых библиотек позволяет работать с многомерными массивами как с отдельными объектами в стиле MATLAB. Включает реализацию основных процедур линейной алгебры, преобразования Фурье, работу со случайными числами и т. д.
SciPy Расширение NumPy включает в себя реализацию методов оптимизации, работу с разреженными матрицами, статистикой и т.д.
Панды Отдельный пакет для многомерного анализа данных и статистики.

СимПи Пакет символьной математики.

Матплотлиб 2D графика.

Маяви2 3D графика на базе ВТК.

Спайдер Удобная IDE для интерактивной разработки математических алгоритмов.

В Scientific Python я нашел отличный баланс между удобной абстракцией высокого уровня для быстрой разработки числовых алгоритмов и современным, зрелым языком.

Но, как известно, идеальных инструментов не бывает. И одна из довольно острых проблем в Python — проблема параллельных вычислений.



Проблемы параллельных вычислений в Python.

Под параллельными вычислениями в этой статье я буду понимать SMP — симметричную многопроцессорную обработку с общей памятью.

Я не буду касаться вопросов использования CUDA и систем с раздельной памятью (чаще всего используется стандарт MPI).

Проблема в GIL. GIL (Global Interpreter Lock) — это блокировка (мьютекс), которая не позволяет нескольким потокам выполнять один и тот же байт-код. К сожалению, эта блокировка необходима, поскольку система управления памятью CPython не является потокобезопасной.

Да, GIL — это не проблема языка Python, а проблема реализации интерпретатора CPython. Но, к сожалению, другие реализации Python не очень подходят для создания быстрых числовых алгоритмов.

К счастью, сейчас существует несколько способов решения проблем GIL. Давайте посмотрим на них.



Тестовое задание

Два набора Н векторы: Р={р 1 ,п 2 ,…,п Н } И Q={q 1 2 ,…,к Н } в трехмерном евклидовом пространстве.

Необходимо построить матрицу р измерение Н х Н , каждый элемент р я, Джей который рассчитывается по формуле:

И снова о GIL в Python

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

Эта матрица довольно часто используется в реальных расчетах, например, при RBF-интерполяции или решении дифуров в PE методом интегральных уравнений.

В тестовых экспериментах количество векторов Н = 5000. Для вычислений использовался процессор с 4 ядрами.

Результаты основаны на среднем времени 10 пробежек.

Полную реализацию тестовых задач можно найти на GitHub [2].

Правильная точка в комментариях от "@chersaya".

Эта тестовая задача используется здесь в качестве примера.

Если вам действительно нужно вычислить попарные расстояния, лучше использовать функцию scipy.spatial.distance.cdist.



Параллельная реализация на C++

Чтобы сравнить эффективность параллельных вычислений на Python, я реализовал эту задачу на C++.

Основной код функции выглядит следующим образом: Однопроцессорная реализация:

  
  
  
  
  
  
  
  
  
   

//! Single thread matrix R calculation void spGetR(vector<Vector3D> & p, vector<Vector3D> & q, MatrixMN & R) { for (int i = 0; i < p.size(); i++) { Vector3D & a = p[i]; for (int j = 0; j < q.size(); j++) { Vector3D & b = q[j]; Vector3D r = b - a; R(i, j) = 1 / (1 + sqrt(r * r)); } } }

Многопроцессорная реализация:

//! OpenMP matrix R calculations void mpGetR(vector<Vector3D> & p, vector<Vector3D> & q, MatrixMN & R) { #pragma omp parallel for for (int i = 0; i < p.size(); i++) { Vector3D & a = p[i]; for (int j = 0; j < q.size(); j++) { Vector3D & b = q[j]; Vector3D r = b - a; R(i, j) = 1 / (1 + sqrt(r * r)); } } }

Что здесь интересного? Ну, во-первых, я использовал отдельный класс Vector3D для представления вектора в трёхмерном пространстве.

Перегруженный оператор «*» в этом классе имеет значение скалярного произведения.

Для представления набора векторов я использовал std::vector. Технология OpenMP использовалась для параллельных вычислений.

Для распараллеливания алгоритма достаточно использовать директиву #pragma omp Parallel for. Полученные результаты:

Однопроцессорный C++ 224 мс
Мультипроцессор С++ 65 мс
Ускорение в 3,45 раза при параллельных вычислениях я считаю вполне неплохим для четырехъядерного процессора.



Параллельные реализации в Python



1.Наивная реализация на чистом Python.
В этом тесте я хотел проверить, как долго будет решаться задача на чистом Python без использования каких-либо специальных пакетов.

Код решения:

def sppyGetR(p, q): R = np.empty((p.shape[0], q.shape[1])) nP = p.shape[0] nQ = q.shape[1] for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R

Здесь п , д — входные данные в формате NumPy с массивами измерений (Н, 3) И (3, Н) .

А дальше идет честный цикл на Python, вычисляющий элементы матрицы R. Полученные результаты:

Однопроцессорный Python 57386 мс
Да-да, именно 57 тысяч миллисекунд. Где-то в 256 раз медленнее, чем однопроцессорный C++.

В общем, это совсем не вариант численных расчетов.



2 Однопроцессорный NumPy
В общем, для вычислений Python с использованием NumPy иногда вообще не приходится думать о параллелизме.

Так, например, процедура умножения двух матриц в NumPy все равно будет выполняться с использованием низкоуровневых высокопроизводительных библиотек линейной алгебры на C++ (MKL или ATLAS).

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

Код решения следующий:

def spnpGetR(p, q): Rx = p[:, 0:1] - q[0:1] Ry = p[:, 1:2] - q[1:2] Rz = p[:, 2:3] - q[2:3] R = 1 / (1 + np.sqrt(Rx * Rx + Ry * Ry + Rz * Rz)) return R

Всего 4 строчки и никаких петель! Вот почему я люблю NumPy. Полученные результаты:

Однопроцессорный NumPy 973 мс
Примерно в 4,3 раза медленнее, чем однопроцессорный C++.

Это уже достаточно хороший результат. Для подавляющего большинства вычислений такой производительности вполне достаточно.

Но пока это все результаты одного процессора.

Перейдем к многопроцессорности.



3 Мультипроцессор NumPy
Традиционное решение проблем GIL — использовать несколько независимых процессов выполнения вместо нескольких потоков выполнения.

Все бы ничего, но есть проблема.

Каждый процесс имеет независимую память, и нам нужно передать матрицу результатов каждому процессу.

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

Я точно не знаю, что стоит за RawArray. Мне кажется, что это файлы, отображаемые в памяти.

Код решения следующий:

def mpnpGetR_worker(job): start, stop = job p = np.reshape(np.frombuffer(mp_share.p), (-1, 3)) q = np.reshape(np.frombuffer(mp_share.q), (3, -1)) R = np.reshape(np.frombuffer(mp_share.R), (p.shape[0], q.shape[1])) Rx = p[start:stop, 0:1] - q[0:1] Ry = p[start:stop, 1:2] - q[1:2] Rz = p[start:stop, 2:3] - q[2:3] R[start:stop, :] = 1 / (1 + np.sqrt(Rx * Rx + Ry * Ry + Rz * Rz)) def mpnpGetR(p, q): nP, nQ = p.shape[0], q.shape[1] sh_p = mp.RawArray(ctypes.c_double, p.ravel()) sh_q = mp.RawArray(ctypes.c_double, q.ravel()) sh_R = mp.RawArray(ctypes.c_double, nP * nQ) nCPU = 4 jobs = utils.generateJobs(nP, nCPU) pool = mp.Pool(processes=nCPU, initializer=mp_init, initargs=(sh_p, sh_q, sh_R)) pool.map(mpnpGetR_worker, jobs, chunksize=1) R = np.reshape(np.frombuffer(sh_R), (nP, nQ)) return R

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

Полученные результаты:

Многопроцессорный NumPy 795 мс
Да, он быстрее однопроцессорной версии, но всего в 1,22 раза.

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

Хотя для других задач этот вариант может оказаться вполне эффективным.

На этом заканчиваются известные мне решения для параллельного программирования с использованием только Python. Далее, как бы нам ни хотелось, чтобы освободиться от GIL, нам придется спуститься на уровень C++.

Но это не так страшно, как кажется.



4 Китон
Cython [3] — это расширение языка Python, которое позволяет встраивать инструкции C в код Python. Таким образом, мы можем взять код Python и добавить несколько инструкций, чтобы значительно ускорить работу узких мест в производительности.

Модули Cython преобразуются в код C, а затем компилируются в модули Python. Код решения нашей задачи на Cython следующий: Однопроцессорный Cython:

@cython.boundscheck(False) @cython.wraparound(False) def spcyGetR(pp, pq): pR = np.empty((pp.shape[0], pq.shape[1])) cdef int i, j, k cdef int nP = pp.shape[0] cdef int nQ = pq.shape[1] cdef double[:, :] p = pp cdef double[:, :] q = pq cdef double[:, :] R = pR cdef double rx, ry, rz with nogil: for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R

Многопроцессорный Cython:

@cython.boundscheck(False) @cython.wraparound(False) def mpcyGetR(pp, pq): pR = np.empty((pp.shape[0], pq.shape[1])) cdef int i, j, k cdef int nP = pp.shape[0] cdef int nQ = pq.shape[1] cdef double[:, :] p = pp cdef double[:, :] q = pq cdef double[:, :] R = pR cdef double rx, ry, rz with nogil, parallel(): for i in prange(nP, schedule='guided'): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R

Если сравнить этот код с реализацией на чистом Python, то все, что нам нужно было сделать, — это просто указать типы используемых переменных.

GIL выпускается в одну строку.

Параллельный цикл организуется просто с помощью инструкции prange вместо xrange. На мой взгляд, это довольно просто и красиво! Полученные результаты:

Однопроцессорный Cython 255 мс
Мультипроцессор Cython 75 мс
Ух ты! Время выполнения почти такое же, как и время выполнения в C++.

Отставание примерно в 1,1 раза как в однопроцессорном, так и в многопроцессорном варианте практически незаметно в реальных задачах.



5 Нумба
Numba [4] — довольно новая библиотека, находящаяся в стадии активной разработки.

Идея здесь примерно такая же, как и в Cython — попытка спуститься на уровень C++ в коде Python. Но идея реализована гораздо элегантнее.

Numba основана на компиляторах LLVM, которые позволяют компилировать непосредственно во время выполнения программы (JIT-компиляция).

Например, чтобы скомпилировать любую процедуру на Python, вам просто нужно добавить аннотацию «jit».

Более того, аннотации позволяют указывать типы входных/выходных данных, что делает JIT-компиляцию значительно более эффективной.

Код реализации задачи следующий.

Однопроцессорный номер Numba:

@jit(double[:, :](double[:, :], double[:, :])) def spnbGetR(p, q): nP = p.shape[0] nQ = q.shape[1] R = np.empty((nP, nQ)) for i in xrange(nP): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) return R

Мультипроцессор Нумба:

def makeWorker(): savethread = pythonapi.PyEval_SaveThread savethread.argtypes = [] savethread.restype = c_void_p restorethread = pythonapi.PyEval_RestoreThread restorethread.argtypes = [c_void_p] restorethread.restype = None def worker(p, q, R, job): threadstate = savethread() nQ = q.shape[1] for i in xrange(job[0], job[1]): for j in xrange(nQ): rx = p[i, 0] - q[0, j] ry = p[i, 1] - q[1, j] rz = p[i, 2] - q[2, j] R[i, j] = 1 / (1 + sqrt(rx * rx + ry * ry + rz * rz)) restorethread(threadstate) signature = void(double[:, :], double[:, :], double[:, :], int64[:]) worker_ext = jit(signature, nopython=True)(worker) return worker_ext def mpnbGetR(p, q): nP, nQ = p.shape[0], q.shape[1] R = np.empty((nP, nQ)) nCPU = utils.getCPUCount() jobs = utils.generateJobs(nP, nCPU) worker_ext = makeWorker() threads = [threading.Thread(target=worker_ext, args=(p, q, R, job)) for job in jobs] for thread in threads: thread.start() for thread in threads: thread.join() return R

По сравнению с чистым Python, к однопроцессорному решению на Numba добавляется только одна аннотация! Мультипроцессорный вариант, к сожалению, не так красив.

Для этого требуется организовать пул потоков и вручную отправить GIL. В предыдущих выпусках Numba была попытка реализовать параллельный цикл с помощью одной инструкции, но из-за проблем со стабильностью эта возможность была удалена в последующих выпусках.

Я уверен, что эта особенность будет исправлена со временем.

Результаты выполнения:

Однопроцессорный Numba 359 мс
Мультипроцессор Нумба 180 мс
Чуть хуже, чем у Cython, но результаты все равно очень хорошие! Да и само решение чрезвычайно элегантное.



выводы

Я хочу проиллюстрировать результаты следующими диаграммами:

И снова о GIL в Python

Рис.

1. Результаты однопроцессорных расчетов

И снова о GIL в Python

Рис.

2. Результаты многопроцессорных расчетов Мне кажется, что проблемы GIL в Python для численных расчетов практически преодолены.

На данный момент я бы рекомендовал Cython в качестве технологии параллельных вычислений.

Но я бы очень внимательно присмотрелся к Нумбе.



Ссылки

[1] Научный Питон: scipy.org [2] Полные исходные коды тестов: github.com/alec-kalinin/open-nuance [3] Китон: cython.org [4] Нумба: numba.pydata.org P.S. В комментариях "@chersaya" правильно указала еще один метод параллельных вычислений.

Это использует библиотеку numexpr. Numexpr использует собственную виртуальную машину, написанную на C, и собственный JIT-компилятор.

Это позволяет ему принимать простые математические выражения в виде строки, компилировать и быстро вычислять.

Пример использования:

import numpy as np import numexpr as ne a = np.arange(1e6) b = np.arange(1e6) result = ne.evaluate("sin(a) + arcsinh(a/b)")

Теги: #научный Python #gil #параллельное программирование #python #Параллельное программирование

Вместе с данным постом часто просматривают: