company_banner

Разработка python module, чтобы продакшн радовал

    Всем привет! Я представляю команду разработчиков некоммерческой организации 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.
    Билеты на саму конференцию здесь.
    Конференции Олега Бунина (Онтико)
    Конференции Олега Бунина

    Similar posts

    Comments 7

      +1

      pybind11 очень качественный инструмент, я всем про него рассказываю и советую. :)


      А есть ещё очень интересный проект cppyy: Automatic Python-C++ bindings
      Всё работает в рантайме на базе Cling.

        0
        интересный способ не знал про него, спасибо посмотрю.
        0

        ещё можно было включить в список Boost.Python

          0

          Boost.Python морально устарел, зависит от других библиотек Boost и требует сборки подключаемых библиотек (pybind11 header-only), имеет ограничения и архитектурные недостатки, при этом не обладает никакими преимуществами по сравнению с pybind11 кроме как поддержкой С++ стандартов до C++11.

          0
          А кодогенератор SWIG не рассматривали по той же причине что и ctypes — можно вызывать C-шный код из питона, но не наоборот?
            0
            про SWIG забыл. посмотрел щас на SWIG кажется что он недает крутых приимушеств перед остальными, но делает дополнительный шаг виде генирацию.
            0
            Я как-то общался с разработчиками одной системы, которые встраивали Lua в С++, у них был то ли Custom Build Step, то ли в рантайме — не помню за давностью лет — так вот, они парсили файл отладочной информации и генерировали на его основе информацию для компилятора.
            То есть всё что можно из крестов торчало в интерпретатор, оставляя реализацию плюсовую минималистичной, и вся движуха происходила из скриптов.
            Ни к чему не призываю, просто декларирую прикольную технику, подходящую только для своих, ибо стрелять в ноги можно по полной программе.

            Only users with full accounts can post comments. Log in, please.