Copyright 2023 Travis J. West, https://traviswest.ca, Input Devices and Music Interaction Laboratory (IDMIL), Centre for Interdisciplinary Research in Music Media and Technology (CIRMMT), McGill University, Montréal, Canada, and Univ. Lille, Inria, CNRS, Centrale Lille, UMR 9189 CRIStAL, F-59000 Lille, France
SPDX-License-Identifier: MIT
The runtime describes the expected behavior of the host platform of an assemblage of components. The runtime must first call every component's init
method, if it has one. Then, in an endless loop, each component's main subroutine must be called, in node-tree-order. Before main subroutines, external input subroutines should be called. After main subroutines, external output subroutines should be called. This sequence of external input, main, external output is termed as a tick. Between ticks, all of the clearable flag endpoints should be cleared appropriately: input flags should be cleared before beginning the tick, and output flags should be cleared after it has resolved. Plugins and throughpoints should be automatically propagated to components that need them by extracting them from the assemblage.
Design Rationale
Originally planned as further overloads of the the init
and activate
functions in the components concepts library, the runtime class below allows the plugins and throughpoints of components having them to be automatically extracted by type from a component container when calling the init
and main
methods of components. The decision was made to construct this as a class in order to ensure that the compiler would have the best chance of computing the argument extraction at compile time. It is a kind of ultimate binding that is meant to drive all other components, including binding components. The runtime is included in the concepts library so that components in the components library may make use of it to simplify their implementation using parts, although this is may be unwise at the time of writing due to compile time limitations of the runtime.
Example
The following example illustrates the main requirements of the runtime, including its flag clearing behavior, throughpoint and plugin propagation, and the order of subroutines.
testcomponent1_t
has an output bang that should be clear on entry to main every tick. It always activates this bang. testcomponent2_t
takes testcomponent1_t
's outputs via an input throughpoint, and the whole testcomponent1_t
as a plugin component, as declared by testcomponent2_t
's main subroutine arguments. It checks that testcomponent1_t
's output bang is correctly propagated through the tick, and sets its outputs depending on the state of testcomponent1_t
.
The test case verifies that subroutines are called in the expected order, and that flags and values propagate as expected.
template<string_literal str>
struct testcomponent1_t : name_<str>
{
struct inputs_t {
struct in1_t {
int value;
} in1;
} inputs;
struct outputs_t {
struct out1_t {
int value;
} out1;
bng<"bang out"> bang_out;
} outputs;
void init() { inputs.in1.value = 42; }
void main()
{
outputs.out1.value = inputs.in1.value + 1;
CHECK(false == (bool)outputs.bang_out);
outputs.bang_out();
CHECK(true == (bool)outputs.bang_out);
}
};
struct testcomponent2_t : name_<"tc2">
{
struct outputs_t {
struct out1_t {
int value;
} out1;
struct out2_t {
int value;
} out2;
} outputs;
void init() {};
void main(const testcomponent1_t<"tc1">::outputs_t& sources, testcomponent1_t<"tc1">& plugin)
{
CHECK(true == (bool)plugin.outputs.bang_out);
outputs.out1.value = sources.out1.value + 1;
outputs.out2.value = plugin.outputs.out1.value + 1;
}
};
struct components1_t
{
testcomponent1_t<"tc1"> tc1;
testcomponent2_t tc2;
};
struct components2_t
{
testcomponent1_t<"tc1"> tc1;
};
constinit components1_t components{};
constexpr auto runtime = Runtime{components};
TEST_CASE("sygaldry runtime calls")
{
runtime.init();
CHECK(runtime.container.tc1.inputs.in1.value == 42);
runtime.tick();
CHECK(false == (bool)runtime.container.tc1.outputs.bang_out);
CHECK(runtime.container.tc1.outputs.out1.value == 43);
CHECK(runtime.container.tc2.outputs.out1.value == 44);
CHECK(runtime.container.tc2.outputs.out2.value == 44);
}
constinit components2_t components2{};
constexpr auto runtime2 = Runtime{components2};
TEST_CASE("sygaldry runtime one component")
{
runtime2.init();
CHECK(runtime2.container.tc1.inputs.in1.value == 42);
runtime2.tick();
CHECK(false == (bool)runtime2.container.tc1.outputs.bang_out);
CHECK(runtime2.container.tc1.outputs.out1.value == 43);
}
Implementation
Extracting Plugins and Components
Arguably the trickiest aspect of the runtime's responsibilities is to recognize what arguments a component's subroutine requires, extract them from the assemblage, and apply them to the subroutine. We would like to ensure that this happens efficiently, meaning without runtime traversal of the component tree derived from the assemblage.
TEST_CASE("sygaldry example test")
{
components1_t components;
components.tc1.inputs.in1.value = 0;
components.tc1.outputs.bang_out();
activate(components.tc2, components);
CHECK(components.tc2.outputs.out1.value == 1);
}
We'll start by extracting the arguments needed to call just one subroutine (main
), and then generalise to include init
and eventually other subroutines.
Use a fold
My first attempt resembled the following. We define a template struct whose type parameters give the arguments a component needs for its main subroutine to be activated. In the activate
method of this structure, these type parameters are unpacked to into find
to locate the entities with the given types in the component tree. This runtime_impl
struct is then instantiated with the appropriate arguments using our function reflection facilities and a bit of template metaprogramming provided by mp11
.
template<typename ... Args>
struct runtime_impl
{
template <typename Component, typename ComponentContainer>
static void activate(Component& component, ComponentContainer& container)
{
if constexpr (requires {&Component::operator();})
component(find<Args>(container) ...);
else if constexpr (requires {&Component::main;})
component.main(find<Args>(container) ...);
}
};
template<typename Component, typename ComponentContainer>
void activate(Component& component, ComponentContainer& container)
{
using args = typename main_subroutine_reflection<Component>::arguments;
using runtime = boost::mp11::mp_rename<boost::mp11::mp_transform<std::remove_cvref_t, args>, runtime_impl>;
runtime::activate(component, container);
}
Traverse at compile time
Unfortunately, the compiler is unable to optimize away the traversal of the component tree implied by the multiple calls to find
in this implementation. We need some way of declaring this traversal constexpr
, or perhaps constinit
. This lead to the following implementation. Roughly the same metaprogramming pattern is used to enable the arguments of the function to be extracted, by expanding a parameter pack with a fold expression over calls to find
. However, instead of doing so in a function call context, a tuple is initialized which holds the arguments until call time. So that this traversal can happen at compile time, the constructor that initializes the tuple is declared constexpr
.
using arg_pack_t = decltype(tpl::forward_as_tuple(find<Args>(std::declval<ComponentContainer&>())...));
const arg_pack_t arg_pack;
constexpr component_runtime_impl(ComponentContainer& container) : arg_pack{tpl::forward_as_tuple(find<Args>(container) ...)} {}
This component_runtime_impl
structure can be declared constinit
, which should guarantee that the construction of the arguments tuple, and thus the traversal of the component tree, happens at compile time; in case the compiler didn't initialize the tuple at compile time for some reason, there would still be little runtime performance impact, as the traversal would take place before the main program properly started running, although there would still be an increase in executable size if this somehow were to happen (e.g. if we forgot to declare the impl struct constinit
).
components1_t constinit main_runtime_components{};
component_runtime<testcomponent2_t, components1_t> constinit main_component_runtime{main_runtime_components.tc2, main_runtime_components};
TEST_CASE("sygaldry component runtime main")
{
main_runtime_components.tc1.outputs.out1.value = 0;
main_runtime_components.tc1.outputs.bang_out();
main_component_runtime.main();
CHECK(main_runtime_components.tc2.outputs.out1.value == 1);
CHECK(main_runtime_components.tc2.outputs.out2.value == 1);
}
Activating the main subroutine then simply requires us to apply the tuple:
template<typename Component, typename ComponentContainer, typename ... Args>
struct component_runtime_impl
{
@{component_runtime 1 tuple}
void main(Component& component) const
{
if constexpr (requires {&Component::operator();})
tpl::apply(component, arg_pack);
else if constexpr (requires {&Component::main;})
tpl::apply([&](auto& ... args) {component.main(args...);}, arg_pack);
}
};
All that's left then, essentially, is the metaprogram that determines the appropriate type for the tuple, seen below in the sequence of using
declarations: we extract the arguments list, remove constant, volatile, and reference qualifiers from the types in the list, prepend the component and assembly types to the list, and then swap whatever tuple container contains the type list with the component runtime implementation type so that we can easily instantiate it.
template<typename Component, typename ComponentContainer>
struct component_runtime
{
using args = typename main_subroutine_reflection<Component>::arguments;
using cvref_less_args = boost::mp11::mp_transform<std::remove_cvref_t, args>;
using prepended = boost::mp11::mp_push_front<cvref_less_args, Component, ComponentContainer>;
using impl_t = boost::mp11::mp_rename<prepended, component_runtime_impl>;
Component& component;
const impl_t impl;
constexpr component_runtime(Component& comp, ComponentContainer& cont) : component{comp}, impl{cont} {};
void init() const
{
};
void main() const
{
impl.main(component);
};
};
Generalize
The above implementation is only able to activate a single component's main subroutine. It is reasonably straightforward to generalize this to both initialize and activate an arbitrary number of components held in a component container.
First, we pull out the argument pack used in the inner implementation:
template<typename ComponentContainer, typename ... Args>
struct impl_arg_pack
{
using arg_pack_t = decltype(tpl::forward_as_tuple(find<Args>(std::declval<ComponentContainer&>())...));
const arg_pack_t pack;
constexpr impl_arg_pack(ComponentContainer& container) : pack{tpl::forward_as_tuple(find<Args>(container) ...)} {}
};
Having this class allows us to get rid of the inner impl
structure (e.g. component_runtime_impl
above) and makes it easier to generalize the component runtime so that it can initialize or activate a component, accepting argument packs for both of these subroutines, as well as others such as external_destinations
that were added later.
The full implementation now resembles the following, which essentially simply gets the types of the arg_pack
tuples, and applies them to the component's subroutines:
template<typename Component, typename ComponentContainer>
struct component_runtime
{
using init_args = typename init_subroutine_reflection<Component>::arguments;
using init_cvref_less_args = boost::mp11::mp_transform<std::remove_cvref_t, init_args>;
using init_prepended = boost::mp11::mp_push_front<init_cvref_less_args, ComponentContainer>;
using init_arg_pack = boost::mp11::mp_rename<init_prepended, impl_arg_pack>;
using main_args = typename main_subroutine_reflection<Component>::arguments;
using main_cvref_less_args = boost::mp11::mp_transform<std::remove_cvref_t, main_args>;
using main_prepended = boost::mp11::mp_push_front<main_cvref_less_args, ComponentContainer>;
using main_arg_pack = boost::mp11::mp_rename<main_prepended, impl_arg_pack>;
Component& component;
init_arg_pack init_args;
main_arg_pack main_args;
constexpr component_runtime(Component& comp, ComponentContainer& cont) : component{comp}, init_args{container}, main_args{container} {}
void init(Component& component) const
{
if constexpr (requires {&Component::init;})
tpl::apply([&](auto& ... args) {component.init(args...);}, init_args.pack);
}
void main(Component& component) const
{
if constexpr (requires {&Component::operator();})
tpl::apply(component, main_args.pack);
else if constexpr (requires {&Component::main;})
tpl::apply([&](auto& ... args) {component.main(args...);}, main_args.pack);
}
};
We can now test the runtime's ability to run the init method:
components1_t constinit init_runtime_components{};
component_runtime<testcomponent1_t<"tc1">, components1_t> constinit init_component_runtime{init_runtime_components.tc1, init_runtime_components};
TEST_CASE("sygaldry component runtime init")
{
init_runtime_components.tc1.inputs.in1.value = 0;
init_component_runtime.init();
CHECK(init_runtime_components.tc1.inputs.in1.value == 42);
}
To avoid repeating the series of template metafunctions used to get the argument pack types, we define a helper template that takes a function reflection structure and returns an appropriate argument pack type. We allow this to return a dummy arg pack in case a component lacks an init or main subroutine, so that components missing one or the other will not trigger a compiler error.
template<typename...>
struct to_arg_pack
{
struct dummy_t {
tpl::tuple<> pack;
constexpr dummy_t(auto&) : pack{} {};
};
using pack_t = dummy_t;
};
template<typename Component, typename ComponentContainer, typename FuncRefl>
requires FuncRefl::exists::value
struct to_arg_pack<Component, ComponentContainer, FuncRefl>
{
using args = typename FuncRefl::arguments;
using cvref_less = boost::mp11::mp_transform<std::remove_cvref_t, args>;
using prepended = boost::mp11::mp_push_front<cvref_less, ComponentContainer>;
using pack_t = boost::mp11::mp_rename<prepended, impl_arg_pack>;
};
We use this helper to get two argument pack types, one for init and one for main, that are then used to instantiate the runtime structure. We thus have a general runtime for a single component that passes our tests.
@{impl_arg_pack}
@{to_arg_pack}
template<typename Component, typename ComponentContainer>
struct component_runtime
{
using init_arg_pack = typename to_arg_pack<Component, ComponentContainer, init_subroutine_reflection<Component>>::pack_t;
using main_arg_pack = typename to_arg_pack<Component, ComponentContainer, main_subroutine_reflection<Component>>::pack_t;
Component& component;
init_arg_pack init_args;
main_arg_pack main_args;
constexpr component_runtime(Component& comp, ComponentContainer& cont) : component{comp}, init_args{cont}, main_args{cont} {}
void init() const
{
if constexpr (requires {&Component::init;})
tpl::apply([&](auto& ... args) {component.init(args...);}, init_args.pack);
}
void main() const
{
if constexpr (requires {&Component::operator();})
tpl::apply(component, main_args.pack);
else if constexpr (requires {&Component::main;})
tpl::apply([&](auto& ... args) {component.main(args...);}, main_args.pack);
}
};
Digression: Other Stages
Thus far we have ignored external sources and destinations and more or less assumed that all our components would run in the order specified in the component tree, first all their initialization routines, then all their main subroutines in a loop forever, with input and output flags cleared in between loops.
This sequence is adequate when considering only regular components, however, it fails to reasonably support binding components. Consider a protocol binding that can set the state of input endpoints and send changes in the state of output endpoints to external devices via its protocol. There is no way to order such a binding component so that external inputs can propagate to the bound components and resulting state changes can be sent out.
Option 1:
run regular components
run binding (component outputs are sent out)
clear flags (external inputs are cleared)
Option 2:
run bindings (external inputs are set)
run regular components
clear flags (component outputs are cleared)
Our chosen solution to this issue is to simply introduce two additional stages to our main loop, which we call external_sources
and external_destinations
. Components (especially binding components) may declare methods with these names, and argument packs are added to the component runtime to call them with any arguments required that can be extracted from the component tree.
@{impl_arg_pack}
@{to_arg_pack}
template<typename Component, typename ComponentContainer>
struct component_runtime
{
using init_arg_pack = typename to_arg_pack<Component, ComponentContainer, init_subroutine_reflection<Component>>::pack_t;
using main_arg_pack = typename to_arg_pack<Component, ComponentContainer, main_subroutine_reflection<Component>>::pack_t;
using ext_src_arg_pack = typename to_arg_pack<Component, ComponentContainer, external_sources_subroutine_reflection<Component>>::pack_t;
using ext_dst_arg_pack = typename to_arg_pack<Component, ComponentContainer, external_destinations_subroutine_reflection<Component>>::pack_t;
Component& component;
init_arg_pack init_args;
main_arg_pack main_args;
ext_src_arg_pack ext_src_args;
ext_dst_arg_pack ext_dst_args;
constexpr component_runtime(Component& comp, ComponentContainer& cont)
: component{comp}, init_args{cont}, main_args{cont}, ext_src_args{cont}, ext_dst_args{cont} {}
void init() const
{
if constexpr (requires {&Component::init;})
tpl::apply([&](auto& ... args) {component.init(args...);}, init_args.pack);
}
void external_sources() const
{
if constexpr (requires {&Component::external_sources;})
tpl::apply([&](auto& ... args) {component.external_sources(args...);}, ext_src_args.pack);
}
void main() const
{
if constexpr (requires {&Component::operator();})
tpl::apply(component, main_args.pack);
else if constexpr (requires {&Component::main;})
tpl::apply([&](auto& ... args) {component.main(args...);}, main_args.pack);
}
void external_destinations() const
{
if constexpr (requires {&Component::external_destinations;})
tpl::apply([&](auto& ... args) {component.external_destinations(args...);}, ext_dst_args.pack);
}
};
Tuple of runtimes
All that remains is to make a tuple of component runtimes with one for each runtime managed component in the component tree. First we define a subroutine that will give us a tuple of runtimes.
template<typename ComponentContainer>
constexpr auto component_to_runtime_tuple(ComponentContainer& cont)
{
auto tup = component_filter_by_tag<node::component>(cont);
if constexpr (is_tuple_v<decltype(tup)>)
return tup.map([&](auto& tagged_component)
{
return component_runtime{tagged_component.ref, cont};
});
else return tpl::make_tuple(component_runtime{tup.ref, cont});
}
components1_t constinit runtime_tuple_components{};
constexpr auto runtime_tuple = component_to_runtime_tuple(runtime_tuple_components);
TEST_CASE("sygaldry runtime tuple")
{
tpl::apply([](auto& ... runtime) {(runtime.init(), ...);}, runtime_tuple);
CHECK(runtime_tuple_components.tc1.inputs.in1.value == 42);
tpl::apply([](auto& ... runtime) {(runtime.main(), ...);}, runtime_tuple);
CHECK(runtime_tuple_components.tc1.outputs.out1.value == 43);
}
Then we can assemble the final runtime class. This requires a somewhat awkward repetition of the call to component_to_runtime_tuple
, first to get the type of the tuple and then to actually initialize it. Otherwise, the top level runtime simply delegates to the inner component runtimes.
template<typename ComponentContainer>
struct Runtime
{
ComponentContainer& container;
decltype(component_to_runtime_tuple(std::declval<ComponentContainer&>())) component_runtimes;
constexpr Runtime(ComponentContainer& c) : container{c}, component_runtimes{component_to_runtime_tuple(c)} {};
void init() const
{
@{set initial values}
tuple_for_each(component_runtimes, [](auto& r){r.init();});
}
void external_sources() const
{
tuple_for_each(component_runtimes, [](auto& r){clear_input_flags(r.component);});
tuple_for_each(component_runtimes, [](auto& r){r.external_sources();});
}
void main() const { tuple_for_each(component_runtimes, [](auto& r){r.main();}); }
void external_destinations() const
{
tuple_for_each(component_runtimes, [](auto& r){r.external_destinations();});
tuple_for_each(component_runtimes, [](auto& r){clear_output_flags(r.component);});
}
void tick() const
{
external_sources();
main();
external_destinations();
}
int app_main() const { for (init(); true; tick()) {} return 0; }
};
Initial values
Some endpoints may require initialization. The logic for performing this is implemented in page-sygac-endpoint-ranges, and here we can simply iterate over all endpoints and delegate to the appropriate function.
This initialization takes place before components' init
subroutines. This way the init
subroutines can rely on having initialized endpoints, and the init
subroutine of session management components can restore saved endpoint values without them being clobbered by this initialization step.
for_each_endpoint(container, []<typename T>(T& ep)
{
initialize_endpoint(ep);
});
Runtime summary
#pragma once
#include <boost/mp11.hpp>
#include "sygac-functions.hpp"
#include "sygac-components.hpp"
#include "sygac-endpoints.hpp"
namespace sygaldry {
@{component_runtime}
@{runtime tuple}
@{Runtime}
}
#include <catch2/catch_test_macros.hpp>
#include "sygac-runtime.hpp"
#include "sygah-endpoints.hpp"
using namespace sygaldry;
@{tests}
# @#'CMakeLists.txt'
set(lib sygac-runtime)
add_library(${lib} INTERFACE)
target_include_directories(${lib} INTERFACE .)
target_link_libraries(${lib} INTERFACE sygac-components)
target_link_libraries(${lib} INTERFACE sygac-functions)
target_link_libraries(${lib} INTERFACE Boost::mp11)
if (SYGALDRY_BUILD_TESTS)
add_executable(${lib}-test ${lib}.test.cpp)
target_link_libraries(${lib}-test PRIVATE Catch2::Catch2WithMain)
target_link_libraries(${lib}-test PRIVATE ${lib})
target_link_libraries(${lib}-test PRIVATE sygah)
catch_discover_tests(${lib}-test)
endif()
# @/