开发一个python模块以提高生产效率

你好!我代表非营利组织Cyber​​DuckNinja的开发团队。我们创建并支持一系列产品,这些产品使开发后端应用程序和机器学习服务变得更加容易。



今天,我想谈谈将Python集成到C ++中的话题。







一切始于早上两点的朋友打来的电话,他抱怨道:“我们的产品正在承受负荷……”在对话中,事实证明生产代码是使用ipyparallel(允许并行和分布式计算的Python包)编写的,用于计算模型并在线获取结果。我们决定了解ipyparallel的体系结构并在负载下执行性能分析。



显而易见,该软件包的所有模块都经过了精心设计,但大部分时间都花在了网络,json解析和其他中间操作上。

通过对ipyparallel的详细研究,发现整个库包含两个交互模块:



  • ipcontroler,负责任务的控制和调度,
  • 引擎,它是代码的执行者。


事实证明,一个不错的功能是这些模块通过pyzmq进行交互。得益于良好的引擎架构,我们设法用基于cppzmq的解决方案代替了网络实现。这种替换打开了无限的开发范围:对应项可以用应用程序的C ++部分编写。



从理论上讲,这使引擎池变得更快,但是仍然不能解决将库集成到Python代码中的问题。如果您需要做太多工作来集成您的库,那么将不需要这种解决方案,而是将其保留在架子上。问题仍然是如何将我们的开发内容自然地集成到当前的引擎代码库中。



我们需要一些合理的标准来了解采用哪种方法:易于开发,仅在C ++中声明API,在Python中没有其他包装程序或对库的全部功能进行本机使用。为了避免在Python中拖拉C ++代码的本机(或不如此)方式感到困惑,我们做了一些研究。在2019年初,互联网上可以找到四种流行的扩展Python的方式:



  1. C类型
  2. CFFI
  3. 赛顿
  4. CPython API


我们已经考虑了所有集成选项。



1. Ctypes



Ctypes是一个外函数接口,允许您加载导出C接口的动态库。它可以用于使用Python中的C库,例如libev,libpq。



例如,有一个用C ++编写的带有接口的库:



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


我们为其编写一个包装器:



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)


我们得出结论:



  1. 无法与解释器API交互。Ctypes是与Python端的C库进行交互的一种方法,但它没有为C / C ++代码与Python进行交互提供一种方法。
  2. 导出C样式的接口。类型可以以这种样式与ABI库进行交互,但是任何其他语言都必须通过C包装器导出其变量,函数和方法。
  3. 需要编写包装器。为了与ABI兼容,必须在代码的C ++端以及在Python端都编写它们,以减少样板代码的数量。


types不适合我们,我们尝试下一种方法-CFFI。



2. CFFI



CFFI与Ctypes类似,但具有一些附加功能。让我们用相同的库演示一个例子:



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)


我们得出以下结论:



CFFI仍然具有相同的缺点,除了包装器变得更胖之外,因为您需要告诉库其接口的定义。CFFI也不合适,让我们继续下一个方法-Cython。



3. Cython



Cython是一种子/元编程语言,它允许您使用C / C ++和Python的混合物编写扩展并将结果加载为动态库。这次有一个用C ++编写并具有接口的库:



#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


然后我们用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)


然后我们为它编写一个包装器:



# 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


现在我们可以从常规Python代码中使用此类:



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


我们得出结论:



  1. 使用Cython时,您仍然必须在C ++端编写包装器代码,但不再需要导出C样式的接口。
  2. 您仍然无法与解释器进行交互。


最后一种方法仍然是-CPython API。我们尝试一下。



4. CPython API



CPython API-允许您为C ++中的Python解释器开发模块的API。最好的选择是pybind11,这是一个高级C ++库,可以轻松使用CPython API。借助它的帮助,您可以轻松导出函数,类,并在C ++中的python内存和本机内存之间转换数据。



因此,让我们从上一个示例中获取代码,并为其编写一个包装器:



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


我们编写了包装器,现在需要将其编译为二进制库。我们需要两件事:构建系统和包管理器。让我们分别将CMake和Conan用于这些目的。



为了使Conan上的构建正常工作,您需要以合适的方式安装Conan本身:



pip3 install conan cmake


并注册其他存储库:



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


让我们在conanfile.txt文件中描述pybind库的项目依赖关系:



[requires]
pybind11/2.3.0@conan/stable

[generators]
cmake


让我们添加CMake文件。请注意与Conan包含的集成-执行CMake时,将运行conan install命令,安装依赖项并使用依赖项数据生成CMake变量:



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


所有准备工作都已完成,让我们收集一下:



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


我们得出结论:



  1. 我们收到了汇编的二进制库,随后可以通过其方式将其加载到Python解释器中。
  2. 与上述方法相比,将代码导出到Python变得容易得多,并且包装代码变得更加紧凑,并使用相同的语言编写。


cpython / pybind11功能之一是在C ++运行时中从python运行时加载,获取或执行功能,反之亦然。



让我们看一个简单的例子:



#include <pybind11/embed.h>  //     

namespace py = pybind11;

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


通过将在C ++应用程序中嵌入python解释器的功能与Python模块引擎相结合,我们提出了一种有趣的方法,即ipyparalles引擎代码不会感觉到组件的替代。对于应用程序,我们选择了一种架构,其中生命周期和事件周期以C ++代码开始,然后Python解释器才在同一过程中启动。



为了理解,让我们看一下我们的方法是如何工作的:



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


在上面的示例中,pyrectangle模块被转发到Python解释器中,并可以作为rect导入。让我们以一个示例来演示“自定义”代码没有任何变化:



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


这种方法的特点是高度的灵活性和许多定制点,并且具有合法管理Python内存的能力。但是存在问题-错误的代价比其他选择要高得多,并且您需要意识到这一风险。



因此,由于需要导出C风格的库接口,并且由于需要在Python端编写包装器,并且最终需要使用CPython API,因此ctypes和CFFI不适合我们。Cython没有出口缺陷,但保留了所有其他缺陷。Pybind11仅支持在C ++方面嵌入和编写包装器。它还具有处理数据结构以及调用Python函数和方法的广泛功能。结果,我们选择了pybind11作为CPython API的高级C ++包装器。



通过将在C ++应用程序中嵌入python的用法与用于快速数据转发的模块机制相结合,并重用了ipyparallel引擎代码库,我们得到了rocketjoe_engine。它的机制与原始机制相同,并且通过减少网络交互,json处理和其他中间操作的种姓来加快工作速度。现在,这使我的朋友可以继续进行生产,为此我在GitHub项目中获得了第一颗星



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



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

.



All Articles