python

Python на максималках: расширения на языках Rust и Cython

  • пятница, 4 ноября 2022 г. в 00:39:18
https://habr.com/ru/company/exness/blog/697034/
  • Блог компании Exness
  • Python
  • Программирование
  • Rust


Python — лучший выбор для анализа данных и машинного обучения. Его производительность в большинстве случаев более чем достаточна. Но как быть, если объемы ваших данных растут значительно быстрее, чем имеющиеся ресурсы, и требуется повышение производительности? 

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

Что не так с Python?

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

Кроме того, при создании переменной не нужно объявлять ее тип, так как Python — интерпретируемый язык, а не компилируемый.

Однако за всеми этими преимуществами таятся два серьезных недостатка Python, и вам лучше подумать о них прежде, чем ваши данные вырастут в объеме, их обработка станет слишком сложной и потребует нестандартных подходов:

  1. Скорость обработки.

  2. Ограниченные возможности параллелизации из-за глобальной блокировки интерпретатора (Global Interpreter Lock, GIL), что является прямым следствием того, что Python является интерпретируемым языком. 

Но давайте пока отложим в сторону параллельные вычисления (мы вернемся к ним в будущих статьях). Сначала хотелось бы обсудить скорость работы Python и возможные пути ее повышения с помощью расширений на языках Rust и Cython. 

Как правило, их сравнивают с помощью отдельных тестов и сопоставляют только с Python. В этой статье мы напрямую сравним чистый Python с Rust и Cython на примере нескольких задач.

Расширения на компилируемых языках (например, на Rust и Cython) позволяют выделить самые вычислительно затратные операции в отдельные модули, написанные на другом, более эффективном языке программирования. Скомпилированные модули можно импортировать и использовать изнутри кода Python с помощью классической процедуры импорта. Например:

import my_module

где my_module — компилируемое расширение.

А нужны ли нестандартные расширения в коде Python?

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

  1. Сам алгоритм должен быть близок к оптимальному. Переписывание неэффективного алгоритма с привлечением другого языка и его использование в качестве расширения Python может сработать хуже, чем простая оптимизация Python;

  2. Существует целый ряд доступных библиотек Python, позволяющих достаточно быстро обрабатывать данные, например Numpy, Pandas, Dask, Vaex, Polars, PyTorch и другие. Вы можете попробовать использовать их для ускорения выполнения своего кода Python, так как они в основном написаны на компилируемых языках и работают быстро. PyTorch стоит здесь особняком: эта библиотека применяется не только для проектирования нейронных сетей, но и для ускорения матричных вычислений, потому что она использует параллельные вычисления на графических процессорах (GPU). В таком контексте PyTorch можно считать аналогом Numpy, предназначенным для вычислений на графических процессорах, а не на ЦП. 

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

Таким образом, разрабатывать собственные расширения для Python имеет смысл только в том случае, если ваша задача никак не может быть решена с привлечением кода из вышеупомянутых библиотек или средствами SQL. Но если вам нужно выполнять специфические преобразования данных, разбивать данные на нестандартные интервалы или производить другую сложную обработку, такие расширения могут оказаться весьма уместными. В некоторых случаях использование Rust или Cython позволяет повысить производительность алгоритма более чем в 40 раз — например, при преобразовании длинных последовательностей, таких как аудиосигналы. 

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

Что такое Rust и Cython?

В этой статье мы рассмотрим компилируемые языки Rust и Cython, которые позволяют создавать расширения для кода Python. Cython изначально разрабатывался именно для этого, тогда как Rust — самостоятельный, сравнительно молодой язык программирования, популярность которого быстро растет. Он используется для разработки веб-приложений, машинного обучения (например, существует версия BERT для Rust) и других целей.

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

Основы Rust и Cython

Для начала напишем простое расширение Python, возвращающее первый элемент переданного массива.

Rust

Установите Rust согласно официальным инструкциям: https://www.rust-lang.org/tools/install

Вначале создадим папку проекта с именем «rust_processing». Папка будет выглядеть следующим образом:

rust_processing
- Cargo.toml
- src
- - lib.rs

Cargo.toml описывает создаваемое расширение:

[package]
name = "rust_processing"
version = "0.1.0"
authors = ["MyName"]
edition = "2018"

[lib]
name = "rust_processing"
crate-type = ["cdylib"]

[dependencies]
rand = "0.8.4"

[dependencies.cpython]
version = "0.5"
features = ["extension-module"]

Убедитесь, что переменные «name» везде одни и те же и совпадают с именем расширения. Далее мы будем использовать это имя для импорта расширения в Python. 

Помимо Cargo.toml нам также понадобится папка с именем «src», содержащая скрипт lib.rs (rs — расширение для кода на Rust). В него мы вставим код нашего расширения, а именно:

# Для объединения Rust и Python необходимо обеспечить преобразование кода на этих языках
extern crate cpython;

use cpython::{PyResult, Python, py_module_initializer, py_fn};

py_module_initializer!(rust_processing, |py, m| {
    m.add(py, "doc", "Этот модуль реализован на языке Rust")?;
    m.add(py, "return_first", py_fn!(py, return_first(array: Vec<String>)))?;
});

fn return_first(_py: Python, array: Vec<i32>) -> PyResult<i32> {
    Ok(_return_first(&users))
}

fn __return_first(array: &Vec<i32>) -> i32 {
    array[0]
}

Важно отметить, что эта функция может принимать как список Python, так и массив Numpy. Второй вариант будет работать быстрее.

Вы, вероятно, заметили в приведенном коде, что Rust требует использовать статическую типизацию и обработку указателей. Это может показаться чрезмерно сложным. Тем не менее, если вы никогда не работали с компилируемыми языками программирования, вам будет со временем все легче осваивать Rust. Сообщество пользователей языка активно растет, у языка подробная документация и интуитивное описание ошибок компиляции

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

cargo rustc --release -- -C link-arg=-undefined -C link-arg=dynamic_lookup 

Это вариант для macOS. Для Linux и Windows все еще проще:

cargo rustc --release

После завершения компиляции в каталоге проекта появится папка «target». Внутри нее в папке «release» будет находиться файл librust_processing.dylib (в Linux он будет иметь расширение .so вместо .dylib, в Windows — .dll). Теперь необходимо изменить расширение на .so, а имя — на то, которое записано в файле cargo.toml.

Эти изменения также вносятся через терминал:

cargo rustc --release

Наконец, если поместить файл rust_processing.so в папку, содержащую конечный скрипт Python, и просто импортировать его. Функция return_first станет доступна из Python.

Cython

Чтобы использовать Cython, установите его с помощью pip:

pip install cython

Затем создайте два файла: setup.py и cython_processing.pyx. В файле setup.py описывается процедура компиляции:

from setuptools import setup
from Cython.Build import cythonize
import numpy

setup(
    ext_modules=cythonize("cython_processing.pyx"),
    include_dirs=[numpy.get_include()]
)

А в файле cython_processing.pyx описывается функциональность модуля:

import numpy as np
cimport numpy as np
cimport cython

cpdef list return_first(np.ndarray array):
    return array[0]

Очень похоже на Python, не правда ли? 

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

Для компиляции просто выполните в терминале следующую команду:

python setup.py build_ext --inplace

Теперь у вас есть файл с расширением .so, который можно импортировать в программу Python. 

Сравнительные тесты

Описание

Для сравнения производительности наших расширений давайте решим несколько задач с массивами для различных наборов данных: 

  • используя чистый Python,

  • с оптимизацией на основе Rust,

  • с оптимизацией на основе Cython.

И первым будет…

Первый элемент

Начнем с простейшей задачи: возвращение первого элемента переданного массива. Это реализуется на языках Python, Cython и Rust следующим образом.

Чистый Python:

def return_first(numbers):
    return numbers[0]

Cython:

import numpy as np
cimport numpy as np
cimport cython

cpdef float return_first(np.ndarray[np.float64_t, ndim=1] numbers):
    return numbers[0]

Rust:

extern crate cpython;

use cpython::{PyResult, Python, py_module_initializer, py_fn};

py_moduly_initializer(rust_processing, |py, m|{
    m.add(py, "__doc__", "Модуль для обработки Rust")?;
    m.add(py, "return_first", py_fn!(py, return_first(numbers: Vec<f32>)))?;
   Ok(())
});

fn return_first(_py: Python, numbers: Vec<f32>) -> PyResult<f32> {
    Ok(_return_first(&numbers))
}

fn _return_first(numbers: &Vec<f32>) -> f32 {
    numbers[0]
}

Теперь протестируем производительность этих трех версий, используя два набора случайно генерируемых массивов объемом 1000 элементов (небольшой) и 10 000 элементов (большой).

Как ни странно, быстрее всех оказался Python. Почему так получилось? 

Дело в том, что все операции выполняются в Python, то есть требуется некоторое дополнительное время на преобразование типов данных из одного языка в другой. Больше всего времени заняла реализация на Rust. Почему? Rust использует ряд специальных механизмов, обеспечивающих простое, стабильное и надежное управление памятью для высоконагруженных приложений. Поэтому некоторые простые операции Rust могут выполняться медленнее.

Теперь попробуем усложнить задачу.

Усечение временного ряда

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

В этой демонстрации мы упростим код для удобочитаемости. Мы передаем только одномерный массив сумм депозитов по дням (переменная deposits) и ищем пары индексов дней, указывающих начало и конец временного ряда (массив кортежей borders).

Итак, вот что у нас получилось.

Чистый Python:

def get_borders(deposits: List[float], min_length: int=10): -> List[tuple]
    borders = [ ]
    for i in range(len(deposits)):
        if deposits[i] > 0:
            if len(borders) == 0:
                borders.append([i])
            else:
                if deposits[i] - borders[-1][0] >= min_length:
                    borders[-1].extend([i])
                    borders.append([i])
    return borders

Cython:

cpdef list get_borders(np.ndarray[np.float64_t, ndim=1] deposits, int min_length=10):
    cdef int i
    cdef list borders = [ ]
    cdef int n_borders
    for i in range(len(deposits)):
        n_borders = len(borders)
        if deposits[i] > 0:
            if n_borders == 0:
                borders.append([i])
            else:
                if i - borders[n_borders - 1][0] >= min_length:
                    borders[n_borders - 1].extend([i])
                    borders.append([i])
    return borders

Rust:

fn get_borders(_py: Python, deposits: Vec<f32>) -> PyResult<Vec<Vec<i32>>> {
    Ok(_get_borders(&deposits))
}

fn _get_borders(deposits: &Vec<f32>) -> Vec<Vec<i32>> {
    let mut borders: Vec<Vec<i32>> = Vec::new();
    for i in 0..(deposits.len()) {
        let n_borders = borders.len();
        if deposits[i] > 0 {
            if n_borders == 0 {
               borders.push(vec![i as i32]);
            }
            else {
                if i as i32 - borders[n_borders - 1][0] >= min_length {
                    borders[n_borders - 1].extend(vec![i as i32];
                    borders.push(vec![i as i32]);
                }
            }
        }
    }
    borders
}

И вновь протестируем производительность этих трех версий на двух наборах случайно генерируемых массивов: 1000 элементов (небольшой) и 10 000 элементов (большой).

Ситуация изменилась. Теперь Cython и Rust работают заметно быстрее, чем чистый Python. При этом Cython оказывается немного быстрее, чем Rust. Это можно объяснить тем, что Rust лучше подходит для серьезных вычислений.

Но что, если еще сильнее усложнить задачу?

Усечение временного ряда (двойной цикл)

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

Для простоты мы добавим фиктивный цикл, в котором ничего существенного не происходит.

Чистый Python:

def get_borders_double_loop(deposits: List[float], min_length: int=10): -> List[tuple]
    borders = [ ]
    for i in range(len(deposits)):
        for j in range(len(deposits)):
            temp = 0
        if deposits[i] > 0:
            if len(borders) == 0:
                borders.append([i])
            else:
                if i - borders[-1][0] >= min_length:
                    borders[-1].extend([i])
                    borders.append([i])
    return borders

Cython:

cpdef list get_borders_double_loop(np.ndarray[np.float64_t, ndim=1] deposits, int min_length=10):
    cdef int i
    cdef int temp
    cdef list borders = [ ]
    cdef int n_borders
    for i in range(len(deposits)):
        for j in range(len(deposits)):
            temp = 0
        n_borders = len(borders)
        if deposits[i] > 0:
            if n_borders == 0:
                borders.append([i])
            else:
                if i - borders[n_borders - 1][0] >= min_length:
                    borders[n_borders - 1].extend([i])
                    borders.append([i])
    return borders

Rust

fn get_borders_double_loop(_py: Python, deposits: Vec<f32>) -> PyResult<Vec<Vec<i32>>> {
    Ok(_get_borders(&deposits))
}

fn _get_borders_double_loop(deposits: &Vec<f32>) -> Vec<Vec<i32>> {
    let mut borders: Vec<Vec<i32>> = Vec::new();
    let mut temp: i32
    for i in 0..(deposits.len()) {
        for j in 0..(deposits.len()) {
            temp = 0
        }
        let n_borders = borders.len();
        if deposits[i] > 0 {
            if n_borders == 0 {
               borders.push(vec![i as i32]);
            }
            else {
                if i as i32 - borders[n_borders - 1][0] >= min_length {
                    borders[n_borders - 1].extend(vec![i as i32];
                    borders.push(vec![i as i32]);
                }
            }
        }
    }
    borders
}

Приводим результаты измерения скорости выполнения:

Любопытно, не так ли?

 Реализация на Python вполне ожидаемо «просела», показав более чем тысячекратное снижение производительности на большом наборе данных. Cython также не избежал замедления, но всего в 300 раз. Его скорость в 12 раз выше, чем скорость чистого Python. Неплохо.

Но посмотрите на Rust. Он с трудом соревновался с Cython в одиночном цикле, но в случае двойного цикла его производительность ничуть не изменилась! На одном цикле Rust демонстрирует практически ту же производительность, что и Cython, но на двойном цикле он имеет 245-кратное преимущество.

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

Замечание о Cython

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

Посмотрите на предыдущий пример. Если вы «забудете» определить тип возвращаемой переменной, производительность алгоритма упадет в 2–3 раза (120 мкс вместо 80 для небольшого набора данных и 1,5 мс вместо 0,6 мс для большого).

Если ваш алгоритм Cython не ускорился так, как ожидалось, просто еще раз проверьте все определения переменных. Возможно, вы что-то пропустили или присвоили какой-то переменной неэффективный тип.

Заключение

Какой можно сделать вывод? Cython и Rust позволяют кардинально улучшить производительность вашего алгоритма. Но, как обычно, здесь есть свои плюсы и минусы.

Язык

Преимущества

Недостатки

Python

Удобство использования

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

Низкая производительность для высоконагруженных вычислений

Cython

Синтаксис поход на Python

Постепенное повышение производительности по мере оптимизации кода

Потенциально значительное повышение производительности

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

Синтаксис более сложен по сравнению с Python

Производительность вложенных циклов и рекурсии ниже, чем у Rust

Rust

Серьезное преимущество на вложенных циклах и рекурсиях даже по сравнению с Cython

Гораздо более сложный синтаксис

Что же выбрать? Как всегда, выбор за вами. Но сначала убедитесь, что ваш алгоритм на Python близок к оптимальному. Возможно, вам не потребуется никакого ускорения. Затем оцените уровень сложности алгоритма. Чем ближе он к простому циклу, тем больше будет аргументов в пользу Cython. Если он содержит вложенные циклы, рекурсии или другие вычисления, обладающие потенциалом для распараллеливания, то наилучшим вариантом может быть Rust.

Что дальше?

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

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

Тестовый стенд

Все примеры кода и тесты скорости выполнения, упомянутые в этой статье, проводились на MacBook Pro с чипом Apple M1 Pro (16 Гб) и с использованием Python 3.9.7 для архитектуры ARM64.