Entwicklung eines Python-Moduls, um die Produktion glücklich zu machen

Hallo! Ich vertrete das Entwicklungsteam bei der gemeinnützigen CyberDuckNinja. Wir entwickeln und unterstützen eine ganze Familie von Produkten, die die Entwicklung von Backend-Anwendungen und maschinellen Lerndiensten erleichtern.



Heute möchte ich auf das Thema der Integration von Python in C ++ eingehen.







Alles begann mit einem Anruf eines Freundes um zwei Uhr morgens, der sich beschwerte: "Wir haben Produktion unter Last ..." Im Gespräch stellte sich heraus, dass der Produktionscode mit ipyparallel (einem Python-Paket, das parallele und verteilte Berechnungen ermöglicht) zur Berechnung des Modells geschrieben wurde und Ergebnisse online erhalten. Wir haben uns entschlossen, die Architektur von ipyparallel zu verstehen und Profilerstellung unter Last durchzuführen.



Es wurde sofort klar, dass alle Module dieses Pakets perfekt gestaltet sind, aber die meiste Zeit wird für Netzwerke, JSON-Parsing und andere Zwischenaktionen aufgewendet.

Bei einer detaillierten Untersuchung von ipyparallel stellte sich heraus, dass die gesamte Bibliothek aus zwei interagierenden Modulen besteht:



  • Ipcontroler, der für die Steuerung und Planung von Aufgaben verantwortlich ist,
  • Engine, die den Code ausführt.


Es stellte sich heraus, dass diese Module über pyzmq interagieren. Dank der guten Engine-Architektur ist es uns gelungen, die Netzwerkimplementierung durch unsere auf cppzmq basierende Lösung zu ersetzen. Dieser Ersatz eröffnet endlosen Entwicklungsspielraum: Das Gegenstück kann im C ++ - Teil der Anwendung geschrieben werden.



Dies machte die Engine-Pools theoretisch noch schneller, löste jedoch immer noch nicht das Problem der Integration von Bibliotheken in den Python-Code. Wenn Sie zu viel tun müssen, um Ihre Bibliothek zu integrieren, ist eine solche Lösung nicht gefragt und bleibt im Regal. Es blieb die Frage, wie wir unsere Entwicklungen nativ in die aktuelle Codebasis der Motoren integrieren können.



Wir brauchten einige vernünftige Kriterien, um zu verstehen, welchen Ansatz wir wählen sollten: einfache Entwicklung, API-Deklaration nur in C ++, keine zusätzlichen Wrapper in Python oder native Nutzung der vollen Leistungsfähigkeit von Bibliotheken. Und um nicht in den nativen (und nicht so) Methoden zum Ziehen von C ++ - Code in Python verwirrt zu werden, haben wir ein wenig recherchiert. Zu Beginn des Jahres 2019 gab es im Internet vier beliebte Möglichkeiten zur Erweiterung von Python:



  1. C-Typen
  2. CFFI
  3. Cython
  4. CPython API


Wir haben alle Integrationsoptionen berücksichtigt.



1. C-Typen



Ctypes ist eine Fremdfunktionsschnittstelle, mit der Sie dynamische Bibliotheken laden können, die eine C-Schnittstelle exportieren. Damit können Sie C-Bibliotheken aus Python verwenden, z. B. libev, libpq.



Zum Beispiel gibt es eine in C ++ geschriebene Bibliothek mit einer Schnittstelle:



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


Wir schreiben einen Wrapper dazu:



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)


Wir ziehen Schlussfolgerungen:



  1. Unfähigkeit, mit der Interpreter-API zu interagieren. Ctypes ist eine Möglichkeit, mit C-Bibliotheken auf der Python-Seite zu interagieren, bietet jedoch keine Möglichkeit für C / C ++ - Code, mit Python zu interagieren.
  2. Exportieren einer C-Schnittstelle. Typen können in diesem Stil mit ABI-Bibliotheken interagieren, aber jede andere Sprache muss ihre Variablen, Funktionen und Methoden über einen C-Wrapper exportieren.
  3. Die Notwendigkeit, Wrapper zu schreiben. Sie müssen sowohl auf der C ++ - Codeseite für die ABI-Kompatibilität mit C als auch auf der Python-Seite geschrieben werden, um die Menge an Boilerplate-Code zu reduzieren.


Typen passen nicht zu uns, wir versuchen die nächste Methode - CFFI.



2. CFFI



CFFI ähnelt Ctypes, verfügt jedoch über einige zusätzliche Funktionen. Lassen Sie uns ein Beispiel mit derselben Bibliothek demonstrieren:



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)


Wir ziehen Schlussfolgerungen:



CFFI hat immer noch die gleichen Nachteile, außer dass die Wrapper etwas dicker werden, da Sie der Bibliothek die Definition ihrer Schnittstelle mitteilen müssen. CFFI ist auch nicht geeignet. Fahren wir mit der nächsten Methode fort - Cython.



3. Cython



Cython ist eine Sub- / Meta-Programmiersprache, mit der Sie Erweiterungen in einer Mischung aus C / C ++ und Python schreiben und das Ergebnis als dynamische Bibliothek laden können. Dieses Mal gibt es eine in C ++ geschriebene Bibliothek mit einer Schnittstelle:



#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


Dann definieren wir diese Schnittstelle in Cython-Sprache:



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)


Und wir schreiben einen Wrapper dazu:



# 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


Jetzt können wir diese Klasse aus regulärem Python-Code verwenden:



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


Wir ziehen Schlussfolgerungen:



  1. Wenn Sie Cython verwenden, müssen Sie immer noch Wrapper-Code auf der C ++ - Seite schreiben, aber Sie müssen die Schnittstelle im C-Stil nicht mehr exportieren.
  2. Sie können immer noch nicht mit dem Dolmetscher interagieren.


Der letzte Weg bleibt - CPython API. Wir versuchen es.



4. CPython API



CPython API - API, mit der Sie Module für den Python-Interpreter in C ++ entwickeln können. Ihre beste Wahl ist pybind11, eine C ++ - Bibliothek auf hoher Ebene, mit der Sie bequem mit der CPython-API arbeiten können. Mit seiner Hilfe können Sie problemlos Funktionen und Klassen exportieren und Daten zwischen Python-Speicher und nativem Speicher in C ++ konvertieren.



Nehmen wir also den Code aus dem vorherigen Beispiel und schreiben einen Wrapper darauf:



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


Wir haben den Wrapper geschrieben, jetzt muss er in eine Binärbibliothek kompiliert werden. Wir brauchen zwei Dinge: ein Build-System und einen Paketmanager. Nehmen wir CMake und Conan für diese Zwecke.



Damit der Build auf Conan funktioniert, müssen Sie Conan selbst auf geeignete Weise installieren:



pip3 install conan cmake


und registrieren Sie zusätzliche Repositorys:



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


Beschreiben wir die Projektabhängigkeiten für die pybind-Bibliothek in der Datei conanfile.txt:



[requires]
pybind11/2.3.0@conan/stable

[generators]
cmake


Fügen wir die CMake-Datei hinzu. Beachten Sie die enthaltene Integration mit Conan. Wenn CMake ausgeführt wird, wird der Befehl conan install ausgeführt, der die Abhängigkeiten installiert und CMake-Variablen mit den Abhängigkeitsdaten generiert:



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


Alle Vorbereitungen sind abgeschlossen, sammeln wir:



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


Wir ziehen Schlussfolgerungen:



  1. Wir haben die zusammengestellte Binärbibliothek erhalten, die anschließend mit ihren Mitteln in den Python-Interpreter geladen werden kann.
  2. Das Exportieren von Code in Python ist im Vergleich zu den oben genannten Methoden viel einfacher geworden, und der Wrapping-Code ist kompakter und in derselben Sprache geschrieben.


Eine Funktion von cpython / pybind11 ist das Laden, Abrufen oder Ausführen einer Funktion aus der Python-Laufzeit in C ++ - Laufzeit und umgekehrt.



Schauen wir uns ein einfaches Beispiel an:



#include <pybind11/embed.h>  //     

namespace py = pybind11;

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


Durch die Kombination der Fähigkeit, einen Python-Interpreter in eine C ++ - Anwendung und die Python-Modul-Engine einzubetten, haben wir einen interessanten Ansatz gefunden, bei dem der ipyparalles-Engine-Code die Substitution von Komponenten nicht spürt. Für Anwendungen haben wir eine Architektur ausgewählt, bei der Lebens- und Ereigniszyklen in C ++ - Code beginnen und erst dann der Python-Interpreter innerhalb desselben Prozesses startet.



Schauen wir uns zum Verständnis an, wie unser Ansatz funktioniert:



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


Im obigen Beispiel wird das Pyrectangle-Modul an den Python-Interpreter weitergeleitet und als Rect für den Import verfügbar gemacht. Lassen Sie uns anhand eines Beispiels zeigen, dass sich am "benutzerdefinierten" Code nichts geändert hat:



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


Dieser Ansatz zeichnet sich durch hohe Flexibilität und viele Anpassungsmöglichkeiten sowie die Möglichkeit aus, Python-Speicher legal zu verwalten. Es gibt jedoch Probleme - die Kosten eines Fehlers sind viel höher als bei anderen Optionen, und Sie müssen sich dieses Risikos bewusst sein.



Daher sind ctypes und CFFI für uns nicht geeignet, da Bibliotheksschnittstellen im C-Stil exportiert werden müssen und auch Wrapper auf der Python-Seite geschrieben und letztendlich die CPython-API verwendet werden müssen, wenn eine Einbettung erforderlich ist. Cython ist frei von Exportfehlern, behält jedoch alle anderen Mängel bei. Pybind11 unterstützt nur das Einbetten und Schreiben von Wrappern auf der C ++ - Seite. Es verfügt außerdem über umfangreiche Funktionen zum Bearbeiten von Datenstrukturen und zum Aufrufen von Python-Funktionen und -Methoden. Aus diesem Grund haben wir uns für pybind11 als C ++ - Wrapper auf hoher Ebene für die CPython-API entschieden.



Durch die Kombination der Verwendung von eingebetteter Python in einer C ++ - Anwendung mit dem Modulmechanismus für die schnelle Datenweiterleitung und die Wiederverwendung der ipyparallelen Engine-Codebasis haben wir eine rocketjoe_engine erhalten. Es ist in der Mechanik identisch mit dem Original und arbeitet schneller, indem Kasten für Netzwerkinteraktionen, JSON-Verarbeitung und andere Zwischenaktionen reduziert werden. Dies ermöglicht meinem Freund nun, die Produktion aufrechtzuerhalten, für die ich den ersten Stern im GitHub-Projekt erhalten habe .



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



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

.



All Articles