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:
- Ctypes
- CFFI
- Cython
- 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:
- 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.
- 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.
- 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:
- 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.
- 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:
- Recebemos a biblioteca binária montada, que pode ser posteriormente carregada no interpretador Python por meio dela.
- 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.
.