Serialisation#

Added in version 0.12.0.

Starting with version 0.12.0, heyoka supports serialisation via the Boost.Serialization library. Before showing a couple of examples of serialisation in action, we need to emphasise a couple of very important caveats:

  • currently, heyoka supports serialisation only to/from binary archives;

  • the serialisation format is platform-dependent and it also depends on the versions of heyoka, LLVM and Boost. Thus, the serialised representation of heyoka objects is not portable across platforms or across different versions of heyoka or its dependencies. Do not try to use the serialised representation of heyoka objects as an exchange format, as this will result in undefined behaviour;

  • heyoka does not make any attempt to validate the state of a deserialised object. Thus, a maliciously-crafted binary archive could be used to crash heyoka or even execute arbitrary code on the machine.

The last point is particularly important: because the integrator objects contain blobs of binary code, a maliciously-crafted archive can easily be used to execute arbitrary code on the host machine.

Let us repeat again these warnings for visibility:

Warning

Do not load heyoka objects from untrusted archives, as this could lead to the execution of malicious code.

Do not use heyoka archives as a data exchange format, and make sure that all the archives you load from have been produced with the same versions of heyoka, LLVM and Boost that you are currently using.

With these warnings out of the way, let us proceed to the code.

A simple example#

In order to illustrate the (de)serialisation workflow, we will be using our good old friend, the simple pendulum. We begin as usual with the definition of the symbolic variables and the integrator object:

    // Create the symbolic variables x and v.
    auto [x, v] = make_vars("x", "v");

    // Create the integrator object
    // in double precision.
    auto ta = taylor_adaptive<double>{// Definition of the ODE system:
                                      // x' = v
                                      // v' = -9.8 * sin(x)
                                      {prime(x) = v, prime(v) = -9.8 * sin(x)},
                                      // Initial conditions
                                      // for x and v.
                                      {0.05, 0.025}};

We then integrate for a few timesteps, so that the time coordinate and the state will evolve from their initial values:

    // Integrate for a few timesteps.
    for (auto i = 0; i < 5; ++i) {
        ta.step();
    }

    // Print out the time and state at the
    // end of the integration.
    std::cout << "ta time (original)     : " << ta.get_time() << '\n';
    std::cout << "ta state (original)    : [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";
ta time (original)     : 1.04348
ta state (original)    : [-0.0506049, -0.00537327]

Let us then proceed to the serialisation of the integrator object into a binary archive. For the purpose of this tutorial we will be writing the archive into a string stream, but the same code would work for serialisation into a file object or into any other standard C++ stream:

    // Serialise ta into a string stream.
    std::stringstream ss;
    {
        boost::archive::binary_oarchive oa(ss);
        oa << ta;
    }

Note how we bracket the lifetime of the binary archive oa by placing it into a separate scope: the invocation of the destructor of oa at the end of the block will ensure that the data is written to the stream ss. Please refer to the Boost.Serialization docs for more information about the serialisation API.

After having serialised ta into ss, we reset ta to its initial state, and we print the time and the state vector to screen in order to confirm that ta has indeed been reset:

    // Reset ta to the initial state.
    ta = taylor_adaptive<double>{{prime(x) = v, prime(v) = -9.8 * sin(x)}, {0.05, 0.025}};

    std::cout << "ta time (after reset)  : " << ta.get_time() << '\n';
    std::cout << "ta state (after reset) : [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";
ta time (after reset)  : 0
ta state (after reset) : [0.05, 0.025]

We are now ready to recover the serialised representation of ta from the string stream. After loading ta from the archive, we will print the time and the state vector to screen to confirm that, indeed, the previous state of ta has been correctly recovered:

    // Restore the serialised representation of ta.
    {
        boost::archive::binary_iarchive ia(ss);
        ia >> ta;
    }

    // Print out the time and state after
    // deserialisation.
    std::cout << "ta time (from archive) : " << ta.get_time() << '\n';
    std::cout << "ta state (from archive): [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";
ta time (from archive) : 1.04348
ta state (from archive): [-0.0506049, -0.00537327]

Full code listing#

#include <iostream>
#include <sstream>

#include <heyoka/heyoka.hpp>

using namespace heyoka;

int main()
{
    // Create the symbolic variables x and v.
    auto [x, v] = make_vars("x", "v");

    // Create the integrator object
    // in double precision.
    auto ta = taylor_adaptive<double>{// Definition of the ODE system:
                                      // x' = v
                                      // v' = -9.8 * sin(x)
                                      {prime(x) = v, prime(v) = -9.8 * sin(x)},
                                      // Initial conditions
                                      // for x and v.
                                      {0.05, 0.025}};

    // Integrate for a few timesteps.
    for (auto i = 0; i < 5; ++i) {
        ta.step();
    }

    // Print out the time and state at the
    // end of the integration.
    std::cout << "ta time (original)     : " << ta.get_time() << '\n';
    std::cout << "ta state (original)    : [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";

    // Serialise ta into a string stream.
    std::stringstream ss;
    {
        boost::archive::binary_oarchive oa(ss);
        oa << ta;
    }

    // Reset ta to the initial state.
    ta = taylor_adaptive<double>{{prime(x) = v, prime(v) = -9.8 * sin(x)}, {0.05, 0.025}};

    std::cout << "ta time (after reset)  : " << ta.get_time() << '\n';
    std::cout << "ta state (after reset) : [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";

    // Restore the serialised representation of ta.
    {
        boost::archive::binary_iarchive ia(ss);
        ia >> ta;
    }

    // Print out the time and state after
    // deserialisation.
    std::cout << "ta time (from archive) : " << ta.get_time() << '\n';
    std::cout << "ta state (from archive): [" << ta.get_state()[0] << ", " << ta.get_state()[1] << "]\n\n";
}

Serialising event callbacks#

The serialisation of integrator objects in the presence of events needs special attention, because the event callbacks are internally implemented as type-erased classes on top of a traditional object-oriented hierarchy. In other words, an integrator stores the callbacks as pointers to a base class, and in this situation the Boost.Serialization library needs some extra assistance in order to work correctly.

There are two steps that need to be taken in order to enable the serialisation of event callbacks:

  • make the callback itself serialisable. The Boost.Serialization docs explain in detail how to add serialisation capabilities to a class. Note that, in order to be serialisable, a callback must be implemented as a function object - it is not possible to serialise function pointers or lambdas;

  • register the callback in the serialisation system via the invocation of a macro (see below).

Let us see a concrete example. We begin with the definition of a simple callback class:

// The callback function object.
struct my_callback {
    // Leave the callback body empty.
    void operator()(taylor_adaptive<double> &, double, int) const {}

private:
    // Make the callback serialisable.
    friend class boost::serialization::access;
    template <typename Archive>
    void serialize(Archive &, unsigned)
    {
    }
};

This trivial callback function object my_callback is meant to be used in a non-terminal event. In order to make the callback serialisable we add a member function template called serialize() which, in this specific case, also does not perform any action because the callback has no state. If the callback contained data members, we would need to serialise them one by one - see the Boost.Serialization docs for details about adding serialisation capabilities to a class.

After having added serialisation capabilities to our my_callback, we need to register it in heyoka’s serialisation system. This is accomplished through the use of the HEYOKA_S11N_CALLABLE_EXPORT() macro:

// Register the callback in the serialisation system.
HEYOKA_S11N_CALLABLE_EXPORT(my_callback, void, taylor_adaptive<double> &, double, int)

The HEYOKA_S11N_CALLABLE_EXPORT() macro takes as first input argument the name of the class being registered (my_callback in this case). The remaining arguments are the signature of the callback: void is the return type, taylor_adaptive<double> &, double and int its argument types. Note that this macro must be invoked in the root namespace and all arguments should be spelled out as fully-qualified names (in this example we can avoid the extra typing due to the using namespace heyoka statement).

The my_callback class is now ready to be (de)serialised. Let us see a simple example, again based on the simple pendulum:

    // Create the symbolic variables x and v.
    auto [x, v] = make_vars("x", "v");

    // Create the integrator object
    // in double precision.
    auto ta = taylor_adaptive<double>{// Definition of the ODE system:
                                      // x' = v
                                      // v' = -9.8 * sin(x)
                                      {prime(x) = v, prime(v) = -9.8 * sin(x)},
                                      // Initial conditions
                                      // for x and v.
                                      {0.05, 0.025},
                                      // Add the non-terminal event v = 0, using
                                      // the callback defined above.
                                      kw::nt_events = {nt_event<double>(v, my_callback{})}};

    std::cout << "Number of events (original)    : " << ta.get_nt_events().size() << '\n';

    // Serialise ta into a string stream.
    std::stringstream ss;
    {
        boost::archive::binary_oarchive oa(ss);
        oa << ta;
    }

    // Reset ta to an integrator without events.
    ta = taylor_adaptive<double>{{prime(x) = v, prime(v) = -9.8 * sin(x)}, {0.05, 0.025}};

    // Restore the serialised representation of ta.
    {
        boost::archive::binary_iarchive ia(ss);
        ia >> ta;
    }

    std::cout << "Number of events (from archive): " << ta.get_nt_events().size() << '\n';
Number of events (original)    : 1
Number of events (from archive): 1

The screen output indeed confirms that the event callback was correctly (de)serialised. If we had not used the HEYOKA_S11N_CALLABLE_EXPORT() macro to register the callback, a runtime exception would have been raised during the serialisation of the integrator object.

Full code listing#

#include <iostream>
#include <sstream>

#include <heyoka/heyoka.hpp>

using namespace heyoka;

// The callback function object.
struct my_callback {
    // Leave the callback body empty.
    void operator()(taylor_adaptive<double> &, double, int) const {}

private:
    // Make the callback serialisable.
    friend class boost::serialization::access;
    template <typename Archive>
    void serialize(Archive &, unsigned)
    {
    }
};

// Register the callback in the serialisation system.
HEYOKA_S11N_CALLABLE_EXPORT(my_callback, void, taylor_adaptive<double> &, double, int)

int main()
{
    // Create the symbolic variables x and v.
    auto [x, v] = make_vars("x", "v");

    // Create the integrator object
    // in double precision.
    auto ta = taylor_adaptive<double>{// Definition of the ODE system:
                                      // x' = v
                                      // v' = -9.8 * sin(x)
                                      {prime(x) = v, prime(v) = -9.8 * sin(x)},
                                      // Initial conditions
                                      // for x and v.
                                      {0.05, 0.025},
                                      // Add the non-terminal event v = 0, using
                                      // the callback defined above.
                                      kw::nt_events = {nt_event<double>(v, my_callback{})}};

    std::cout << "Number of events (original)    : " << ta.get_nt_events().size() << '\n';

    // Serialise ta into a string stream.
    std::stringstream ss;
    {
        boost::archive::binary_oarchive oa(ss);
        oa << ta;
    }

    // Reset ta to an integrator without events.
    ta = taylor_adaptive<double>{{prime(x) = v, prime(v) = -9.8 * sin(x)}, {0.05, 0.025}};

    // Restore the serialised representation of ta.
    {
        boost::archive::binary_iarchive ia(ss);
        ia >> ta;
    }

    std::cout << "Number of events (from archive): " << ta.get_nt_events().size() << '\n';
}