Написание и интеграция нативного модуля Python в ваш проект занимает много времени; стоит ли оно того?

Во-первых, вам нужно выбрать структуру реализации. Например, при использовании подхода AOT (упреждающая генерация машинного кода) вы можете написать расширение с помощью стандартного инструмента, такого как Cython, или выбрать на основе C/C++. генератор привязок (например, boost::python, pybind11, CFFI) или, наконец, протестируйте новичка, такого как Rust, с фреймворком PyO3.

Затем вам нужно решить, что оптимизировать, найти критически важные части кода и решить, какую часть этого кода вам нужно переместить в нативный модуль.

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



После выбора фреймворка возникает реальный вопрос: что мы хотим оптимизировать? Каковы плюсы и минусы использования нативного модуля?

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

Что измерять?

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

  • Чистая реализация Python: основа этого эталонного теста.
  • Реализация Cython с подсказками типов.
  • Реализация Rust с фреймворком PyO3.

С целями:

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

И некоторые ограничения:

  • В тесте используется функция только с базовыми типами данных. В нативных модулях со структурированными или настраиваемыми типами данных операции копирования и преобразования этих объектов между Python слоем и уровнем собственного кода требуют дополнительных затрат. .
  • Собственный код функции is_prime использует только основные тестовые операторы и математические операции в блоках циклов. Это не сложная вычислительная задача, которая может использовать инструкции SIMD/векторизации, параллелизм, многопоточность и т. д. В сложных вычислительных заданиях повышение производительности может быть намного выше.

Как?

Начнем с простой функции, основанной на моей предыдущей статье.



Этот код обеспечивает на основе Rust реализацию функции is_prime. К этой базовой линии мы добавим:

  • Чистая Python реализация is_prime
  • Реализация is_prime на Cython.
  • Функция для сравнения тысяч запусков и отслеживания их для дальнейшего анализа.

Наша эталонная функция

Наш тест будет использовать простую функцию проверки простоты. Эта функция взята из моей предыдущей статьи Смешанный проект Rust + Python. Функция is_prime проверяет простоту своих входных данных путем деления их на предшествующие числа. Для числа num мы проверяем остаток от деления чисел между 2 и √num.

→ Rust «is_prime» =
 
#[pyfunction] (1)
fn is_prime(num: u32) -> bool {
    match num {
        0 | 1 => false,
        _ => {
            let limit = (num as f32).sqrt() as u32; (2)
 
            (2..=limit).any(|i| num % i == 0) == false (3)
        }
    }
}
  • (1): макрос Rust #[pyfunction] генерирует код для привязки Python.
  • (2): вычислить верхнюю границу нашего ряда пробных делений.
  • (3): создайте испытания и протестируйте оставшуюся часть раздела.

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

  • Проверка простого числа. Например, число 12899 включает 112 проверок, прежде чем вернуть значение true.
  • Проверка непростого числа. Например, предыдущее число 12898 требует только 1 проверки, прежде чем будет возвращено значение false.

Структура проекта

На основе определенного кода и структуры статьи Смешанный проект Rust + Python. Я добавляю только директорию с чистой реализацией Python и Cython is_prime.

$ tree mybench
 
mybench
├── bench
│   ├── bench.py
│   └── cytest.pyx
├── Cargo.toml
├── pyproject.toml
├── src
│   └── lib.rs
└── test
    └── test.py
 
3 directories, 6 files

В наш файл pyproject.toml мы добавляем зависимость от пакета click для управления нашими аргументами командной строки.

[project.optional-dependencies]
test = [
  "hypothesis",
  "sympy",
  "click"
]

Чтобы добиться наилучшей производительности кода Rust, мы компилируем проект с флагом --release, чтобы включить оптимизацию.

$ cd mybench
$ maturin develop --release --extras test

Чистая реализация Python

Наша реализация на чистом Python очень проста, и только «цикл for» проверяет наши пробные версии. Код <<pure_python_is_prime>> интегрирован в файл bench.py.

→ Python «pure_python_is_prime» =
 
def is_prime_py(num: int) -> bool:
    if num == 0 or num == 1:
        return False
    else:
        limit = math.sqrt(num)
        for i in range(2, int(limit) + 1):
            if num % i == 0:
                return False
        return True

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

→ Python «instrumented_is_prime» =
 
def is_prime_py_instrumented(num):
    if num == 0 or num == 1:
        return (False, 1)  (1)
    else:
        ntests = 0
        limit = math.sqrt(num)
        for i in range(2, int(limit) + 1):
            ntests += 1
            if num % i == 0:
                return (False, ntests)  (2)
        return (True, ntests)  (3)
  • (1): если наше число равно 0 или 1, мы возвращаем кортеж (False, 1). False — результат теста на простоту, а 1 — количество задействованных тестов.
  • (2): если в нашем испытании есть остаток 0, то num не является простым числом. «ntests» — счетчик тестовых операций.
  • (3): окончательный случай, все пробные тесты отрицательны, num — простое число.

Реализация Сайтона

Код Cython подобен коду Python; мы добавляем информацию о типе, чтобы ускорить этот код. Cython использует аннотации типов для создания оптимизированного машинного кода.

→ Cython «mybench/bench/cytest.pyx» =
 
import math
 
def is_prime_cy(int num):  (1)
  cdef int i  (2)
  cdef int limit
 
  if num == 0 or num == 1:
    return False
  else:
    limit = int(math.sqrt(num))
    for i in range(2, limit + 1):
      if num % i == 0:
        return False
    return True
  • (1): мы объявляем аргумент нашей функции как int.
  • (2): мы вводим две временные переменные i и limit как int.

Эталонная функция

Эта функция запустит тест с двумя входными аргументами:

  • nb_runs: количество запусков нашей функции. Окончательное время усредняется между этими прогонами.
  • num: число, которое нужно проверить как простое число.
→ Python «bench_number» =
 
def bench_number(nb_runs: int, num: int):
    run_infos = is_prime_py_instrumented(num)  (1)
    tm_rust = timeit.timeit(
        stmt=lambda: mybench.is_prime(num), number=nb_runs
    )  (2)
    tm_cython = timeit.timeit(
        stmt=lambda: cytest.is_prime_cy(num), number=nb_runs
    )  (3)
    tm_python = timeit.timeit(stmt=lambda: is_prime_py(num), number=nb_runs)  (4)
    return run_infos, tm_rust, tm_cython, tm_python
  • (1): мы запускаем функцию is_prime_py_instrumented, чтобы оценить сложность нашего кода (с точки зрения операций).
  • (2): мы тестируем нашу функцию Rust is_prime, запуская ее nb_runs раза. Функция timeit усредняет время выполнения каждого прогона.
  • (3) — (4): одинаковая логика для версий Cython и чистого Python.

Эталонная основная программа

Наконец, мы пишем нашу основную тестовую программу, интегрируя предыдущие фрагменты кода, импортируя наши модули Rust и Cython и интегрируя простой интерфейс командной строки.

→ Python «mybench/bench/bench.py» =
 
import click
import timeit
import math
import pathlib
import typing
import json
import sys
import pyximport

pyximport.install()  (1)

import mybench  (2)
import cytest  (3)

(4)
<<pure_python_is_prime>>
(5)
<<instrumented_is_prime>>
(6)
<<bench_number>>


(7)
@click.command()
@click.option(
    "--nb_runs",
    type=int,
    default=1000,
    help="Number of runs.",
)
@click.option(
    "--num",
    type=int,
    default=12899,
    help="The number to test or upper bound \
    if output_serie is defined.",
)
@click.option(
    "--output_serie",
    type=str,
    default=None,
    help="Output file for serie results.",
)
def bench(nb_runs: int, num: int, output_serie: str):
    if output_serie is None:  (8)
        (
            run_infos,
            tm_rust,
            tm_cython,
            tm_python,
        ) = bench_number(nb_runs, num)
        print(
            "Running {} primality tests on {}, \
            result '{}' with {} tests".format(
                nb_runs,
                num,
                run_infos[0],
                run_infos[1],
            )
        )
        print("Bench Rust/PyO3 {:.3f} {}".format(tm_rust, mybench.is_prime(num)))
        print("Bench Cython {:.3f} {}".format(tm_cython, cytest.is_prime_cy(num)))
        print("Bench Python {:.3f} {}".format(tm_python, is_prime_py(num)))
    else:  (9)
        prime_list = [tst_num for tst_num in range(2, num + 1) if is_prime_py(tst_num)]
        with open(output_serie, "w+") as fp:  
            fp.write("num,nb_runs,result,nb_tests,\
                      tm_rust,tm_cython,tm_python\n")
            for tst_num in prime_list[0::100]: (10)
                (
                    run_infos,
                    tm_rust,
                    tm_cython,
                    tm_python,
                ) = bench_number(nb_runs, tst_num)
                fp.write(
                    "{},{},{},{},{},{},{}\n".format(
                        tst_num,
                        nb_runs,
                        run_infos[0],
                        run_infos[1],
                        tm_rust,
                        tm_cython,
                        tm_python,
                    )
                )


if __name__ == "__main__":
    bench()
  • (1): pyximport — это средство, предоставляемое Cython для прямой компиляции и импорта. Нам не нужно писать собственный setup.py для сборки с помощью транспилятора cython.
  • (2): мы импортируем наш собственный модуль Rust, скомпилированный в режиме release.
  • (3): мы импортируем наш собственный модуль Cython.
  • (4): наша чистая функция Python определена в блоке кода <<pure_python_is_prime>>.
  • (5): инструментальная версия определена в кодовом блоке <<instrumented_is_prime>>.
  • (6): наша функция бенчмаркинга, работающая с тремя реализациями и определенная в <<bench_number>> блоке кода.
  • (7): мы определяем простой интерфейс командной строки для проведения экспериментов.

Мы можем запустить нашу программу в двух режимах:

  • Для проверки простоты в режиме одиночного эксперимента (8) выбирается одно число.
  • В режиме серийного эксперимента (9). Приведем верхнюю границу ряда. Этот режим позволяет выполнять тестовые прогоны с несколькими уровнями сложности. Мы запускаем эксперимент на сгенерированном ряду простых чисел (ниже указанной верхней границы) для каждых 100 элементов (10).

Результаты и анализ

Во-первых, мы можем протестировать интерфейс командной строки нашей тестовой функции.

$ cd mybench
$ python bench/bench.py --help
 
Usage: bench.py [OPTIONS]
 
Options:
  --nb_runs INTEGER    Number of runs.
  --num INTEGER        The number to test or upper bound if output_serie is
                       defined.
  --output_serie TEXT  Output file for serie results.
  --help               Show this message and exit.

В начальном тесте мы проверяем простоту числа 12899 с помощью 1 000 000 запусков.

$ cd mybench
$ python bench/bench.py --nb_runs 1000000 --num 12899
 
> Running 1000000 primality tests on 12899, result 'True' with 112 tests
> Bench Rust/PyO3 0.529 True
> Bench Cython 0.526 True
> Bench Python 4.349 True

Этот первоначальный тест показывает, что проверка простоты числа 12899 требует 112 внутренних тестов в нашей is_prime функции. Результат — True (12899 — простое число). Время работы чистой версии Python составляет 4.3s. Для Cython и Rust у нас примерно одинаковые результаты производительности с 0.52s.

В этом случае мы имеем коэффициент улучшения производительности x12 между чистым Python и собственным расширением Cython/Rust.

Мы можем проверить стоимость накладных расходов Cython/Rust.

$ cd mybench
$ python bench/bench.py --nb_runs 1000000 --num 1

Тестируя особый случай '1", мы выполняем только один тест в нашей функции is_prime. В этом случае стоимость наших вычислений — это в основном стоимость вызова функции is_prime: мы наблюдаем накладные расходы на вызовы Cython и Rust по сравнению с чистым Python.

> Running 1000000 primality tests on 1, result 'False' with 1 tests
> Bench Rust/PyO3 0.166 False
> Bench Cython 0.113 False
> Bench Python 0.134 False

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

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

$ cd mybench
$ python bench/bench.py --nb_runs 1000000 --num 100000 --output_serie runs.csv

Этот запуск создает выходной файл «runs.csv» в следующем формате.

num,nb_runs,result,nb_tests,tm_rust,tm_cython,tm_python
2,1000000,True,0,0.1748378580014105,0.17820978199961246,0.3463963739995961
547,1000000,True,22,0.23610526800075604,0.24908054699881177,1.2034144149984058
1229,1000000,True,34,0.2719646179994015,0.2854190660000313,1.6644533799990313
1993,1000000,True,43,0.29658469800051535,0.3141682330006006,1.961818502000824
2749,1000000,True,51,0.32396009900003264,0.33482105799885176,2.2520081020011276
3581,1000000,True,58,0.3524247669993201,0.35497432999909506,2.4976866420001897
4421,1000000,True,65,0.37128741100059415,0.37557477799964545,2.7788527150005393
5281,1000000,True,71,0.3890202149996185,0.394951800999479,3.0130012549998355
6143,1000000,True,77,0.4090290809999715,0.41361685099946044,3.184734868000305

Во-первых, график нашей трассировки запуска. На оси X указано количество тестов (сложность выполнения). На оси Y указано среднее время выполнения трех реализаций.

import pandas as pd
 
df = pd.read_csv("mybench/runs.csv")
sdf = df[["nb_tests", "tm_rust", "tm_python", "tm_cython"]]
sdf.plot(x="nb_tests", kind="line")

Неудивительно, что мы видим, что среднее время выполнения линейно увеличивается с ростом сложности в трех реализациях. Чистая Pythonреализациявремя выполнения увеличивается с более крутым наклоном, чем собственные реализации. Производительность Cython и Rust/PyO3 сопоставима.

Во-вторых, график соотношения между запусками чистого Python и запусками нативного (усредненные Rust + Cython).

import pandas as pd
 
df = pd.read_csv("mybench/runs.csv")
df["tm_native"] = (df["tm_rust"] + df["tm_cython"]) / 2.0
df["ratio"] = df["tm_python"] / df["tm_native"]
sdf = df[["nb_tests", "ratio"]]
sdf.plot(x="nb_tests", y="ratio", kind="scatter")

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

Заворачивать

Простое завершение:

  • В нашем тестовом примере накладные расходы на вызовы Cython и Rust/PyO3 сравнимы с небольшим преимуществом Cython.
  • Чем больше вычислительной сложности перемещается в собственное расширение, тем выше повышение производительности. Например, если вы используете встроенную функцию в цикле, вы повышаете производительность, перемещая код цикла в расширение.
  • Для простых функций без циклов использование собственного расширения может оказаться излишним.
  • В этом тесте используются простые типы данных. Если вы используете более сложные типы данных (например, списки или массивы), перемещение этих типов между Python и расширением сопряжено с затратами. Поэтому полезно реализовать эти типы изначально и предоставить их в интерпретаторе Python. Это подход, используемый NumPy [1]: необработанный буфер памяти C, отображаемый в интерпретаторе Python.

Рекомендации

[1] Ван Дер Уолт, С., Колберт, С. К., и Вароко, Г. (2011). Массив NumPy: структура для эффективных числовых вычислений. Вычисления в науке и технике, 13(2), 22–30.