Desarrollando un módulo de Python para hacer feliz la producción

¡Hola! Represento al equipo de desarrollo de la organización sin fines de lucro CyberDuckNinja. Creamos y respaldamos toda una familia de productos que facilitan el desarrollo de aplicaciones backend y servicios de aprendizaje automático.



Hoy me gustaría tocar el tema de la integración de Python en C ++.







Todo comenzó con una llamada de un amigo a las dos de la madrugada, quien se quejó: "Tenemos producción bajo carga ..." En la conversación resultó que el código de producción fue escrito usando ipyparallel (un paquete de Python que permite computación paralela y distribuida) para calcular el modelo y obtener resultados en línea. Decidimos comprender la arquitectura de ipyparallel y realizar la creación de perfiles bajo carga.



Inmediatamente quedó claro que todos los módulos de este paquete están diseñados a la perfección, pero la mayor parte del tiempo se dedica a la creación de redes, el análisis json y otras acciones intermedias.

Tras un estudio detallado de ipyparallel, resultó que toda la biblioteca consta de dos módulos interactivos:



  • Ipcontroler, que se encarga del control y la programación de tareas,
  • Engine, que es el ejecutor del código.


Una buena característica resultó ser que estos módulos interactúan a través de pyzmq. Gracias a la buena arquitectura del motor, logramos reemplazar la implementación de redes con nuestra solución construida sobre cppzmq. Este reemplazo abre un alcance de desarrollo infinito: la contraparte se puede escribir en la parte C ++ de la aplicación.



Esto hizo que los grupos de motores fueran teóricamente aún más rápidos, pero aún no resolvió el problema de integrar bibliotecas en el código Python. Si tiene que hacer demasiado para integrar su biblioteca, tal solución no tendrá demanda y permanecerá en el estante. La pregunta seguía siendo cómo integrar de forma nativa nuestros desarrollos en la base de código del motor actual.



Necesitábamos algunos criterios razonables para comprender qué enfoque elegir: facilidad de desarrollo, declaración de API solo dentro de C ++, sin envoltorios adicionales dentro de Python o uso nativo de todo el poder de las bibliotecas. Y para no confundirnos con las formas nativas (y no tan) de arrastrar el código C ++ en Python, hicimos una pequeña investigación. A principios de 2019, se podían encontrar cuatro formas populares de extender Python en Internet:



  1. Ctypes
  2. CFFI
  3. Cython
  4. API de CPython


Hemos considerado todas las opciones de integración.



1. Ctypes



Ctypes es una interfaz de función ajena que le permite cargar bibliotecas dinámicas que exportan una interfaz C. Con él, puede usar bibliotecas C de Python, por ejemplo, libev, libpq.



Por ejemplo, hay una biblioteca escrita en C ++ con una interfaz:



extern "C"
{
    Foo* Foo_new();
    void Foo_bar(Foo* foo);
}


Le escribimos un envoltorio:



import ctypes

lib = ctypes.cdll.LoadLibrary('./libfoo.so')

class Foo:
    def __init__(self) -> None:
        super().__init__()

        lib.Foo_new.argtypes = []
        lib.Foo_new.restype = ctypes.c_void_p
        lib.Foo_bar.argtypes = []
        lib.Foo_bar.restype = ctypes.c_void_p

        self.obj = lib.Foo_new()

    def bar(self) -> None:
        lib.Foo_bar(self.obj)


Sacamos conclusiones:



  1. Incapacidad para interactuar con la API del intérprete. Ctypes es una forma de interactuar con las bibliotecas C en el lado de Python, pero no proporciona una forma para que el código C / C ++ interactúe con Python.
  2. Exportación de una interfaz de estilo C. Los tipos pueden interactuar con las bibliotecas ABI en este estilo, pero cualquier otro lenguaje debe exportar sus variables, funciones y métodos a través de un contenedor C.
  3. La necesidad de escribir envoltorios. Deben escribirse tanto en el lado C ++ del código para la compatibilidad de ABI con C como en el lado de Python para reducir la cantidad de código repetitivo.


types no nos conviene, probamos el siguiente método: CFFI.



2. CFFI



CFFI es similar a Ctypes, pero tiene algunas características adicionales. Demostremos un ejemplo con la misma biblioteca:



import cffi

ffi = cffi.FFI()

ffi.cdef("""
    Foo* Foo_new();
    void Foo_bar(Foo* foo);
""")

lib = ffi.dlopen("./libfoo.so")

class Foo:
    def __init__(self) -> None:
        super().__init__()

        self.obj = lib.Foo_new()

    def bar(self) -> None:
        lib.Foo_bar(self.obj)






Sacamos conclusiones: CFFI todavía tiene las mismas desventajas, excepto que las envolturas se vuelven un poco más gruesas, ya que necesita decirle a la biblioteca la definición de su interfaz. CFFI tampoco es adecuado, pasemos al siguiente método: Cython.



3. Cython



Cython es un lenguaje de programación sub / meta que le permite escribir extensiones en una mezcla de C / C ++ y Python y cargar el resultado como una biblioteca dinámica. Esta vez hay una biblioteca escrita en C ++ y que tiene una interfaz:



#ifndef RECTANGLE_H
#define RECTANGLE_H

namespace shapes {
    class Rectangle {
        public:
            int x0, y0, x1, y1;
            Rectangle();
            Rectangle(int x0, int y0, int x1, int y1);
            ~Rectangle();
            int getArea();
            void getSize(int* width, int* height);
            void move(int dx, int dy);
    };
}

#endif


Luego definimos esta interfaz en lenguaje Cython:



cdef extern from "Rectangle.cpp":
    pass

# Declare the class with cdef
cdef extern from "Rectangle.h" namespace "shapes":
    cdef cppclass Rectangle:
        Rectangle() except +
        Rectangle(int, int, int, int) except +
        int x0, y0, x1, y1
        int getArea()
        void getSize(int* width, int* height)
        void move(int, int)


Y le escribimos un envoltorio:



# distutils: language = c++

from Rectangle cimport Rectangle

cdef class PyRectangle:
    cdef Rectangle c_rect

    def __cinit__(self, int x0, int y0, int x1, int y1):
        self.c_rect = Rectangle(x0, y0, x1, y1)

    def get_area(self):
        return self.c_rect.getArea()

    def get_size(self):
        cdef int width, height
        self.c_rect.getSize(&width, &height)
        return width, height

    def move(self, dx, dy):
        self.c_rect.move(dx, dy)

    # Attribute access
    @property
    def x0(self):
        return self.c_rect.x0

    @x0.setter
    def x0(self, x0):
        self.c_rect.x0 = x0

    # Attribute access
    @property
    def x1(self):
        return self.c_rect.x1

    @x1.setter
    def x1(self, x1):
        self.c_rect.x1 = x1

    # Attribute access
    @property
    def y0(self):
        return self.c_rect.y0

    @y0.setter
    def y0(self, y0):
        self.c_rect.y0 = y0

    # Attribute access
    @property
    def y1(self):
        return self.c_rect.y1

    @y1.setter
    def y1(self, y1):
        self.c_rect.y1 = y1


Ahora podemos usar esta clase del código Python normal:



import rect
x0, y0, x1, y1 = 1, 2, 3, 4
rect_obj = rect.PyRectangle(x0, y0, x1, y1)
print(dir(rect_obj))


Sacamos conclusiones:



  1. Al usar Cython, todavía tiene que escribir código contenedor en el lado C ++, pero ya no necesita exportar la interfaz de estilo C.
  2. Aún no puede interactuar con el intérprete.


La última forma sigue siendo: CPython API. Lo intentamos.



4. API de CPython



API CPython: API que le permite desarrollar módulos para el intérprete de Python en C ++. Su mejor opción es pybind11, una biblioteca de C ++ de alto nivel que hace que trabajar con la API de CPython sea conveniente. Con su ayuda, puede exportar fácilmente funciones, clases, convertir datos entre la memoria de Python y la memoria nativa en C ++.



Entonces, tomemos el código del ejemplo anterior y escribamos un contenedor en él:



PYBIND11_MODULE(rect, m) {
    py::class_<Rectangle>(m, "PyRectangle")
        .def(py::init<>())
        .def(py::init<int, int, int, int>())
        .def("getArea", &Rectangle::getArea)
        .def("getSize", [](Rectangle &rect) -> std::tuple<int, int> {
            int width, height;

            rect.getSize(&width, &height);

            return std::make_tuple(width, height);
        })
        .def("move", &Rectangle::move)
        .def_readwrite("x0", &Rectangle::x0)
        .def_readwrite("x1", &Rectangle::x1)
        .def_readwrite("y0", &Rectangle::y0)
        .def_readwrite("y1", &Rectangle::y1);
}


Escribimos el contenedor, ahora debe compilarse en una biblioteca binaria. Necesitamos dos cosas: un sistema de compilación y un administrador de paquetes. Tomemos CMake y Conan para estos propósitos, respectivamente.



Para que la compilación en Conan funcione, debe instalar Conan de una manera adecuada:



pip3 install conan cmake


y registrar repositorios adicionales:



conan remote add bincrafters https://api.bintray.com/conan/bincrafters/public-conan
conan remote add cyberduckninja https://api.bintray.com/conan/cyberduckninja/conan


Describamos las dependencias del proyecto para la biblioteca pybind en el archivo conanfile.txt:



[requires]
pybind11/2.3.0@conan/stable

[generators]
cmake


Agreguemos el archivo CMake. Preste atención a la integración incluida con Conan: cuando se ejecuta CMake, se ejecutará el comando conan install, que instala las dependencias y genera variables de CMake con información sobre las dependencias:



cmake_minimum_required(VERSION 3.17)

set(project rectangle)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS OFF)

	if (NOT EXISTS "${CMAKE_BINARY_DIR}/conan.cmake")
    	message(STATUS "Downloading conan.cmake from https://github.com/conan-io/cmake-conan")
    	file(DOWNLOAD "https://raw.githubusercontent.com/conan-io/cmake-conan/v0.15/conan.cmake" "${CMAKE_BINARY_DIR}/conan.cmake")
	endif ()

	set(CONAN_SYSTEM_INCLUDES "On")

	include(${CMAKE_BINARY_DIR}/conan.cmake)

	conan_cmake_run(
        	CONANFILE conanfile.txt
        	BASIC_SETUP
        	BUILD missing
        	NO_OUTPUT_DIRS
	)

find_package(Python3 COMPONENTS Interpreter Development)
include_directories(${PYTHON_INCLUDE_DIRS})
include_directories(${Python3_INCLUDE_DIRS})
find_package(pybind11 REQUIRED)

pybind11_add_module(${PROJECT_NAME} main.cpp )

target_include_directories(
    	${PROJECT_NAME}
    	PRIVATE
    	${NUMPY_ROOT}/include
    	${PROJECT_SOURCE_DIR}/vendor/General_NetSDK_Eng_Linux64_IS_V3.051
    	${PROJECT_SOURCE_DIR}/vendor/ffmpeg4.2.1
)

target_link_libraries(
    	${PROJECT_NAME}
    	PRIVATE
    	${CONAN_LIBS}
)


Todos los preparativos están completos, recopilemos:



cmake . -DCMAKE_BUILD_TYPE=Release 
cmake --build . --parallel 2


Sacamos conclusiones:



  1. Recibimos la biblioteca binaria ensamblada, que posteriormente se puede cargar en el intérprete de Python por su medio.
  2. Se ha vuelto mucho más fácil exportar código en Python en comparación con los métodos anteriores, y el código envolvente se ha vuelto más compacto y está escrito en el mismo lenguaje.


Una de las características de cpython / pybind11 es cargar, obtener o ejecutar una función desde el tiempo de ejecución de Python mientras está en el tiempo de ejecución de C ++ y viceversa.



Echemos un vistazo a un ejemplo sencillo:



#include <pybind11/embed.h>  //     

namespace py = pybind11;

int main() {
    py::scoped_interpreter guard{}; //  python vm
    py::print("Hello, World!"); //     Hello, World!
}


Al combinar la capacidad de incrustar un intérprete de Python en una aplicación C ++ y el motor de módulos de Python, se nos ocurrió un enfoque interesante mediante el cual el código del motor de ipyparalles no siente la sustitución de componentes. Para las aplicaciones, elegimos una arquitectura en la que los ciclos de vida y eventos comienzan en código C ++, y solo entonces el intérprete de Python comienza dentro del mismo proceso.



Para entenderlo, echemos un vistazo a cómo funciona nuestro enfoque:



#include <pybind11/embed.h>

#include "pyrectangle.hpp" //  ++  rectangle

using namespace py::literals;
//            rectangle
constexpr static char init_script[] = R"__(
    import sys

    sys.modules['rect'] = rect
)__";
//             rectangle
constexpr static char load_script[] = R"__(
    import sys, os
    from importlib import import_module

    sys.path.insert(0, os.path.dirname(path))
    module_name, _ = os.path.splitext(path)
    import_module(os.path.basename(module_name))
)__";

int main() {
    py::scoped_interpreter guard; //  
    py::module pyrectangle("rect");    

    add_pyrectangle(pyrectangle); //  
    py::exec(init_script, py::globals(), py::dict("rect"_a = pyrectangle)); //        Python.
    py::exec(load_script, py::globals(), py::dict("path"_a = "main.py")); //  main.py

    return 0;
}


En el ejemplo anterior, el módulo pyrectangle se reenvía al intérprete de Python y está disponible para su importación como rect. Demostremos con un ejemplo que nada ha cambiado para el código "personalizado":



from pprint import pprint

from rect import PyRectangle

r = PyRectangle(0, 3, 5, 8)

pprint(r)

assert r.getArea() == 25

width, height = r.getSize()

assert width == 5 and height == 5


Este enfoque se caracteriza por una alta flexibilidad y muchos puntos de personalización, así como por la capacidad de administrar legalmente la memoria de Python. Pero hay problemas: el costo de un error es mucho más alto que en otras opciones y debe ser consciente de este riesgo.



Por lo tanto, ctypes y CFFI no son adecuados para nosotros debido a la necesidad de exportar interfaces de biblioteca de estilo C, y también debido a la necesidad de escribir envoltorios en el lado de Python y, en última instancia, usar la API de CPython si es necesario incrustar. Cython está libre de su defecto de exportación, pero conserva todos los demás defectos. Pybind11 solo admite la incrustación y escritura de envoltorios en el lado de C ++. También tiene amplias capacidades para manipular estructuras de datos y llamar a funciones y métodos de Python. Como resultado, nos decidimos por pybind11 como un contenedor de C ++ de alto nivel para la API de CPython.



Al combinar el uso de incrustar python dentro de una aplicación C ++ con el mecanismo del módulo para el reenvío rápido de datos y reutilizar el código base del motor ipyparallel, obtuvimos un rocketjoe_engine. Es idéntico en mecánica al original y funciona más rápido al reducir las castas para interacciones de red, procesamiento json y otras acciones intermedias. Ahora, esto le permite a mi amigo mantener cargas en producción, por lo que recibí la primera estrella en el proyecto GitHub .



Conan, Russian Python Week C++, Python Conan .



Russian Python Week 4 — 14 17 . , Python: Python- . , Python.

.



All Articles