Написание и интеграция нативного модуля 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.