Message passing design

I'm thinking about trying to implement UVM in Rust. UVM is a way of modelling chips where you pass messages between components that are connected by ports. Imagine the Blender nodes interface but with little packets flowing along the edges.

In C++ this is really easy. You just do something like this:

template <class PacketType>
struct Port {
   void connect_to(std::function<void (PacketType)> f) {
        m_targets.push_back(f);
    }

   void send(PacketType packet) {
      for (const auto& target : m_targets) {
         target(packet);
      }
   }
};

struct ComponentA {
    Port<Foo> foo_output;
};

struct ComponentB {
    Port<Bar> bar_output;
    bool foo_received = false;
    void foo_input(Foo f) {
        foo_received = true;
        bar_output.send(...);
    }
};

void test() {
    ComponentA a;
    ComponentB b;
    a.foo_output.connect_to([](auto pkt) { b.foo_input(pkt); });
    ...
}

In other words, you can treat the ports as a fancy way of storing a load of callbacks. This has some nice advantages:

  1. It's really fast.
  2. When debugging the stack trace is really nice and shows you the history of how you got to where you are.

However, this cannot be directly translated to Rust for two reasons:

  1. The above code makes the implicit requirement that a and b do not move (you can enforce it in C++ by deleting the copy/move operators) and that they all live for the same lifetime.
  2. The callbacks need mutable access to the state in the component (e.g. foo_received). There can also be loops in the connections, so you can't just mutably borrow the whole component for the connection.

The "obvious" way to do this in Rust is via a load of Rc<RefCell<>>s, but I can't work out a way to do it without splitting each component into two, kind of like this:

struct ComponentA {
  state: RefCell<ComponentAState>,
}

struct ComponentAState {
   foo_received: bool,
}

Because you can't do self: Rc<RefCell<Self>>. You also end up with a ton of .borrow()s. All very boilerplatey.

Can anyone think of a more elegant way to do this? Has anyone already done something like this?

The cleanest solution would be not to intrusively pack your types full of Rc<RefCell<_>> ahead of time. If the interface needs mutation, take a plain &mut self. Let the user worry about any eventual shared mutability they might need to make use of. Playground:

struct Port<P> {
    targets: Vec<Box<dyn FnMut(P)>>,
}

impl<P> Port<P> {
    fn connect_to<F>(&mut self, f: F)
    where
        F: FnMut(P) + 'static
    {
        self.targets.push(Box::new(f));
    }

    fn send(&mut self, packet: P) where P: Clone {
        if let Some((last, rest)) = self.targets.split_last_mut() {
            for target in rest {
                target(packet.clone());
            }
            last(packet);
        }
    }
}

impl<P> Default for Port<P> {
    fn default() -> Self {
        Port { targets: Vec::new() }
    }
}

#[derive(Clone)]
struct Foo;

#[derive(Clone)]
struct Bar;

#[derive(Default)]
struct ComponentA {
    foo_output: Port<Foo>,
}

#[derive(Default)]
struct ComponentB {
    bar_output: Port<Bar>,
    foo_received: bool,
}

impl ComponentB {
    fn foo_input(&mut self, _foo: Foo) {
        self.foo_received = true;
        self.bar_output.send(Bar);
    }
}

fn main() {
    let a = Rc::new(RefCell::new(ComponentA::default()));
    let b = Rc::new(RefCell::new(ComponentB::default()));
    
    a.borrow_mut().foo_output.connect_to(move |pkt| b.borrow_mut().foo_input(pkt));
}
1 Like

Ah very neat, thanks!

The next challenge would be how to do it for internal components - that is, components that contain other components. The connections would be made from a method inside ComponentA but it can't connect the internal component's ports back to itself because it only has &self.

I guess you could make a static method like fn make_connections(rcself: Rc<Refcell<Self>>) {....

Ah wait actually I could just have the fn new() function create an Rx<Refcell<Self>> and do all the internal connections there. Sorted!

Thanks a lot!

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.