Python не перестаёт удивлять многих своей гибкостью и эффективностью. Лично я являюсь приверженцем С и Fortran, а также серьёзно увлекаюсь C++, поскольку эти языки позволяют добиться высокого быстродействия. Python тоже предлагает такие возможности, но дополнительно выделяется удобством, за что я его и люблю.
Этот инструмент способен обеспечивать хорошее быстродействие, поскольку имеет в арсенале ключевые оптимизированные библиотеки, а также возможность динамической компиляции основного кода, который предварительно не компилировался. Однако скорость Python значительно падает, когда дело доходит до обработки крупных датасетов или более сложных алгоритмов. В текущей статье мы разберём:
- Почему столь важно думать о «будущем разнородных вычислений».
- Две ключевых сложности, которые необходимо преодолеть в открытом решении.
- Параллельное выполнение задач для более эффективного задействования CPU.
- Использование ускорителя для дополнительного повышения быстродействия.
Один только третий пункт позволил увеличить быстродействие в 12 раз притом, что четвёртый позволяет добиться ещё большего за счёт ускорителя. Эти простые техники могут оказаться бесценными при работе с Python, когда требуется добиться дополнительного ускорения программы. Описанные здесь приёмы позволяют нам уверенно продвигаться вперёд без длительного ожидания результатов.
Размышляя о будущем разнородных вычислений
Несмотря на то, что понимание разнородности не особо важно, если нас интересует лишь ускорение кода Python, стоит отметить значительный сдвиг, который сейчас наблюдается в сфере вычислительных наук. Компьютеры с каждым годом становятся все быстрее. Поначалу этот прирост производительности обеспечивали грамотные и более сложные архитектуры. Затем где-то в период между 1989 и 2006 годами основным ускоряющим фактором стала растущая тактовая частота. Однако в 2006 её увеличение вдруг перестало иметь смысл, и для повышения скорости снова потребовалось изменение архитектур.
В результате многоядерные процессоры обеспечили повышенное быстродействие за счёт увеличения количества (однородных) ядер в процессоре. В отличие от повышения частот получение дополнительного ускорения за счёт многоядерности потребовало перестройки ПО. Классическая статья Херба Саттера «The Free Lunch Is Over» акцентировала потребность в многопоточности. И хотя этот сдвиг был необходим, он существенно усложнил жизнь разработчикам программного обеспечения.
Затем появились ускорители, способные выполнять вычисления в дополнение к CPU. Наиболее успешными из них пока что являются графические процессоры (GPU). Изначально GPU были разработаны для разгрузки основного процессора путём снятия с него задачи по обработке графики, передаваемой на дисплей компьютера. Для задействования этой дополнительной вычислительной мощности появилось несколько программных моделей, но вместо отправки на дисплей их результаты передавались программе, выполняющейся на CPU. Сегодня «разнородные» процессоры в одной системе больше не являются равноценными. Тем не менее все популярные языки программирования обычно предполагают одно вычислительное устройство, поэтому, когда мы выбираем часть кода для выполнения на отдельном таком устройстве, то используем термин «разгрузка».
Несколько лет назад два легендарных представителя индустрии, Джон Хеннеси и Дэвид Паттерсон, заявили о вступлении в «A New Golden Age for Computer Architecture» (новый золотой век компьютерной архитектуры). Разнородные вычисления начинают набирать обороты благодаря появлению множества идей для специализированных под разные области процессоров. Некоторые эти тенденции ждёт успех, другие же обречены на провал, но суть в том, что сфера вычислений изменилась бесповоротно, поскольку больше не ориентирована на выполнение всех вычислений на одном устройстве.
Две ключевых сложности, преодолеваемые одним хорошим решением
И хотя сегодня популярна технология CUDA, она относится лишь к GPU Nvidia. Нам же нужны открытые решения для обработки целой волны новых архитектур ускорителей от различных вендоров. Программам, выполняющимся на разнородных платформах, необходим способ определения доступных в среде выполнения устройств. Им также нужен способ снять с этих устройств нагрузку.
CUDA игнорирует возможность обнаружения устройств, предполагая, что доступны только GPU Nvidia. Python-программистам для использования GPU с CUDA (Nvidia) или ROCm (AMD) доступна библиотека CuPy. Но хоть CuPy и является открытым решением, она не повышает быстродействие CPU и не охватывает других производителей или архитектуры. Нам бы больше подошла программа с возможностью использования на устройствах разных вендоров и поддержкой новых аппаратных решений. Однако, прежде чем радоваться разгрузке с помощью ускорителя, давайте убедимся, что получаем максимум от нашего CPU, так как понимание принципов использования параллелизма и скомпилированного кода также поможет разобраться с использованием многопоточности на ускорителях.
Numba является открытым JIT-компилятором кода Python и NumPy, разработанным Anaconda. Он может компилировать всевозможный численный код Python, включая многие функции NumPy. Numba также поддерживает автоматическое распараллеливание циклов, генерацию ускоряемого GPU кода, создание универсальных функций (ufuncs
) и обратных вызовов Си.
В рамках проекта Numba компания Intel разработала автоматический параллелизатор, который включается установкой опции parallel=True
в @numba.jit
. Этот инструмент анализирует распараллеливаемые по данным области кода в скомпилированной функции и планирует их параллельное выполнение. Есть два типа операций, которые Numba может распараллеливать автоматически:
- Неявные распараллеливаемые области, такие как выражения массивов NumPy, NumPy
ufunc
и функции редукции NumPy. - Явные циклы параллельных данных, определяемые с помощью выражения
numba.prange
.
Вот пример простого цикла Python:
def f1(a,b,c,N):
for i in range(N):
c[i] = a[i] + b[i]
Его можно сделать явно параллельным, изменив последовательный диапазон (range
) на параллельный (prange
) и добавив директиву njit
:
@njit(parallel=True)
def add(a,b,c,N):
for i in prange(N):
c[i] = a[i] + b[i]
В итоге время выполнения сократилось с 24.3 до 1.9 секунды, но результаты от системы к системе могут отличаться. Чтобы опробовать этот приём, клонируйте репозиторий с образцами oneAPI (git clone) и откройте блокнот AI-and-Analytics/Jupyter/Numba_DPPY_Essentials_training/Welcome.ipynb. Проще всего сделать это, зарегистрировав бесплатный аккаунт на Intel® DevCloud for oneAPI.
Дополнительное повышение быстродействия с помощью ускорителя
Ускоритель оказывается особенно эффективен, когда приложение загружено достаточным объёмом работы, чтобы её разгрузка оправдала необходимые для этого затраты. Первым шагом мы компилируем выбранные вычисления (фрагмент программы), чтобы их можно было разгрузить на другие устройства. Продолжая предыдущий пример, мы используем расширения Numba (numba-dpex) для обозначения разгружаемого фрагмента программы (подробнее читайте в Jupyter notebook training).
@dppy.kernel
def add(a, b, c):
i = dppy.get_global_id(0)
c[i] = a[i] + b[i]
Фрагмент программы компилируется и распараллеливается так, будто ранее он использовал @njit
для подготовки к выполнению на CPU, только на сей раз он подготавливается к переносу на устройство. Код компилируется в промежуточный язык (SPIR-V), который среда выполнения в установленный для его запуска момент сопоставляет с устройством. Это универсальное решение, которое позволяет переносить вычисления на ускоритель любого вендора.
Аргументами для фрагмента программы могут выступать массивы NumPy или массивы унифицированной разделяемой памяти (USM) (тип массивов, явно помещаемых в унифицированную разделяемую память), в зависимости от того, что больше соответствует нашим потребностям. Этот выбор повлияет на настройку данных и активацию фрагментов программы.
Далее мы задействуем решение C++ для открытого мультивендорного и мультиархитектурного программирования под названием SYCL, используя опенсорсную библиотеку dpctl (подробнее читайте в документации GitHub и «Interfacing SYCL and Python for XPU Programming»). Эта библиотека позволяет программам Python обращаться к устройствам SYCL, очередям и источникам памяти, а также выполнять операции с массивами/тензорами Python. Такой подход позволяет избежать изобретения новых решений, уменьшить потребность в освоении новых техник и повысить совместимость.
Подключение к устройству выполняется просто:
device = dpctl.select_default_device()
print("Using device ...")
device.print_device_info()
Дефолтное устройство можно установить с помощью переменной среды SYCL_DEVICE_FILTER
, если мы хотим контролировать выбор устройства без изменения этой простой программы. Библиотека dpctl также поддерживает программное управление для анализа и выбора доступного устройства на основе свойств аппаратного обеспечения.
Фрагмент программы также можно активировать (перенести и выполнить) на устройстве буквально парой строк кода:
with dpctl.device_context(device):
dpar_add[global_size,dppy.DEFAULT_LOCAL_SIZE](a,b,c)
Использование device_context
приводит к тому, что среда выполнения создаёт все необходимые копии данных (наши данные по-прежнему находились в стандартных массивах NumPy). Кроме этого, dpctl предлагает возможность явного выделения USM-памяти для устройств и управления ею. Это особенно пригождается, когда при углублении в оптимизацию возникают сложности с поручением её обработки для стандартных массивов NumPy среде выполнения.
Асинхронность и синхронность
Написание кода в стиле Python легко поддерживается описанными выше синхронными механизмами. При этом, если мы готовы слегка изменить код, перед нами также открываются асинхронные возможности и сопутствующие им преимущества (сокращение или исключение задержек при перемещении данных и активации фрагментов кода). Подробнее об асинхронном выполнении можете узнать из примера кода в dpctl gemv.
А что насчёт CuPy?
Библиотека CuPy является NumPy/SciPy-совместимой библиотекой, позволяющей выполнять их код на платформах Nvidia CUDA или AMD ROCm. Однако серьёзные усилия, необходимые для повторной реализации CuPy под новые платформы, существенно мешают обеспечить мультивендорную поддержку в одной программе Python, так что две вышеупомянутых сложности она не решает. CuPy может выбирать только среди GPU-устройств с CUDA. При этом она не даёт возможности прямого контроля памяти, хотя и может автоматически выполнять её пулинг с целью сокращения числа вызовов cudaMalloc
. При переносе выполнения кода она не позволяет выбирать для этого устройство и в случае отсутствия доступного GPU с CUDA даст сбой. Большую универсальность для приложения можно достичь за счёт использования более удобного инструмента, решающего указанные сложности, связанные с разнородностью устройств.
А что насчёт scikit-learn?
В целом программирование на Python отлично подходит для реализации вычислений, направленных на конкретные данные (методика «compute-follows-data»). Библиотека dpctl поддерживает тензоры, которые мы связываем с заданным устройством. Если мы в своей программе можем привести данные к тензору для устройства (например, dpctl.tensor.asarray(data, device="gpu:0")
), они будут ассоциированы с этим устройством и помещены на него. При использовании пропатченной версии scikit-learn, способной распознавать эти тензоры, задействующие такой тензор методы вычисляются на связанном устройстве автоматически.
Это прекрасный вариант использования динамической типизации в Python, позволяющий понять, где располагаются данные, и направить вычисления именно туда. Единственное, что изменяется в нашем коде Python – это место, где наши тензоры преобразуются в тензоры для устройства. Судя по текущим отзывам, мы ожидаем, что методика «compute-follows-data» станет наиболее популярной среди Python-программистов.
Открытость, мультивендорность и мультиархитектурность
Python может выполнять роль инструмента, объединяющего мощность аппаратного разнообразия и позволяющего задействовать потенциал надвигающегося «Кембрийского взрыва» в сфере ускорителей. Здесь стоит иметь ввиду использование распараллеливания данных через Numba совместно с dpctl и техниками «compute-follows-data» из scikit-learn, поскольку все эти решения не ограничены какими-либо вендорами или архитектурами.
При том, что Numba предоставляет отличную поддержку для NumPy, в перспективе будет нелишним рассмотреть дополнительные возможности для SciPy и прочих направлений программирования на Python. Фрагментация API массивов в Python подтолкнула к стандартизации этих API (подробнее читайте здесь) на фоне возникшего стремления разработчиков разделять рабочие нагрузки с устройствами, помимо CPU. Стандарт API массивов во многом помогает расширить область применения Numba и dpctl, а также их влияние. В NumPy и CuPy поддержка API массивов уже внедрена, и на очереди стоят dpctl с PyTorch. По мере охвата всё большего числа библиотек, реализация поддержки разнородных вычислений (ускорителей всех типов) становится всё доступнее.
Простого использования dpctl.device_context
оказывается недостаточно в более сложном коде Python со множеством потоков или асинхронных задач (вот соответствующая тема на GitHub). Здесь будет лучше следовать политике «compute-follows-data», по крайней мере в сложном многопоточном коде Python. Этот подход может стать предпочтительным в сравнении с использованием device_context
.
Перед нами есть масса возможностей по оптимизации способов повышения быстродействия Python. Все эти решения являются опенсорсными и на сегодня работают очень хорошо.
Дополнительные материалы
Самое лучшее обучение – это практика. Ну а чтобы было проще начать, ниже я привожу некоторые полезные вспомогательные источники информации.
Для Numba и dpctl есть 90-минутное видео, в котором разбираются описанные выше принципы: «Data Parallel Essentials for Python».
Ещё есть прекрасное видео «Losing your Loops Fast Numerical Computing with NumPy» от Джейка Вандерпласа (автора «Python Data Science Handbook»), где он рассказывает о способах эффективного использования NumPy.
Все описанные в данной статье решения для использования Python в сфере разнородных вычислений являются опенсорсными и по умолчанию включены в наборы инструментов Intel® oneAPI Base и Intel® AI Analytics. NumPy с поддержкой SYCL размещена на GitHub. Расширения компилятора Numba для выделения фрагмента программы и автоматического переноса соответствующей вычислительной нагрузки также находятся на GitHub. Что касается открытых инструментов для управления распараллеливанием данных, то по этой теме доступна документация на GitHub и исследовательская работа «Interfacing SYCL and Python for XPU Programming». Они позволяют программам Python обращаться к устройствам SYCL, очередям, памяти, а также выполнять операции с массивами/тензорами, используя ресурсы SYCL.
Помимо этого, в них реализована обработка исключений, включая асинхронные ошибки в коде устройств. Эти ошибки перехватываются при повторном выбрасывании в виде синхронных исключений соответствующими функциями-обработчиками. Это поведение обеспечивается генераторами расширений Python и хорошо описано в документации сообщества: Cython и Pybind11.
▍ P.S. от переводчика🎄
Уважаемые читатели, поздравляю вас с наступающим Новым годом! По всей видимости, он будет не менее ярким, чем уходящий, и с учётом такого положения дел хочется пожелать всем конструктивной оптимизации жизни и успешной адаптации под быстроменяющиеся реалии. Пусть спутниками в этом процессе для вас станут стойкость, оптимизм и простая взаимная человеческая любовь, которой в нашей чувственной палитре мира стало явно нехватать. Балансируя на правильной смеси этих трёх компонентов, будет куда проще как преодолевать сложности, так и покорять новые вершины.
Отдельно хочу выразить признательность всем постоянным читателям, в особенности тем, кто помогает повышать качество публикуемых мной материалов с помощью объективной критики, рекомендаций и банального указания на ошибки. Я осознаю неполноту своих знаний и стремлюсь при каждой возможности их расширить. Спасибо вам и Всех Благ!
Автор: Дмитрий Брайт