Python Support

Developers are able to integrate Python into certain SRTC components using the facilities of Pybind11 and some additional helpers provided by the RTC Toolkit itself. Pybind11 is a powerful tool to interface Python and C++ together. It can be used either to embed C++ extensions into Python or to embed a Python interpreter into C++ code. In the case of SRTC components, we are predominantly dealing with the later case of embedding a Python interpreter to execute algorithms coded in Python. Any custom SRTC components that are instantiated with the RtcComponentMain entry-point function can be easily embed such an interpreter.

Since most of the complexities to interface Python and C++ together are already handled by Pybind11, the only additional facilities provided on top of Pybind11 by the RTC Toolkit are a helper class to setup the embedded Python interpreter, and Python bindings for C++ vector and matrix buffer classes to avoid unnecessary memory copies. In addition, guidelines are provided for using Pybind11 inside SRTC components. These guidelines and facilities are described in the following subsections.

Guidelines

These guidelines provide canonical and best practices for integrating an embedded Python interpreter using Pybind11 and executing Python code within SRTC components.

Interpreter Instantiation

There should only be one Python interpreter instantiated in a SRTC component at a time. The interpreter’s lifetime must outlive any and all calls to the Python interpreter in the process, i.e. any calls to the Pybind11 API or calls to objects accessed through bindings created with Pybind11.

Since SRTC components are multi-threaded and we may want to call Python code in various component states from different threads, the best is to instantiate the interpreter in the main thread, using the PythonInterpreter helper class, see section Embedded Python Interpreter.

Note

Pybind11 provides a pybind11::scoped_interpreter class for instantiating the Python interpreter. However, it is better to use PythonInterpreter, which actually uses pybind11::scoped_interpreter under the hood, but it also performs the additional setup needed for the multi-threaded case, such as releasing the GIL for other threads to be able to execute Python code.

Always Acquire The GIL

The Python interpreter’s Global Interpreter Lock (GIL) must be acquired using the pybind11::gil_scoped_acquire helper class before any calls to the Python interpreter are made. If this is not done consistently, this will lead to race conditions and undefined behaviour.

For example, in any method or function where calls to the Pybind11 API or calls to Python objects are being made, we should have the following pattern:

void SomeClass::SomeMethod() {
    namespace py = pybind11;
    CallCppCode();
    {
        py::gil_scoped_acquire gil;
        // ... Make calls to the Python interpreter ...
        py::eval(...)
    }
    MoreCppCode();
}

Instantiation of pybind11::gil_scoped_acquire should be the first line in the code block. Using a scope, i.e. the additional { ... } braces, makes sure to release the GIL as early as possible, allowing other threads to execute Python code while the thread running SomeMethod() continues executing C++ code in MoreCppCode() in the above example.

Python Exceptions

Python exceptions must be caught and handled or translated to non-Python exceptions whenever the Python interpreter is called. Python exceptions should not be leaked into the component framework, since the framework does not attempt to catch or handle these. Only exceptions from C++ are being caught and handled by the framework. Leaking Python exceptions should be treated as undefined behaviour.

The reason for the component framework not explicitly handling Python exceptions is that it would need to lock the GIL to do so properly. This would force an implicit build-time and link-time dependency on Pybind11 and Python for all SRTC components, even those that are C++ only and do not intend to call any Python code.

If Python exceptions might be generated by Python code, then the best pattern to use on the C++ side is adding a try...catch block similar to the following:

namespace py = pybind11;
try {
    py::gil_scoped_acquire gil;
    // ... Make calls to the Python interpreter ...
} catch (const py::error_already_set& error) {
    CII_THROW(MyCppException, error.what());
}

This also allows converting Python exceptions to appropriate C++ exceptions. In the above example this is MyCppException, which is assumed to inherit from RtctkException. However, the developer is free to choose how the exceptions are mapped between Python and C++.

Python Code As Modules

The canonical approach for dealing with the Python code is to have it available as a normal Python module that can be imported into the Python interpreter, i.e. it should be importable with the import statement in the standalone Python interpreter.

In the C++ code the Python module should then be imported with pybind11::module::import. The appropriate object or function to invoke in the Python module’s API should then be accessed and the invocation made. The setup code, which imports the module and accesses the object to be invoked, can be moved to some early stage of the SRTC component’s lifecycle, so that this does not happen multiple times unnecessarily. For example, this could be done in the business logic constructor or the ActivityInitialising method that implements the Init command.

Assuming there is a Python module prepared called mymodule with the following declared Python function:

def python_function(arg1, foobar=None):
    result = 0
    # ... Execute more Python code and set result ...
    return result

The following code pattern could be used to fetch and setup the objects to invoke python_function from the module:

namespace py = pybind11;
try {
    py::gil_scoped_acquire gil;
    m_module = py::module::import("mymodule");
    m_python_function = m_module.attr("python_function");
} catch (const py::error_already_set& error) {
    CII_THROW(MyCppException, error.what());
}

Here we assume that m_module and m_python_function are attributes of the business logic object. Assigning these to pybind11::object attributes makes sure the reference counts for m_module and m_python_function are incremented and Python does not attempt to garbage collect these too early. This way these Python objects outlive any invocations to the m_python_function function.

Then in the method where the invocation to m_python_function should be made, use the following code pattern:

namespace py = pybind11;
using py::literals::_a;
int result;
try {
    py::gil_scoped_acquire gil;
    py::object arg1 = ...;  // Prepare the Python input arguments.
    py::object arg2 = ...;
    auto pyresult = m_python_function(arg1, "foobar"_a=arg2);
    // ... Use pyresult while the GIL is locked ...
    result = pyresult.cast<int>();
} catch (const py::error_already_set& error) {
    CII_THROW(MyCppException, error.what());
}
// ... Use result in C++ code, but not pyresult,
//     because we no longer have the GIL ...

Note

To allow for easy swapping of Python modules the names of the modules and functions can also be retrieved from the Runtime Configuration Repository. This may provide additional flexibility and may eliminate the need for recompilation.

If this strategy is used then one should also consider using the pybind11::module::reload method to reload the module with the Python code to be executed in the SRTC component.

Embedded Python Interpreter

Instantiation of the embedded Python interpreter should be done at the beginning of the RtcComponentMain function using the rtctk::componentFramework::PythonInterpreter class. PythonInterpreter is a RAII helper class that is effectively equivalent to the following:

namespace py = pybind11;
py::scoped_interpreter interpreter;
// ... Import embedded modules ...
py::gil_scoped_release release_gil;

The benefit of PythonInterpreter is that it turns all the necessary setup needed for SRTC components using the embedded interpreter into a one-liner.

PythonInterpreter is provided by the rtctkServicesPython library. Therefore, Python based SRTC components should add this as a dependency and link against this library. The PythonInterpreter class is then used as indicated in the following example:

#include <rtctk/componentFramework/pythonInterpreter.hpp>
...
using rtctk::componentFramework::PythonInterpreter;
...
void RtcComponentMain(const Args& args) {
    PythonInterpreter interpreter;
    ...
    RunAsRtcComponent<BusinessLogic>(args);
}

Note

It is possible to pass additional arguments to the constructor for PythonInterpreter that may be useful for specialised configurations of the embedded interpreter. See the constructor API documentation for details.

Provided Python Bindings

The RTC Toolkit provides Python bindings as embedded Python modules that make it possible to pass vectors and matrices loaded from the Runtime Configuration Repository between C++ and Python code without internal data being copied. The embedded modules are part of the rtctkServicesPython library.

To use these bindings add rtctkServicesPython as a dependency to the component and link against this library. Before invoking any Python code that tries to use std::vector or MatrixBuffer objects from an adapter implementing RepositoryIf, the bindings need to be set up accordingly. This is done automatically by PythonInterpreter. Otherwise, the setup can be done by invoking the methods LoadVectorBufferPyBinds and LoadMatrixBufferPyBinds, depending if the bindings for vector or matrix data is needed. One needs to include the headers and invoke the load methods as indicated below:

#include <rtctk/componentFramework/vectorPy.hpp>
#include <rtctk/componentFramework/matrixBufferPy.hpp>
...
using rtctk::componentFramework::LoadVectorBufferPyBinds;
using rtctk::componentFramework::LoadMatrixBufferPyBinds;
...
auto py_vector_binding_module = LoadVectorBufferPyBinds();
auto py_matrix_binding_module = LoadMatrixBufferPyBinds();

It is important to keep the py_vector_binding_module and py_matrix_binding_module objects alive as long as Python code attempts to handle objects of type std::vector or MatrixBuffer.

With the bindings loaded, Pybind11 will automatically expose objects of type std::vector and MatrixBuffer to Python code using Python’s buffer protocol, which allows Python code to access the data directly, without an intermediate copy. For example, consider the following invocation code in C++:

namespace py = pybind11;
RuntimeRepoIf& repo = ...;
py::object py_module, py_function;
...
{
    py::gil_scoped_acquire gil;
    py_module = py::module::import("mymodule");
    py_function = py_module.attr("myfunction");
}
...
std::vector<float> vector = repo.GetDataPoint<std::vector<float>>("/vector"_dppath);
MatrixBuffer<float> matrix = repo.GetDataPoint<MatrixBuffer<float>>("/matrix"_dppath);
{
    py::gil_scoped_acquire gil;
    py_function(&vector, &matrix);
}

Note

To avoid a memory copy, it is important to pass the std::vector and MatrixBuffer objects by reference and not by value to the Python function being called.

On the Python side, one can create a Numpy ndarray view into the vector and matrix buffers, i.e. wrap the buffers, as indicated in the following example:

import numpy
...
def myfunction(vector, matrix):
    np_vector = numpy.array(vector, copy=False)
    np_matrix = numpy.array(matrix, copy=False)
    # ... Use the wrapped ndarray objects np_vector and np_matrix ...

The np_vector and np_matrix objects in the above example are then usable as normal and will not copy memory, unless a Numpy API call is made that forces a memory copy, e.g. a change of data type or the size of the array.

Limitations and Known Issues

  • The version of Pybind11 used in the ELT DevEnv does not currently support booleans.

  • Currently the RTC Toolkit only supports no-copy passing of data in std::vector and MatrixBuffer formats, see Supported Data Types.