SkillAgentSearch skills...

Ramen

Real-time Actor-based Message Exchange Network ๐Ÿœ

Install / Use

/learn @Zubax/Ramen

README

<h1 align="center" style="text-align:center">Real-time Actor-based Message Exchange Network ๐Ÿœ</h1> <p align="center" style="text-align:center">Lightweight dataflow embedded programming in C++</p> <div align="center">

Verification Forum

</div> <hr/>

RAMEN is a very compact, unopinionated, single-header C++20+ dependency-free library that implements message-passing/flow-based programming for hard real-time mission-critical embedded systems, as well as general-purpose applications. It is designed to be very low-overhead, efficient, and easy to use.

To use the library in your project, simply copy ramen/ramen.hpp into your project tree, #include <ramen.hpp>, and you're ready to roll. Alternatively, add this repository as a submodule, and add ramen/ to the include paths.

It should work on any conventional platform, from 8-bit to 64-bit; if you find this to be untrue, please open a ticket.

RAMEN powers Dyshlo -- a powerful motor control and simulation library by Zubax Robotics.

Why though? ๐Ÿง

There exists a class of problems in software engineering that are hard to model efficiently using more conventional paradigms, such as OOP, but are easy to describe using the dataflow model. Problems of that class are particularly often encountered in embedded real-time control systems and digital signal processing (DSP) pipelines.

One approach there is to use model-based design with automatic code generation using LabView, Simulink, etc. The disadvantage of this approach is that it may be difficult to couple autogenerated code with the rest of the system (such as the higher-level business logic), as the generated code tends to be opinionated, strongly affecting the rest of the codebase.

Another approach is to use a full-scale event framework like QP/C++ et al. These are probably great tools for some projects, but importantly, RAMEN is not a framework but a very lightweight library that is designed to work on any platform out of the box.

RAMEN allows one to apply dataflow programming in an extremely unopinionated way, and it can be coupled with conventional C/C++ programs ad-hoc. It is implemented in only a few hundred lines of straightforward C++, plus a couple more hundred lines for some useful utilities that are nice to have in a dataflow program.

RAMEN is typesafe, has no runtime error states, requires no heap, no exceptions, no RTTI, no macros, and it adds no nontrivial computational complexity on top of the user logic.

Notation ๐Ÿ“

There are two ways to arrange dataflows:

  • Push model: an actor receives data together with control flow and performs evaluations eagerly, updating its outputs immediately, triggering dependent computations downstream from itself.

  • Pull model: an actor receives control when its outputs are needed, and fetches the data it needs lazily, triggering dependent computations upstream.

Any problem can be modeled using either approach, but some problems may be easier to model using one but not the other. RAMEN supports both and allows mixing them if necessary.

RAMEN is built using only two primitive entities:

  • Behaviors implement business logic, like methods in an OOP program. They contain arbitrary user code, no strings attached. In the pull model, behaviors produce data, while in the push model they accept data.

  • Events are used to notify other actors when new data is available (push aka eager model) or new data is required for some computation (pull aka lazy model).

Events and behaviors are linked into topics using operator>>. When an event is triggered, all behaviors on the topic are executed. In the pull model, there should be only one behavior per topic to avoid ambiguity (otherwise, behavior executed later will overwrite the output computed by the earlier behaviors, which may not be what you want). The push model allows mixing an arbitrary number of events and behaviors per topic; there, either of the connected events will trigger all behaviors (fanout).

Given that behaviors and ports can either sink or source data, we end up with four combinations:

| Port kind | Control | Data | Alias | |--------------|---------|------|------------| | in-behavior | in | in | Pushable | | out-event | out | out | Pusher | | out-behavior | in | out | Pullable | | in-event | out | in | Puller |

On a diagram, data inputs go on the left, data outputs on the right, and the direction of the control flow is shown with an arrow:

                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  (input data type) โ”‚ Actor โ”‚ (output data type)
  pushable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚       โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ pusher
                    โ”‚       โ”‚
  (input data type) โ”‚       โ”‚ (output data type)
    puller โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค       โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ pullable
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Dazzle me ๐Ÿคฏ

Let's make a simple summation node using the pull model (lazy computation):

               โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       (float) โ”‚ Summer โ”‚ (float)
 in_a โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค        โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ out_sum
               โ”‚        โ”‚
       (float) โ”‚        โ”‚
 in_b โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค        โ”‚
               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
struct Summer
{
    ramen::Puller<float> in_a;
    ramen::Puller<float> in_b;
    ramen::Pullable<float> out_sum = [this](float& out) { out = *in_a + *in_b; };
};

We can see that the Puller entities are used to source the arguments from whatever external entity this summer is connected to. Crucially, the summer itself has no idea where the data is coming from.

The data is read using operator* for convenience, but this will only work if the data type is default-constructible. Sometimes it is not (e.g., Eigen::MatrixRef has to be bound to the storage matrix upon construction, so it is not default-constructible; this use case is very common in DSP), in which case the data is obtained using the ordinary function call syntax:

float a = 0;
in_a(a);

float b = 0;
in_b(b);

out = a + b;

This is also why we return the result via the out-parameter.

The summer can be linked to other actors using operator>> once before the program is executed; linking does not allocate dynamic memory and cannot fail, but it involves a linked list traversal, so it has a linear complexity on the number of ports on the topic. The direction of the operator arrow follows the direction of the control flow (not data flow).

Summer sum;

// Create the top-level ports (these could be ports of another actor, but in this example we only have one).
ramen::Pullable<float> ingest_a = [](float& out) { std::cin >> out; };
ramen::Pullable<float> ingest_b = [](float& out) { std::cin >> out; };
ramen::Puller<float> final_answer;

// Link them up. An unconnected behavior is never executed. An unconnected event is computationally free.
sum.in_a >> ingest_a;
sum.in_b >> ingest_b;
final_answer >> sum.out_sum;

// Run the network.
while (true) { std::cout << *final_answer << std::endl; }

Some logic will be easier to implement using push model instead. It could be said to be more flexible in certain ways. Below we have another summer implemented using the push model. One matter that is immediately apparent is that this naive implementation will update the output whenever either of the inputs are updated; this is rarely the desired behavior:

               โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       (float) โ”‚ Summer โ”‚ (float)
 in_a โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ out_sum
               โ”‚        โ”‚
       (float) โ”‚        โ”‚
 in_b โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        โ”‚
               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
struct Summer
{
    float a;
    float b;
    ramen::Pusher<float> out_sum;
    ramen::Pushable<float> in_a = [this](const float x) { a = x; out_sum(a + b); };
    ramen::Pushable<float> in_b = [this](const float x) { b = x; out_sum(a + b); };
};

There are many ways to address the update rate issue. The optimal choice depends on the specifics of the problem at hand. One such approach is to introduce an explicit trigger; in control systems, it is convenient to carry some shared context via the trigger inputs, such as the update time step $\Delta{}t$.

                  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
          (float) โ”‚ Summer โ”‚ (float)
    in_a โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ output
                  โ”‚        โ”‚
          (float) โ”‚        โ”‚
    in_b โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        โ”‚
                  โ”‚        โ”‚
               () โ”‚        โ”‚
 in_tick โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚        โ”‚
                  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
struct Summer
{
    float a;
    float b;
    ramen::Pusher<float> out_sum;
    ramen::Pushable<float> in_a = [this](const float x) { a = x; };
    ramen::Pushable<float> in_b = [this](const float x) { b = x; };
    ramen::Pushable<>   in_tick = [this] { out_sum(a + b); };  // often accepts time delta
};

Another way is to use implicit synchronization at the rate of the slowest input like this:

struct Summer
{
    std::optional<float> a;
    std::optional<float> b;
    
    ramen::Pusher<float> out_sum;
    ramen::Pushable<float> in_a = [this](const float x) { a = x; poll(); };
    ramen::Pushable<float> in_b = [this](const float x) { b = x; poll(); };

    void poll() {
        if (a && b) {
            out_sum(*a + *b);  // Only emit output when both inputs are updated.
            a.reset();         // The output is throttled at the rate of the slowest input.
            b.reset();
        }
    }
};

Connection example for the last one:

Summer sum;

// Create the top-level ports (these could be ports of another actor, but in this example we only have one).
ramen::Pusher<float> ingest_a;
ramen::Pusher<float> ingest_b;
ramen::Pushable<float> print_float = [](const float x) { std::cout << x << s

Related Skills

View on GitHub
GitHub Stars70
CategoryDevelopment
Updated15d ago
Forks3

Languages

C++

Security Score

100/100

Audited on Mar 19, 2026

No findings