Поиск по сайту Поиск

Danger, danger, high performance: ускоряем Python по максимуму

Разрушаем мифы и рассказываем, как достичь высокой производительности в программах на Python.

Вот уже более десятка лет Python широко используется как разработчиками, так и исследователями. За счёт своей эффективности и простоты он стал популярен в научных вычислениях и машинном обучении. Однако базовые функции Python — однопоточные. То есть программы на Python не могут одновременно использовать множество процессорных ядер. Как же тогда достичь высокой производительности в анализе данных и машинном обучении на Python?

Язык Python изначально предназначался для введения динамической типизации и предсказуемого, потокобезопасного поведения вместо сложного управления статическими типами и потоковыми примитивами. Для этого в нём используется глобальная блокировка интерпретатора (Global Interpreter Lock, GIL), которая ограничивает выполнение операций только одним потоком за раз. За последнее десятилетие было представлено много реализаций параллельных вычислений для Python, но они не обеспечивали настоящий параллелизм. Означает ли это, что Python — непроизводительный язык? Давайте разберёмся.

Фундаментальные конструкции базового языка для циклов и других асинхронных или параллельных вызовов подчиняются однопоточному GIL. Даже такое определение списка — [x*x for x in range(0,10)]  — всегда будет однопоточным. Хотя в языке существует библиотека поддержки потоков, которая многих вводит в заблуждение, на самом деле все операции выполняются в рамках GIL. Почему же в таком выразительном языке присутствуют эти правила?

Причина тому — уровень абстракций, принятый языковой концепцией. В рамках самого Python достижима лишь многопроцессность, то есть параллелизм на уровне отдельных рабочих процессов. Тем самым оказываются потеряны некоторые важные преимущества многопоточности, такие как общий доступ к памяти родительского процесса и сниженные накладные расходы на коммуникацию. Обеспечение многопоточности в Python достижимо посредством «склейки» управляющего Python-кода с библиотеками на других языках, например, на Си. Так, интерфейсы вроде  ctypes или cffi повсеместно используются в популярных пакетах NumPy и SciPy для подключения внешних производительных библиотек со встроенной многопоточностью или даже с поддержкой GPU (например, CUBLAS).

Существует ряд других техник повышения производительности Python-программ. Например, доступны следующие фреймворки:

Numba: допускает JIT-компиляцию кода (Just-in-time), а также может запускать Python-совместимый код на основе LLVM (Low Level Virtual Machine).

Cython: предоставляет Python-подобный синтаксис со скомпилированными модулями, которые могут использовать аппаратную векторизацию при компиляции в C.

numexpr: позволяет использовать компиляторы и продвинутую векторизацию для символьных вычислений.

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

Рассмотрим общий пример одной из наиболее распространённых конструкций, к которой мы бы хотели применить параллелизм — цикл for. Посмотрим на фрагмент:

def test_func(list_of_items):
    final_list = []
    for items in list_of_items:
        if item < 50:
            final_list.append(item)
    return final_list

Здесь мы проверяем список list_of_items и возвращаем все числа из него, которые меньше 50.

Запуск этого кода даёт следующий результат:

import random
random_list = [random.randint(0,1000000) for x in range(0,1000000)]
%timeit test_func(random_list)
27.4ms ± 331 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Python обрабатывает список последовательно с помощью одного потока, поскольку код написан на базовом чистом языке. Здесь мы не наблюдаем никакого параллелизма. Такие конструкции — хорошие кандидаты для фреймворка Numba. Он использует декоратор с символом @, чтобы помечать функции для JIT-компиляции:

@jit(nopython=True)
def test_func(list_of_items):
    final_list = []
    for item in list_of_items:
        if item < 50:
            final_list.append(item)
    return final_list

Теперь мы получим:

%timeit test_func(random_list)
15.7ms ± 173 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Видно, что производительность повысилась почти вдвое. Дело в том, что исходный код Python написан в примитивах и типах данных, которые могут быть легко скомпилированы и векторизованы для CPU. И первое, на что стоит обратить внимание — это списки. Они бывают очень «тяжёлыми» из-за слабой типизации и встроенного аллокатора. Но если мы посмотрим на типы данных, содержащиеся в random_list, то увидим, что они все целочисленные. Благодаря этой согласованности типов JIT-компилятор Numba может векторизовать цикл.

Если список содержит разнотипные элементы (например, символы и числа), то выполнение кода завершится ошибкой TypeError. Кроме того, если функция содержит операции для смешанных типов данных, Numba не сможет создать высокопроизводительный JIT-код и обратится к объектному коду Python.

Урок здесь заключается в том, что достижение параллелизма в Python зависит от исходного кода. Чистота типов и использование векторизуемых структур данных позволяют Numba распараллеливать код с помощью простого декоратора. Наиболее осторожно следует обращаться со словарями, поскольку обычно они плохо поддаются векторизации. То же относится к генераторам и списковым включениям. Реорганизация их в списки, множества или массивы может облегчить ситуацию.

Гораздо проще достичь параллелизма в числовой и символьной арифметике. NumPy и SciPy отлично справляются с пересылкой вычислений вне GIL-кода на низкоуровневый код С и среду выполнения CUBLAS. Возьмём, к примеру, символьное выражение NumPy ((2 * a + 3 * b) / b):

import numpy as np
a = np.random.rand(int(1e6))
b = np.random.rand(int(1e6))

%timeit (2*a + 3*b)/b
8.61ms ± 108 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Выражение многократно использует однопоточный интерпретатор Python из-за структуры библиотеки NumPy. Каждый return из Numpy передаётся в C и затем обратно возвращается на уровень Python.  Потом объект Python направляется к каждому последовательному вызову для повторной отправки на C. Эти прыжки туда-сюда создают так называемое «узкое место» в вычислениях. Поэтому, если вы хотите посчитать линейную алгебру, которую тяжело или невозможно описать в Numpy или SciPy, лучшим вариантом будет numexpr:

import numexpr as ne
%timeit ne.evaluate('(2*a + 3*b)/b')
2.22ms ± 52.7 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Как же numexpr достигает почти четырёхкратного ускорения? Он использует символьное представление вычислений для генерации кода, которое работает на уровне функций доступной библиотеки BLAS. В случае BLAS для CPU, код этих функций будет наилучшим образом векторизован; в случае CUBLAS — вычислительную нагрузку примут ядра графического процессора. Так все вычисления остаются в виде низкоуровневого кода до их завершения и возвращения результата обратно на уровень Python. Этот метод также позволяет избежать многократных обращений через интерпретатор Python, сокращая число однопоточных участков кода, а также обеспечивает краткий синтаксис.

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

С оригинальной статьёй можно ознакомиться на сайте techdecoded.intel.io.

Что такое IaaS

Совсем недавно в блоге мы рассказывали об ИТ-инфраструктуре. В неё могут входить серверы, персональные компьютеры, маршрутизаторы, веб-серверы, операционные системы, CMS...
Read More

Сравнение IAAS, PAAS, SAAS

Чтобы создать IТ-инфраструктуру для своего бизнеса, не обязательно сразу покупать и настраивать дорогостоящее оборудование, а также нанимать целый штат администраторов....
Read More

Как реклама работает сама или Что такое Яндекс.Бизнес

Мы не раз рассказывали о том, как создать и настроить сайт. Но для эффективной работы веб-ресурса и регулярной прибыли важно...
Read More

9 плагинов WordPress для чата онлайн-поддержки

Любой клиент хочет быстро получить ответы на вопросы, связанные с выбором товара, оформлением заказа, доставкой и прочим. Чаще всего, если...
Read More

Властелин порядка. Как устроена работа процесс-менеджеров в REG.RU

IT-индустрия развивается суперстремительно. Постоянно появляются новые инструменты, подходы и специальности. Сегодня остановимся на методологиях разработки IT-продуктов и узнаем, кто такой...
Read More

DoS vs DDoS-атака: отличия и профилактика

Для хорошей работы любого сайта важно обеспечить надёжное подключение и защитить его от атак и взломов. Ведь хакерские атаки, независимо...
Read More

Сыграем в города? .МОСКВА, .NYC, .PARIS и другие «городские» домены для локального бизнеса

Первое знакомство клиента с компанией часто происходит через интернет. Чем ярче проект — тем больше шансы выделиться среди конкурентов и...
Read More

Как напомнить клиентам о себе через экран смартфона

Любому бизнесу важно не только искать новых клиентов, но и поддерживать связь со старыми — с этой задачей отлично справляется...
Read More

Как создать свой сайт с нуля

Чтобы создать сайт, вам понадобится несколько обязательных элементов: домен, хостинг, SSL-сертификат и программа для создания внешнего вида сайта. Каждый из этих элементов можно...
Read More

Сарафанный маркетинг: как заставить всех о вас говорить

Любой человек, принимая решение о покупке, будет прислушиваться к отзывам знакомых. Также если близкий человек позитивно отзывается о бренде и...
Read More