perlindgren / syncrim

15 stars 4 forks source link

SyncRim

A graphical simulator for synchronous circuits written in Rust based on the vizia framework. The long term goal is to simulate models of modern embedded processors, giving the user control over - and insight in - the inner workings of a CPU without the need for traditional RTL waveform simulations which are hard to interpret. Other use cases stretch exploratory research of synchronous circuits, hardware software co-design etc.

SyncRim is heavily inspired by the Java based in-house SyncSim development at Luleå University of Technology (LTU). SyncSim has been (and still is) used in teaching Micro-computer Engineering at LTU for almost two decades, but it starts to show its age. SyncRim a Rust implementation of SyncSim is tempting :)


Dependencies

For faster builds under Linux, we depend on clang and mold being installed. You may disable the alternate linking in .cargo/config.toml, if you want to stick with lld comment out the linker configuration.

For visualization of the underlying simulation model install graphviz.


Running examples

To test SyncRim run:

cargo run --example <example>

This will build and run the corresponding example and as a side effect create a loadable model (<example>.json).

To load and run the created model (<example>.json).

cargo run -- -model <example>.json

Alternatively, you can run the mips example from the mips folder.

cd mips
cargo run --example mips

And consequently run the created model (mips.json).

cd mips # if not already done
cargo run

You can also run the examples correspondingly in vscode.

After the initial models have been generated you may alter them (edit the json files and just run the corresponding main to simulate the altered model).

Disclaimer: you will run into panics in case your model is faulty, sorry no nice error messages to be expected. Circular dependent combinatorial circuits are considered illegal (for good reasons). Direct register to register dependencies (without intermittent combinatorial components) will likely render undefined behavior.


winit and Scaling

At least under Linux, scaling can be incorrectly determined by winit. This will cause various graphical artifacts (e.g., weird clipping of fonts in tooltips). It is not problem with Vizia or any other GUI per-se, but a problem of Linux not defining a stable way of providing the current scaling. A workaround is possible by setting the environment variable WINIT_X11_SCALE_FACTOR to 1.0 (or any other scaling wanted). An example for fish shell:

set -x WINIT_X11_SCALE_FACTOR 1.0

Key design goals

Technologies used

Design overview

SyncRim is based on the following guiding principles:

Modularity:


POC implementation

SyncRim is in early development. The POC implementation currently demonstrates:

TODO


Implementation specifics

In the following we highlight some specifics of the current design.

Types for the storage model

The common module provides:

pub struct Ports {
    pub inputs: Vec<Input>,
    pub out_type: OutputType,
    pub outputs: Vec<Output>,
}

Where:

pub struct Input {
    pub id: String,
    pub index: usize,
}

pub enum OutputType {
    // Will be evaluated as a combinatorial function from inputs to outputs
    Combinatorial,
    // Will be evaluated as synchronous from input to output
    Sequential,
}

pub enum Output {
    // Will be evaluated as a constant (function without inputs)
    Constant(u32),
    // Will be evaluated as a function
    Function,
}

Notice, the Output/OutputType may be subject to change, see github #3.

These types are used to build components.


Traits

pub trait Component {
    // placeholder
    fn to_(&self) {}

    // returns the (id, Ports) of the component
    fn get_id_ports(&self) -> (String, Ports);

    // evaluation function
    fn evaluate(&self, _simulator: &mut Simulator) {}

     // create view
    fn view(&self, _cx: &mut Context) {}
}

Any component must implement the Component trait, (evaluate and view are optional).

For serialization to work, typetag is derived for the Component trait definition as well as its implementations. Under the hood, the dyn Traits are handled as enums by serde.


Components

SyncSim provides a set of predefined components:

The components implement the Component trait, used to build a various mappings.

A (simulation) model can extend the set of components (see the mips member crate).

A model is defined by the storage ComponentStore:

#[cfg(test)]
type Components = Vec<Rc<dyn Component>>;

#[cfg(all(not(test), feature = "gui-vizia"))]
type Components = Vec<Rc<dyn ViziaComponent>>;

#[cfg(all(not(test), feature = "egui"))]
type Components = Vec<Rc<dyn EguiComponent>>;

#[derive(Serialize, Deserialize)]
pub struct ComponentStore {
    pub store: Components,
}

// Common functionality for all components
#[typetag::serde(tag = "type")]
pub trait Component {
    // placeholder
    fn to_(&self) {}

    /// returns the (id, Ports) of the component
    fn get_id_ports(&self) -> (String, Ports);

    /// evaluation function
    fn evaluate(&self, _simulator: &mut Simulator) {}
}

// Specific functionality for Vizia frontend
#[typetag::serde(tag = "type")]
pub trait ViziaComponent: Component {
    /// create Vizia view
    fn view(&self, _cx: &mut vizia::context::Context) {}
}

// Specific functionality for EGui frontend
#[typetag::serde(tag = "type")]
pub trait EguiComponent: Component {
    /// TBD
    fn tbd(&self) {}
}

The business logic is captured by the Component trait, while the ViziaComponent/EguiComponent traits specify the frontend behavior. Notice Egui behavior is still to be determined.

SyncRim is featured gated, allowing front-ends to be optionally pulled in. By default, the vizia feature is active, but tests can be run without any frontend selected (--no-default-features).


Simulator State

In order to view the simulator state we store (current) values as a Vizia Lens.

#[derive(Lens, Debug, Clone)]
pub struct Simulator {
    ...
    pub sim_state: Vec<Signal>,
}

The Simulator holds the values and the mapping between identifiers and ports.

pub struct Simulator<'a> {
    pub id_start_index: IdStartIndex,

    // Components stored in topological evaluation order
    pub ordered_components: Components,
}

Simulator Implementation

The initial simulator state is constructed from a ComponentStore.

impl Simulator {
    pub fn new(component_store: &ComponentStore, clock: &mut usize) -> Self
    ...

As a side effect the clock will be set to 1 (indicating the reset state).

The Simulator holds the evaluation order of components in ordered_components, and the mutable state (sim_state).

To progress simulation, we iterated over the ordered_components:

impl Simulator {
    ...
    // iterate over the evaluators
    pub fn clock(&mut self, clock: &mut Clock) {
        for component in &self.ordered_components {
            component.evaluate(self, sim_state);
        }
    }
}

As as side effect the clock will be incremented.


Example component Add

The Add component is defined by:

#[derive(Serialize, Deserialize)]
pub struct Add {
    pub id: String,
    pub pos: (f32, f32),
    pub a_in: Input,
    pub b_in: Input,
}

An instance of Add might look like this:

Add {
    id: "add1",
    pos: (200.0, 120.0),
    a_in: Input {
      id: "c1",
      index : 0
    },
    b_in: Input {
      id: "r1",
      index: 0
    }
}

The corresponding serialized json looks like this:

        {
            "type": "Add",
            "id": "add1",
            "pos": [
                200.0,
                120.0
            ],
            "a_in": {
                "id": "c1",
                "index": 0
            },
            "b_in": {
                "id": "r1",
                "index": 0
            }
        },

The Add component implements get_id_ports, evaluate and view. The first is used on loading a model for determining the dependencies (and from that the topological order), the second is used for simulation and the third to create a Vizia view of the component.

Notice that the get_id_ports returns a vector of output types. In this case the component has just one output (the sum of inputs computed as a function). On loading the model, consecutive space is allocated for each output and a mapping created from the component identifier to the allocated space.

evaluate retrieves the input values from the simulator, computes the sum and stores it at the first position of the allocated space. In case a component has several outputs, the offset is passed, e.g., simulator.set_id_index(..., 1, ...), to set the 2nd output of the component.

The logic part is found in src/components/add.rs:

impl Component for Add {
    fn to_(&self) {
        trace!("Add");
    }

    fn get_id_ports(&self) -> (String, Ports) {
        (
            self.id.clone(),
            Ports {
                inputs: vec![self.a_in.clone(), self.b_in.clone()],
                out_type: OutputType::Combinatorial,
                outputs: vec![Output::Function; 2],
            },
        )
    }

    // propagate addition to output
    fn evaluate(&self, simulator: &mut Simulator) {
        // get input values
        let a_in = simulator.get_input_val(&self.a_in);
        let b_in = simulator.get_input_val(&self.b_in);

        // compute addition (notice will panic on overflow)
        let (value, overflow) =
            SignedSignal::overflowing_add(a_in as SignedSignal, b_in as SignedSignal);

        trace!(
            "eval Add a_in {}, b_in {}, value = {}, overflow = {}",
            a_in, b_in, value, overflow
        );

        // set output
        simulator.set_id_index(&self.id, 0, value as Signal);
        simulator.set_id_index(&self.id, 1, Signal::from(overflow));
    }
}

A Vizia frontend for the Add component is found in src/gui_vizia/components/add.rs:

impl ViziaComponent for Add {
    // create view
    fn view(&self, cx: &mut Context) {
        trace!("---- Create Add View");

        View::build(AddView {}, cx, move |cx| {
            Label::new(cx, "+")
                .left(Percentage(50.0))
                .top(Pixels(40.0 - 10.0))
                .hoverable(false);
            NewPopup::new(cx, self.get_id_ports()).position_type(PositionType::SelfDirected);
        })
        .left(Pixels(self.pos.0 - 20.0))
        .top(Pixels(self.pos.1 - 40.0))
        .width(Pixels(40.0))
        .height(Pixels(80.0))
        .on_press(|ex| ex.emit(PopupEvent::Switch))
        .tooltip(|cx| new_component_tooltip(cx, self));
    }
}

The Add component is anchored at pos with height 80 and width 40 pixels. The tooltip is used to show the inputs and outputs on hovering. The View implementation (not depicted), provides element (used for CSS styling) and draw used for rendering.


Development

Github

The CI based on Github actions performs:

There is currently no automatic interaction tests of the GUI components.

Workflow

VsCode

Recommended plugins:

It can be convenient to use json formatter tied to format on save, this way we can keep models in easy readable and editable shape.


Features handling

Look in BUILD.md for a breakdown of the SyncRim feature handling and workspace setup.


License

TDB (likely MIT + Apache)