Desenvolvendo um módulo Python para tornar a produção feliz

Olá! Eu represento a equipe de desenvolvimento da organização sem fins lucrativos CyberDuckNinja. Criamos e oferecemos suporte a toda uma família de produtos que facilita o desenvolvimento de aplicativos de back-end e serviços de aprendizado de máquina.



Hoje eu gostaria de tocar no tópico da integração do Python ao C ++.







Tudo começou com uma ligação de um amigo às duas da manhã, que reclamou: "Temos produção sob carga ..." Na conversa, descobriu-se que o código de produção foi escrito usando ipyparallel (um pacote Python que permite cálculos paralelos e distribuídos) para calcular o modelo e obter resultados online. Decidimos entender a arquitetura do ipyparallel e realizar a criação de perfil sob carga.



Ficou imediatamente claro que todos os módulos deste pacote são projetados perfeitamente, mas a maior parte do tempo é gasto em rede, análise json e outras ações intermediárias.

Após um estudo detalhado do ipyparallel, descobriu-se que toda a biblioteca consiste em dois módulos interativos:



  • Ipcontroler, que é responsável pelo controle e agendamento de tarefas,
  • Engine, que é o executor do código.


Um recurso interessante é que esses módulos interagem por meio do pyzmq. Graças à boa arquitetura do motor, conseguimos substituir a implementação de rede por nossa solução construída em cppzmq. Essa substituição abre um escopo de desenvolvimento infinito: a contraparte pode ser escrita na parte C ++ do aplicativo.



Isso tornou os pools de mecanismo teoricamente ainda mais rápidos, mas ainda não resolveu o problema de integração de bibliotecas no código Python. Se você tiver que fazer muito para integrar sua biblioteca, essa solução não terá demanda e permanecerá na prateleira. A questão permanecia como implementar nativamente nossos desenvolvimentos na base de código do mecanismo atual.



Precisávamos de alguns critérios razoáveis ​​para entender qual abordagem escolher: facilidade de desenvolvimento, declaração de API apenas dentro de C ++, sem wrappers adicionais dentro de Python ou uso nativo de todo o poder das bibliotecas. E para não se confundir com as maneiras nativas (e nem tanto) de arrastar pelo código C ++ em Python, pesquisamos um pouco. No início de 2019, quatro maneiras populares de estender o Python podiam ser encontradas na Internet:



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


Nós consideramos todas as opções de integração.



1. Ctypes



Ctypes é uma interface de função externa que permite carregar bibliotecas dinâmicas que exportam uma interface C. Com ele, você pode usar bibliotecas C de Python, por exemplo, libev, libpq.



Por exemplo, existe uma biblioteca escrita em C ++ com uma interface:



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


Nós escrevemos um wrapper para ele:



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)


Tiramos conclusões:



  1. Incapacidade de interagir com a API do intérprete. Ctypes é uma maneira de interagir com bibliotecas C no lado do Python, mas não fornece uma maneira para o código C / C ++ interagir com o Python.
  2. Exportando uma interface de estilo C. Os tipos podem interagir com as bibliotecas ABI neste estilo, mas qualquer outra linguagem deve exportar suas variáveis, funções e métodos por meio de um wrapper C.
  3. A necessidade de escrever wrappers. Eles devem ser escritos tanto no lado do código C ++ para compatibilidade ABI com C quanto no lado Python para reduzir a quantidade de código clichê.


Os tipos de não combinam conosco, tentamos o próximo método - CFFI.



2. CFFI



CFFI é semelhante a Ctypes, mas possui alguns recursos adicionais. Vamos demonstrar um exemplo com a mesma 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)


Tiramos conclusões:



CFFI ainda tem as mesmas desvantagens, exceto que os invólucros ficam um pouco mais gordos, já que você precisa informar à biblioteca a definição de sua interface. CFFI também não é adequado, vamos passar para o próximo método - Cython.



3. Cython



Cython é uma sub / meta linguagem de programação que permite escrever extensões em uma mistura de C / C ++ e Python e carregar o resultado como uma biblioteca dinâmica. Desta vez, existe uma biblioteca escrita em C ++ e possuindo uma interface:



#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


Em seguida, definimos esta interface na linguagem 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)


E escrevemos um wrapper para ele:



# 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


Agora podemos usar esta classe do código Python regular:



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


Tiramos conclusões:



  1. Ao usar o Cython, você ainda precisa escrever o código do wrapper no lado C ++, mas não precisa mais exportar a interface de estilo C.
  2. Você ainda não consegue interagir com o intérprete.


A última maneira permanece - API CPython. Nós tentamos.



4. API CPython



API CPython - API que permite desenvolver módulos para o interpretador Python em C ++. Sua melhor aposta é pybind11, uma biblioteca C ++ de alto nível que torna o trabalho com a API CPython conveniente. Com sua ajuda, você pode facilmente exportar funções, classes, converter dados entre a memória Python e a memória nativa em C ++.



Então, vamos pegar o código do exemplo anterior e escrever um wrapper nele:



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);
}


Escrevemos o wrapper, agora ele precisa ser compilado em uma biblioteca binária. Precisamos de duas coisas: um sistema de construção e um gerenciador de pacotes. Vamos usar CMake e Conan para esses fins, respectivamente.



Para fazer a construção no Conan funcionar, você precisa instalar o próprio Conan de uma maneira adequada:



pip3 install conan cmake


e registrar repositórios adicionais:



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


Vamos descrever as dependências do projeto para a biblioteca pybind no arquivo conanfile.txt:



[requires]
pybind11/2.3.0@conan/stable

[generators]
cmake


Vamos adicionar o arquivo CMake. Preste atenção à integração incluída com o Conan - quando CMake é executado, o comando conan install será executado, que instala as dependências e gera variáveis ​​CMake com informações sobre as dependências:



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 os preparativos estão completos, vamos coletar:



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


Tiramos conclusões:



  1. Recebemos a biblioteca binária montada, que pode ser posteriormente carregada no interpretador Python por meio dela.
  2. Ficou muito mais fácil exportar o código para Python em comparação com os métodos acima, e o código de empacotamento tornou-se mais compacto e escrito na mesma linguagem.


Um dos recursos do cpython / pybind11 é carregar, obter ou executar uma função do tempo de execução do python durante o tempo de execução do C ++ e vice-versa.



Vamos dar uma olhada em um exemplo simples:



#include <pybind11/embed.h>  //     

namespace py = pybind11;

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


Ao combinar a capacidade de incorporar um interpretador Python em um aplicativo C ++ e o mecanismo de módulos Python, criamos uma abordagem interessante em que o código do mecanismo ipyparalles não sente a substituição de componentes. Para os aplicativos, escolhemos uma arquitetura na qual os ciclos de vida e eventos começam no código C ++ e, somente então, o interpretador Python é iniciado dentro do mesmo processo.



Para entender, vamos dar uma olhada em como nossa abordagem funciona:



#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;
}


No exemplo acima, o módulo pirectângulo é encaminhado para o interpretador Python e disponibilizado para importação como rect. Vamos demonstrar com um exemplo que nada mudou para o 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


Esta abordagem é caracterizada por alta flexibilidade e muitos pontos de customização, bem como a capacidade de gerenciar legalmente a memória Python. Mas existem problemas - o custo de um erro é muito mais alto do que em outras opções e você precisa estar ciente desse risco.



Portanto, ctypes e CFFI não são adequados para nós devido à necessidade de exportar interfaces de biblioteca de estilo C e também devido à necessidade de escrever wrappers no lado Python e, em última análise, usar a API CPython se a incorporação for necessária. Cython está livre de sua falha de exportação, mas mantém todas as outras falhas. Pybind11 só suporta a incorporação e gravação de wrappers no lado C ++. Ele também possui recursos abrangentes para manipular estruturas de dados e chamar funções e métodos Python. Como resultado, optamos por pybind11 como um wrapper C ++ de alto nível para a API CPython.



Combinando o uso de embed python dentro de um aplicativo C ++ com o mecanismo de módulo para encaminhamento rápido de dados e reutilização da base de código do mecanismo ipyparallel, obtivemos um rocketjoe_engine. É idêntico ao original em mecânica e funciona mais rápido, reduzindo as castas para interações de rede, processamento json e outras ações intermediárias. Agora, isso permite que meu amigo continue com muita produção, pela qual recebi a primeira estrela no projeto GitHub .



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



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

.



All Articles