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::vectorandMatrixBufferformats, see Supported Data Types.