Всем привет! Я представляю команду разработчиков некоммерческой организации CyberDuckNinja. Мы создаём и поддерживаем целое семейство продуктов, которые позволяют облегчить разработку backend-приложений и сервисов машинного обучения.

Сегодня хотелось бы затронуть тему интеграции Python в C++.



Все началось со звонка друга в два часа ночи, который пожаловался: «У нас под нагрузкой ложится продакшн ...» В разговоре выяснилось, что код продакшена написан с использованием ipyparallel (пакет Python, который позволяет производить параллельные и распределённые вычисления) для обсчета модели и получения результатов в режиме онлайн. Мы решили разобраться в архитектуре ipyparallel и провести профайлинг под нагрузкой.

Сразу стало понятно, что все модули данного пакета спроектированы отлично, но большая часть времени уходит на сетевые взаимодействия, парсинг json и другие промежуточные действия.
При подробном изучении ipyparallel выяснилось, что вся библиотека состоит из двух взаимодействующих модулей:

  • Ipcontroler, который отвечает за контроль и планирование задач,
  • Engine, который является исполнителем кода.

Приятной особенностью оказалось, что эти модули взаимодействуют через pyzmq. Благодаря хорошей архитектуре engine удалось заменить реализацию сетевого взаимодействия на наше решение, построенное на cppzmq. Эта замена открывает бесконечный простор для разработки: ответную часть можно написать в C++ части приложения.

Это позволило сделать пулы engine теоретически еще шустрее, но всё-таки не решило задачи с интеграцией библиотек внутрь Python-кода. Если для интеграции своей библиотеки придется сделать слишком много, то такое решение не будет востребовано и останется на полке. Оставался вопрос, как нативно внедрить наши наработки в текущую кодовую базу engine.

Нам нужны были какие-то разумные критерии, чтобы понять, какой выбрать подход: лёгкость разработки, декларирование API только внутри C++, отсутствие дополнительных обёрток внутри Python или нативность использования всей мощности библиотек. А чтобы не запутаться в нативных (и не очень) способах протаскивания С++ кода в Python, мы сделали небольшой ресёрч. На момент начала 2019 года в интернете можно было найти четыре популярных способа расширения Python:

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

Мы рассмотрели все варианты интеграции.

1. Ctypes


Ctypes — это Foreign Function Interface, позволяющий загружать динамические библиотеки, которые экспортируют интерфейс на языке 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 является способом взаимодействия с Cи библиотеками на стороне Python, но не предоставляет способ взаимодействия C/C++ кода с Python.
  2. Экспортирование интерфейса в стиле Cи. Сtypes может взаимодействовать с ABI библиотеками этом в стиле, но любой другой язык должен экспортировать свои переменные, функции, методы через Cи-обёртку.
  3. Необходимость написание обёрток. Их приходится писать как на стороне C++ кода для совместимости ABI с Си, так и на стороне Python, чтобы уменьшить количество boilerplate кода.

С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 — API, которое позволяет разрабатывать модули для интерпретатора Python на C++. Лучше всего использовать pybind11, высокоуровневую библиотеку на С++, которая делает работу с CPython API удобной. С её помощью можно легко экспортировать функции, классы, преобразовать данные между памятью 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 — это загрузка, получение или выполнение функции из runtime python, находясь в рантайме С++ и наоборот.

Давайте посмотрим на простом примере:

#include <pybind11/embed.h>  // подключаем  работу с интерпретатором

namespace py = pybind11;

int main() {
    py::scoped_interpreter guard{}; // инициализируем python vm
    py::print("Hello, World!"); // печатаем  на консоль Hello, World!
}

Скомбинировав возможность встраивать интерпретатор python в приложение на С++ и механизм Python модулей, мы придумали интересный подход, при помощи которого код ipyparalles engine не чувствует подмену компонентов. Для приложений мы выбрали архитектуру, в которой жизненные и событийные циклы начинаются в коде на 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. Но есть проблемы — стоимость ошибки гораздо выше, чем в других вариантах, и о таком риске надо знать.

Таким образом, ctypes и CFFI для нас не подходят из-за необходимости экспорта интерфейсов библиотеки в стиле Cи, а также из-за необходимости писать обёртки на стороне Python и, в конечном итоге, использования CPython API, если необходимо встраивание. Cython лишён недостатка с экспортом, но сохраняет все остальные недостатки. Pybind11 поддерживает возможность встраивания и написания обёрток только на стороне С++. Также он имеет широкие возможности для управления структурами данных и вызова функций и методов Python. В итоге мы остановились на pybind11 как на высокоуровневой обертке на C++ для CPython API.

Скомбинировав применение embed python внутри C++ приложения с механизмом модулей для быстрых пробросов данных и переиспользовав кодовую базу ipyparallel engine, мы получили rocketjoe_engine. Он идентичен по механикам с оригиналом и работает шустрее за счет уменьшения кастов на сетевые взаимодействия, обработку json и другие промежуточные действия. Теперь это позволяет держать нагрузки на продакшене у моего друга, за что я и получил первую звездочку в проекте GitHub.

Если вы заинтересовались пакетным менеджером Conan, то узнать о нем больше можно на предстоящей конференции Russian Python Week в докладе про пакетирование проектов на C++, а также про особенности разработки на Python и самого пакетного менеджера Conan вместе с его инфраструктурой.

Russian Python Week стартует уже через 4 дня — она будет с 14 по 17 сентября. Программа готова, и ещё на конференции пройдёт первый Чемпионат России по Python: можно проверить уровень своего мастерства и получить независимую оценку своих скиллов среди Python-разработчиков всей страны. Участие бесплатное, но надо знать стандартную библиотеку Python.
Билеты на саму конференцию здесь.