Aujourd'hui, je voudrais aborder le sujet de l'intégration de Python dans C ++.
Tout a commencé par un appel d'un ami à deux heures du matin, qui s'est plaint: "Nous avons une production en charge ..." Dans la conversation, il s'est avéré que le code de production avait été écrit en utilisant ipyparallel (un package Python qui permet le calcul parallèle et distribué) pour calculer le modèle et obtenir des résultats en ligne. Nous avons décidé de comprendre l'architecture d'ipyparallel et d'effectuer un profilage sous charge.
Il est immédiatement devenu clair que tous les modules de ce package ont été parfaitement conçus, mais la plupart du temps est consacré au réseautage, à l'analyse json et à d'autres actions intermédiaires.
Après une étude détaillée d'ipyparallel, il s'est avéré que la bibliothèque entière se compose de deux modules interactifs:
- Ipcontroler, qui est responsable du contrôle et de l'ordonnancement des tâches,
- Engine, qui est l'exécuteur du code.
Une fonctionnalité intéressante s'est avérée être que ces modules interagissent via pyzmq. Grâce à la bonne architecture du moteur, nous avons réussi à remplacer l'implémentation réseau par notre solution basée sur cppzmq. Ce remplacement ouvre une portée de développement infinie: la contrepartie peut être écrite dans la partie C ++ de l'application.
Cela a rendu les pools de moteurs théoriquement encore plus rapides, mais n'a toujours pas résolu le problème de l'intégration des bibliothèques dans le code Python. Si vous devez trop en faire pour intégrer votre bibliothèque, alors une telle solution ne sera pas demandée et restera sur les tablettes. La question restait de savoir comment intégrer nativement nos développements dans le code moteur actuel.
Nous avions besoin de critères raisonnables pour comprendre quelle approche adopter: facilité de développement, déclaration d'API uniquement dans C ++, pas de wrappers supplémentaires à l'intérieur de Python, ou utilisation native de toute la puissance des bibliothèques. Et afin de ne pas se tromper dans les façons natives (et pas si) de faire glisser le code C ++ en Python, nous avons fait un peu de recherche. Début 2019, quatre moyens populaires d'étendre Python pouvaient être trouvés sur Internet:
- Ctypes
- CFFI
- Cython
- API CPython
Nous avons examiné toutes les options d'intégration.
1. Ctypes
Ctypes est une interface de fonction étrangère qui vous permet de charger des bibliothèques dynamiques qui exportent une interface C. Avec lui, vous pouvez utiliser des bibliothèques C de Python, par exemple, libev, libpq.
Par exemple, il existe une bibliothèque écrite en C ++ avec une interface:
extern "C"
{
Foo* Foo_new();
void Foo_bar(Foo* foo);
}
Nous y écrivons un wrapper:
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)
Nous tirons des conclusions:
- Incapacité d'interagir avec l'API de l'interpréteur. Ctypes est un moyen d'interagir avec les bibliothèques C du côté Python, mais il ne fournit pas un moyen pour le code C / C ++ d'interagir avec Python.
- Exportation d'une interface de style C. Les types peuvent interagir avec les bibliothèques ABI dans ce style, mais tout autre langage doit exporter ses variables, fonctions, méthodes via un wrapper C.
- Le besoin d'écrire des wrappers. Ils doivent être écrits à la fois du côté code C ++ pour la compatibilité ABI avec C, et du côté Python pour réduire la quantité de code standard.
types ne nous convient pas, nous essayons la méthode suivante - CFFI.
2. CFFI
CFFI est similaire à Ctypes mais possède quelques fonctionnalités supplémentaires. Démontrons un exemple avec la même bibliothèque:
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)
Nous tirons des conclusions:
CFFI a toujours les mêmes inconvénients, sauf que les wrappers deviennent un peu plus gros, puisqu'il faut dire à la bibliothèque la définition de son interface. CFFI n'est pas non plus adapté, passons à la méthode suivante - Cython.
3. Cython
Cython est un langage de programmation sub / meta qui vous permet d'écrire des extensions dans un mélange de C / C ++ et de Python et de charger le résultat sous forme de bibliothèque dynamique. Cette fois, il existe une bibliothèque écrite en C ++ et ayant une 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
Ensuite, nous définissons cette interface en langage 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)
Et nous y écrivons un wrapper:
# 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
Nous pouvons maintenant utiliser cette classe à partir du code Python normal:
import rect
x0, y0, x1, y1 = 1, 2, 3, 4
rect_obj = rect.PyRectangle(x0, y0, x1, y1)
print(dir(rect_obj))
Nous tirons des conclusions:
- Lorsque vous utilisez Cython, vous devez toujours écrire du code wrapper côté C ++, mais vous n'avez plus besoin d'exporter l'interface de style C.
- Vous ne pouvez toujours pas interagir avec l'interprète.
Le dernier moyen reste - l'API CPython. Nous essayons.
4. API CPython
API CPython - API qui vous permet de développer des modules pour l'interpréteur Python en C ++. Votre meilleur pari est pybind11, une bibliothèque C ++ de haut niveau qui facilite l'utilisation de l'API CPython. Avec son aide, vous pouvez facilement exporter des fonctions, des classes, convertir des données entre la mémoire python et la mémoire native en C ++.
Alors, prenons le code de l'exemple précédent et écrivons-y un wrapper:
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);
}
Nous avons écrit le wrapper, il doit maintenant être compilé dans une bibliothèque binaire. Nous avons besoin de deux choses: un système de construction et un gestionnaire de paquets. Prenons CMake et Conan à ces fins, respectivement.
Pour que la compilation sur Conan fonctionne, vous devez installer Conan lui-même de manière appropriée:
pip3 install conan cmake
et enregistrez des référentiels supplémentaires:
conan remote add bincrafters https://api.bintray.com/conan/bincrafters/public-conan
conan remote add cyberduckninja https://api.bintray.com/conan/cyberduckninja/conan
Décrivons les dépendances du projet pour la bibliothèque pybind dans le fichier conanfile.txt:
[requires]
pybind11/2.3.0@conan/stable
[generators]
cmake
Ajoutons le fichier CMake. Faites attention à l'intégration incluse avec Conan - lorsque CMake est exécuté, la commande conan install sera exécutée, qui installe les dépendances et génère des variables CMake avec des informations sur les dépendances:
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}
)
Toutes les préparations sont terminées, rassemblons:
cmake . -DCMAKE_BUILD_TYPE=Release
cmake --build . --parallel 2
Nous tirons des conclusions:
- Nous avons reçu la bibliothèque binaire assemblée, qui peut être ensuite chargée dans l'interpréteur Python par ses moyens.
- Il est devenu beaucoup plus facile d'exporter du code en Python par rapport aux méthodes ci-dessus, et le code d'encapsulation est devenu plus compact et écrit dans le même langage.
L'une des fonctionnalités de cpython / pybind11 est de charger, d'obtenir ou d'exécuter une fonction à partir du runtime python pendant le runtime C ++ et vice versa.
Jetons un coup d'œil à un exemple simple:
#include <pybind11/embed.h> //
namespace py = pybind11;
int main() {
py::scoped_interpreter guard{}; // python vm
py::print("Hello, World!"); // Hello, World!
}
En combinant la possibilité d'incorporer un interpréteur python dans une application C ++ et le moteur de modules Python, nous avons proposé une approche intéressante dans laquelle le code du moteur ipyparalles ne ressent pas la substitution de composants. Pour les applications, nous avons choisi une architecture dans laquelle les cycles de vie et d'événements commencent dans du code C ++, et alors seulement l'interpréteur Python démarre dans le même processus.
Pour comprendre, examinons le fonctionnement de notre approche:
#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;
}
Dans l'exemple ci-dessus, le module pyrectangle est transmis à l'interpréteur Python et mis à disposition pour l'importation en tant que rect. Démontrons avec un exemple que rien n'a changé pour le code "personnalisé":
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
Cette approche se caractérise par une grande flexibilité et de nombreux points de personnalisation, ainsi que par la possibilité de gérer légalement la mémoire Python. Mais il y a des problèmes - le coût d'une erreur est beaucoup plus élevé que dans d'autres options, et vous devez être conscient de ce risque.
Ainsi, ctypes et CFFI ne nous conviennent pas en raison de la nécessité d'exporter des interfaces de bibliothèque de style C, et également en raison de la nécessité d'écrire des wrappers côté Python et, finalement, d'utiliser l'API CPython si l'incorporation est nécessaire. Cython est exempt de sa faille d'exportation, mais conserve toutes les autres failles. Pybind11 ne prend en charge que l'incorporation et l'écriture de wrappers côté C ++. Il dispose également de capacités étendues pour manipuler des structures de données et appeler des fonctions et des méthodes Python. En conséquence, nous avons opté pour pybind11 en tant que wrapper C ++ de haut niveau pour l'API CPython.
En combinant l'utilisation de python incorporé dans une application C ++ avec le mécanisme de module pour une transmission rapide des données et la réutilisation de la base de code du moteur ipyparallel, nous avons obtenu un rocketjoe_engine. Il est identique en mécanique à l'original et fonctionne plus rapidement en réduisant les castes pour les interactions réseau, le traitement json et d'autres actions intermédiaires. Maintenant, cela permet à mon ami de garder des charges sur la production, pour laquelle j'ai reçu la première étoile dans le projet GitHub .
Conan, Russian Python Week C++, Python Conan .
Russian Python Week 4 — 14 17 . , Python: Python- . , Python.
.