Модули расширения Python на Rust

от автора

“Absolute statements are the root of all evil.
The key is balance. There are no answers, only questions.”
????

Автор статьи: zolkko.

Оптимизации

Когда говорят про оптимизацию в контектсте программного обеспечения, то при этом часто подразумевают оптимизацию производительности программиста и/или оптимизацию самого программного обеспечения.

Исходя из YAGNI принципа, Python позволяет программисту сосредоточиться на реализации ПО, избавив его от необходимости заботиться о низкоуровневых вещах таких как регионых памяти, в которых выделяются объекты, об её освобождении или о соглашениях о вызовах.

На обратную проблему в одной из его лекций о Haskell указал Саймон Джонс. У него был слайд, на котором была нарисована стрелка, закрашенная градиентом: в начале было написано “no types”, посередине — “Haskell”, в конце — “Coq”. При этом, когда он указал на Coq, он сказал такую фразу: “This stresses power over usability. Right?! You need a PhD here!”[1]. Несмотря на то, что это была шутка, мантара Python – одна из любимых программистами особенностей этого языка. И из моего опыта, это то, что позволяет выпускать готовый продукт несколько быстрее.

Что касается оптимизации ПО, то в разных источниках об этом говорится по-разному, но я её для себя делю на три уровня:

  • на уровне архитектуры
  • высокоуровневая, алгоритмы и структуры данных
  • низкоуровневая

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

Оптимизация на уровне выполнения

Наверное, самой логичной и правильной (в плане трудоёмкости) стратегей для низкоуровневой оптимизации Python кода является применение специальных инструметов таких как PyPy, Pyston и прочих. Связано это с тем, что часто использользуемый код Cpython уже оптимален, а попытки что добавление любой строчки приведёт скорее к деградации производительности. А так же не возможность применения “класических” меотдов оптимизации из-за динамической типизации Python.
Эту проблему, в том числе, омечал Kevin Modzelewski на Pyston Talk 2015[2]. По его словам можно расчитывать на примерно 10% runtime. Комбинируюя же различные техники такие как JIT, tracing JIT, эвристический анализ, Pyston добиться прироста производительности на 25%.
А вот один бенчмарк график, взятый из его доклада:

На графике видно графике видно, что в какой-то момент PyPy становится в 38 раз медленнее обычного Cpython. Такой результат наводит на мысль, что применяя такой интсрументарий нужно обязательно проводить измерения производительности. Причём делать это нужно на реальных данные, в условиях приблежённым к реальным условия испольнения ПО. И желательно выполнять такое упражнение при каждом обновлении версий интерпритаторов. Здесь можно привести цитату: “If you make an optimization and don’t measure to confirm the performance increase, all you know for certain is that you’ve made your code harder to read”[3].

Оптимизация на уровне исходного кода

Анологичную проблему можно выявить и при оптимизации на уровне языка за счёт использования идеоматичного производительного кода. Для её иллюстрации привожу пример небольшой программы (не совсем идеомитичной[4]), в которой определяется большой список маленьких слов размещением с повторениями из 3 по 10 и три функции и три функции преобразующие этот список в список слов из заглавных букв:

LST = list(map(''.join, product('abc', repeat=10)))  def foo():     return map(str.upper, LST)  def bar():     res = []     for i in LST:         res.append(i.upper())     return res  def baz():     return [i.upper() for i in LST] 
Модули CPython

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

Инструментарий

Многие из доступных для Python инструментов предлагают разнообразный функционал, начиная с генерации кода для CUDA и заканчивая прозрачной интеграцией с numpy или C++. Однако, дальше по тексту я буду рассматривать их поведение только в контексте написания модулей расширений на специально выбранном пограничном примере:

def add_mul_two(a, b):     acc = 0     i = 0     while i < 1000:         acc += a + b         i += 1     return acc 

Как видно CPython исполняет его буквально:

12 SETUP_LOOP              40 (to 55) 15 LOAD_FAST                3 (i) 18 LOAD_CONST             2 (1000) 21 COMPARE_OP             0 (<) 24 POP_JUMP_IF_FALSE  54  27 LOAD_FAST                2 (acc) 30 LOAD_FAST                0 (a) 33 LOAD_FAST                1 (b) 36 BINARY_ADD 37 INPLACE_ADD 38 STORE_FAST              2 (acc)  41 LOAD_FAST                3 (i) 44 LOAD_CONST             3 (1) 47 INPLACE_ADD 48 STORE_FAST              3 (i) 51 JUMP_ABSOLUTE        15 54 POP_BLOCK 

Поправить ситуацию можно написав простейший модуль расширения на C.
Для этого нужно определить минимальную функцию инициализации модуля:

// example.c void initexample(void) {     Py_InitModule("example", NULL); } 

Эта функция имеет такое название по тому, что фактически выполнение инструкции импорта

import example  IMPORT_NAME  0 (example) STORE_FAST    0 (example) 

Будет приводить к выполнению

// ceval.c ... w = GETITEM(names, oparg); v = PyDict_GetItemString(f->f_builtins, "__import__"); ... x = PyEval_CallObject(v, w); ... 

Встроенной функции builtin___import__ (bltinmodule.c), и дальше по цепочке вызовов:

dl_funcptr _PyImport_GetDynLoadFunc(const char *fqname, const char *shortname, const char *pathname, FILE *fp) {     char funcname[258];     PyOS_snprintf(funcname, sizeof(funcname), "init%.200s", shortname);     return dl_loadmod(Py_GetProgramName(), pathname, funcname); } 

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

Далее определяется метод модуля

static PyObject * add_mul_two(PyObject * self, PyObject * args);  static PyMethodDef ExampleMethods[] = {     {"add_mul_two", add_mul_two, METH_VARARGS, ""},     {NULL, NULL, 0, NULL} };  void initexample(void) {     Py_InitModule("example", ExampleMethods); } 

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

PyObject * add_mul_two(PyObject * self, PyObject * args) {     int a, b, acc = 0;     if (!PyArg_ParseTuple(args, "ii", &a, &b)) {         PyErr_SetNone(PyExc_ValueError);         return NULL;     }     for (int i = 0; i < 1000; i++)         acc += a + b;     return Py_BuildValue("i", acc); } 

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

___main__.add_mul_two$1.int32.int32: addl	%r8d, %ecx imull	$1000, %ecx, %eax movl	%eax, (%rdi) xorl	%eax, %eax retq 

Но написав при этом только две строчки и не выходя за рамки одного языка программирования.

@jit(int32(int32, int32), nopython=True) 

Помимо этого кода numba так же сгенерирует

add_mul_two.inspect_asm().values()[0].decode('string_escape') 

функцию обёртку вида:

_wrapper.__main__.add_mul_two$1.int32.int32: 	...            movq	%rdi, %r14 	movabsq	$_.const.add_mul_two, %r10 	movabsq	$_PyArg_UnpackTuple, %r11     ... 	movabsq	$_PyNumber_Long, %r15 	callq	*%r15 	movq	%rax, %rbx 	xorl	%r14d, %r14d 	testq	%rbx, %rbx 	je	LBB1_8 	movabsq	$_PyLong_AsLongLong, %rax     … 

Её задача — разобрать входные аргументы согласно описанным в декораторе сигнатурам и если это получилось выполнить скомпелированную версию. Этот метод кажеться очень заманчивым, однако если, например, вынести тело цикла в отдульную функцию, то её так же нужно будет обрамлять декоратором или же отключать nopython.

Cython — следующий претендент. Он представляет из себя над множество Python с поддержкой вызова C функций и определения C типов. А потому в простейшем случае add_mul_two функция на нём будет выглядеть аналогично Cpython. Однако обширный функционал не даётся просто так и в отличии от C версии результирующий файл будет почти 2000 строк CPython API вида:

__pyx_t_2 = PyNumber_Add(__pyx_v_a, __pyx_v_b); if (unlikely(!__pyx_t_2)) {     __pyx_filename = __pyx_f[0];     __pyx_lineno = 14; __pyx_clineno = __LINE__;     goto __pyx_L1_error; } __Pyx_GOTREF(__pyx_t_2); __pyx_t_3 = PyNumber_InPlaceAdd(__pyx_v_acc, __pyx_t_2); if (unlikely(!__pyx_t_3)) {     __pyx_filename = __pyx_f[0]; __pyx_lineno = 14;     __pyx_clineno = __LINE__; goto __pyx_L1_error; } 

Улучшить ситуацию в плане специфичности, но не по объёму кода, можно было бы написав, например, реализацию самой функции на C, а Cython использовать для определения обёртки.

int cadd_mul_two(int a, int b) {     int32_t acc = 0;     for (int i = 0; i < 1000; i++)         acc += a + b;     return acc; }  cdef extern from "example_func.h":     int cadd_mul_two(int, int)  def add_two(a, b):     return cadd_two(a, b)  cythonize("sample.pyx", sources=[ 'example_func.c' ]) 

Получая при этом практически идеальный вариант, но в таком случае уже нужно писать на C, Cython, Python.

 __pyx_t_1 = __Pyx_PyInt_As_int32_t(__pyx_v_a); if (unlikely((__pyx_t_1 == (int32_t)-1) && PyErr_Occurred())) {__pyx_filename = __pyx_f[0]; __pyx_ __pyx_t_2 = __Pyx_PyInt_As_int32_t(__pyx_v_b); if (unlikely((__pyx_t_2 == (int32_t)-1) && PyErr_Occurred())) {__pyx_filename = __pyx_f[0]; __pyx_ __pyx_t_3 = __Pyx_PyInt_From_int32_t(cadd_two(__pyx_t_1, __pyx_t_2)); if (unlikely(!__pyx_t_3)) {__pyx_filename = __pyx_f[0]; __pyx_lineno = 8; _  
Rust

Для того, чтобы сделать модуль на Rust, нужно всего лишь объявить extern функцию c no_mangle,

#[no_mangle] pub extern fn initexample() {     unsafe {         Py_InitModule4_64(&SAMPLE[0] as *const _,             &METHODS[0] as *const _,             0 as *const _,             0,             PYTHON_API_VERSION);     }; } 

и описать типы:

type PyCFunction = unsafe extern "C"     fn (slf: *mut isize, args: *mut isize) -> *mut isize;  #[repr(C)] struct PyMethodDef {     pub ml_name: *const i8,     pub ml_meth: Option<PyCFunction>,     pub ml_flags: i32,     pub ml_doc: *const i8, } unsafe impl Sync for PyMethodDef { } 

Также, как и в C, нужно объявить PyMethod:

lazy_static! {     static ref METHODS: Vec = { vec![         PyMethodDef {             ml_name: &ADD_MUL_TWO[0] as *const _,             ml_meth: Some(add_mul_two),         },         ...     ] }; } 

Из-за того, что в CPython много вызовов C API, придётся написать ещё и такое:

#[link(name="python2.7")] extern {     fn Py_InitModule4_64(name: *const i8,         methods: *const PyMethodDef,         doc: *const i8, s: isize, apiver: usize) -> *mut isize;     fn PyArg_ParseTuple(arg1: *mut isize,         arg2: *const i8, ...) -> isize;     fn Py_BuildValue(arg1: *const i8, ...) -> *mut isize; } 

Зато в итоге мы получим вот такую вот красивую функцию:

#[allow(unused_variables)] unsafe extern "C" fn add_mul_two(slf: *mut isize,         args: *mut isize) -> *mut isize {     let mut a: i32 = 0;     let mut b: i32 = 0;     if PyArg_ParseTuple(args,             &II_ARGS[0] as *const _,             &a as *const i32, &b as *const i32) == 0 {         return 0 as *mut _;     }     let mut acc: i32 = 0;     for i in 0..1000 { acc += a + b; }     Py_BuildValue(&I_ARGS[0] as *const _, acc) } 

Или при желании

let acc: i32 = (0..).take(1000)                 .map(|_| a + b)                 .fold(0, |acc, x| acc + x); 

Эта функция так же будет компилироваться две машинные инструкции:

__ZN7add_mul_two20h391818698d43ab0ffcaE: ... callq   0x7a002      ## symbol stub for: _PyArg_ParseTuple testq   %rax, %rax je      0x14e3 movl    -0x8(%rbp), %eax addl    -0x4(%rbp), %eax imull   $0x3e8, %eax, %esi      ## imm = 0x3E8 leaq    _ref5540(%rip), %rdi    ## literal pool for: "h" ... 

Минусы у такого подхода следующие:

  • только CPython API 2.7 и если нужно так же Python 3, то придётся много кода продублировать
  • если попытаться сократить размер бинарника за счёт no_std, то кода будет её больше
  • в том числе т.к. Ряд структур данных в Rust отличаются от C. Так например Rust использует паскаль-строки и для взаимодействия с C придётся использовать что-то подобное std::ffi::CString

Но к счастью есть замечательный проект rust-cpython, который не только уже описал все необходимые CpythonAPI но и предоставляет к ним высокоуровневые абстракции и при этом поддерживает как Python 2.x, так и 3.x. Код получается примерно таким:

[package] name = "example" version = "0.1.0" [lib] name = "example" crate-type = ["dylib"]  [dependencies] interpolate_idents = "0.0.9"  [dependencies.cpython] version = "0.0.5" default-features = false features = ["python27-sys"] 
#![feature(slice_patterns)] #![feature(plugin)] #![plugin(interpolate_idents)]  #[macro_use] extern crate cpython;   use cpython::{PyObject, PyResult, Python, PyTuple, PyDict, ToPyObject, PythonObject};   fn add_two(py: Python, args: &PyTuple, _: Option<&PyDict>) -> PyResult<PyObject> {     match args.as_slice() {         [ref a_obj, ref b_obj] => {             let a = a_obj.extract::<i32>(py).unwrap();             let b = b_obj.extract::<i32>(py).unwrap();             let mut acc:i32 = 0;              for _ in 0..1000 {                 acc += a + b;             }              Ok(acc.to_py_object(py).into_object())         },         _ => Ok(py.None())     } } 
py_module_initializer!(example, |py, module| {     try!(module.add(py, "add_two", py_fn!(add_two)));     Ok(()) }); 

Здесь используется nightly Rust по сути только для sclice_pattens и PyTuple.as_slice.
Зато, как мне кажется, Rust, в такой ситуации, предлагает решение с мощьными высокоуровневыми абстракциями, возможностью тонкай настройки алгоритмов и структур данных, эффективный и прогнозируемый результат оптимизаций. Т.е. выглядит достойной альтернативой другим инструментам.

Код примеров, использованный в статье, можно посмотреть здесь.

Bibliography

1: Simon Peyton Jones, Adventure with Types in Haskell — Simon Peyton Jones (Lecture 2), 2014, youtu.be/brE_dyedGm0?t=536
2: Kevin Modzelewski, 2015/11/10 Pyston Meetup, 2015, www.youtube.com/watch?v=NdB9XoBg5zI
3: Martin Fowler, Yet Another OptimizationArticle, 2002, martinfowler.com/ieeeSoftware/yetOptimization.pdf
4: Raymond Hettinger, Transforming Code into Beautiful, Idiomatic Python, 2013, www.youtube.com/watch?v=OSGv2VnC0go

ссылка на оригинал статьи https://habrahabr.ru/post/279561/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *