NeXosim Discrete-Event Simulation Framework

repository·main·Indexed 43 days ago

https://github.com/asynchronics/nexosim

A high-performance, discrete-event simulation framework written in Rust. NeXosim uses an asynchronous, actor-based model and a component-oriented architecture inspired by flow-based programming (FBP) to enable highly parallelized simulations of complex, time-driven state machines. It features a custom MPSC queue for efficient message passing, a `#[Model]` attribute macro for trait implementation, and tools for dynamic method scheduling and JSON schema generation.

Tokens
11.6K
Snippets
30
Records
50
Agent score
53%

What's inside NeXosim

  1. How models and connections work in NeXosim

    main

    NeXosim uses a component-oriented architecture inspired by flow-based programming (FBP).

    • Models: Isolated entities with fixed typed inputs and outputs. Models are implemented as Rust structs and decorated with the #[Model] attribute.
    • Ports/IO: Models communicate via ports (like Output<T>). Inputs are typically defined as methods on the model, and outputs are fields within the struct.
    • Connections: Models are linked during bench assembly. You connect an output port of one model to an input method of another using the .connect() method, passing a Mailbox as the communication medium.
    • Mailboxes: Every model instance requires a Mailbox to receive messages/events. This acts as the receiver for the model's asynchronous communication channel.
    // Example connection pattern
    // multiplier1.output connects to multiplier2's input method via multiplier2_mbox
    multiplier1
        .output
        .connect(DelayedMultiplier::input, &multiplier2_mbox);
  2. Use the `observable` module for state change propagation

    main
    The observable module in nexosim-util provides types designed to enable the automatic propagation of state changes to an associated output. This is useful in NeXosim-based simulations where you need to react to or export changes in simulation state automatically.
  3. How Tickers prevent simulation unresponsiveness

    main

    In NeXosim, simulations can run in 'tick-less' mode where the simulation advances directly to the next scheduled event. While efficient, this can cause the simulation to block (sleep) during long idle periods, making it unresponsive to:

    • Commands: Instructions like Scheduler::halt are postponed until the next event.
    • External scheduling: The Scheduler cannot schedule new events into the current idle window.
    • Injected events: Processing of external events in the injector queue is delayed.

    A Ticker solves this by forcing the synchronization clock to wake up at regular intervals, even if no events are scheduled. This ensures the simulation regularly yields control back to the controller. Ticks are more efficient than recurring scheduler events because they only run the executor when required.

    You can attach a ticker to a simulation using SimInit::with_clock.

  4. Install NeXosim

    main

    To use NeXosim in your Rust project, add the following dependencies to your Cargo.toml. Note that serde is required for model serialization/deserialization, which is a common pattern in NeXosim for state management.

    [dependencies]
    nexosim = "1"
    serde = "1"
  5. Build a simulation bench with SimInit

    main

    To run a simulation, you must assemble a 'bench' using SimInit. The workflow follows these steps:

    1. Instantiate Models: Create your model instances and their corresponding Mailboxs.
    2. Connect Components: Use .connect() to link model outputs to inputs, or .connect_sink() to link outputs to a simulation sink.
    3. Register Inputs: Create an EventSource and register it with the bench to allow external event injection.
    4. Add Models to Bench: Use .add_model(model, mailbox, name) to register each component.
    5. Initialize: Call .init(start_time) to finalize the bench and start the simulation engine.
    let mut bench = SimInit::new();
    
    // ... connect models ...
    
    let mut simu = bench
        .add_model(multiplier1, multiplier1_mbox, "multiplier 1")
        .add_model(multiplier2, multiplier2_mbox, "multiplier 2")
        .init(t0)?;
  6. What is a `SchedulableId` and how to use it

    main

    A SchedulableId<M, T> is a type-safe identifier for a specific input method of a model M that accepts argument type T.

    Automatic Usage

    Most users should use the #[nexosim(schedulable)] attribute on model methods and the schedulable!(Self::method_name) macro. This automatically handles the registration and retrieval of IDs via the ModelRegistry.

    Manual Usage

    If you are implementing the Model trait manually (without the procedural macro) or need to schedule a function that isn't a model method, you can obtain a SchedulableId using BuildContext::register_schedulable during the model building phase.

  7. Understanding NeXosim causal messaging guarantees

    main

    NeXosim provides specific guarantees regarding message delivery order to ensure deterministic behavior in a parallel environment. This is referred to as causal messaging:

    1. One-to-one message ordering guarantee: If model A sends two events or queries M1 and then M2 to model B, then B will always process M1 before M2.
    2. Transitivity guarantee: If A sends M1 to B and then M2 to C, and C subsequently sends M3 to B, then B is guaranteed to process M1 before M3 (even if M1 and M2 are processed in any order by B and C).

    These guarantees apply to events scheduled from the global Scheduler and events self-scheduled by a model using its Context.

  8. Authoring simulation models

    main

    Models in NeXosim are defined by implementing the Model trait. The most convenient way to do this is by using the #[Model] proc-macro on the model's impl block.

    Port Types

    Models interact through four types of ports:

    • Output ports (Output<T>): Used to broadcast messages.
    • Requestor ports (UniRequestor<T> or Requestor<T>): Used to send/broadcast messages and receive either a single reply or an iterator over replies from all connected repliers.
    • Input ports: Synchronous or asynchronous methods implementing the InputFn trait. They take &mut self, a message, and optionally &Context<Self> and &Model::Env.
    • Replier ports: Similar to input ports but implement the ReplierFn trait and return a reply.

    Model Lifecycle and Features

    • Initialization: Use the #[nexosim(init)] attribute to define a custom init method that runs once after all connections are made but before simulation starts.
    • Self-scheduling: Decorate input methods with #[nexosim(schedulable)] to allow the model to schedule its own future actions using the Context.
    • Prototypes: For complex hierarchical models, implement the ProtoModel trait to handle submodel connection and setup during the build phase.
    use serde::{Deserialize, Serialize};
    use nexosim::model::{Context, Model, schedulable};
    use nexosim::ports::Output;
    
    #[derive(Default, Serialize, Deserialize)]
    pub struct Delay {
       pub output: Output<f64>,
    }
    
    #[Model]
    impl Delay {
        pub fn input(&mut self, value: f64, cx: &Context<Self>) {
            // Schedule an event in the future
            cx.schedule_event(std::time::Duration::from_secs(1), schedulable!(Self::send), value).unwrap();
        }
    
        #[nexosim(schedulable)]
        async fn send(&mut self, value: f64) {
            self.output.send(value).await;
        }
    }
  9. Implement or use different Clock types for simulation

    main

    The Clock trait abstracts over different timing mechanisms used to synchronize a simulation. You can associate a clock with a simulation during initialization using SimInit::with_clock.

    Available implementations:

    • NoClock: A dummy clock that ignores synchronization, causing the simulation to run as fast as possible.
    • SystemClock: A real-time clock synchronized with the system's monotonic clock. It requires an explicit reference time mapping simulation time to wall clock time.
    • AutoSystemClock: A real-time clock that initializes itself on its first call to synchronize, implicitly defining the reference time based on the first deadline provided.
    // Example: Using SystemClock with SimInit
    let t0 = MonotonicTime::new(1_234_567_890, 0).unwrap();
    let clock = SystemClock::from_instant(t0, Instant::now() + Duration::from_secs(1));
    let ticker = PeriodicTicker::new(Duration::from_millis(100));
    
    let simu = SimInit::new()
        .with_clock(clock, ticker)
        .init(t0);
  10. How NeXosim simulation works

    main

    NeXosim is a high-performance, discrete-event computation framework based on a component-oriented architecture similar to flow-based programming (FBP). It uses an actor-model approach where models are isolated entities communicating via message passing through typed ports and connections.

    Simulating a system involves three main activities:

    1. Designing models: Creating individual nodes with specific inputs and outputs.
    2. Assembling a bench: Inter-connecting model ports to form a system.
    3. Executing the simulation: Managing time increments and message exchanges.

    NeXosim leverages asynchronous programming for transparent auto-parallelization, achieving high throughput via a custom multi-threaded executor.

  11. Use `Requestor<T, R>` to broadcast queries

    main

    Requestor<T, R> ports are used to broadcast queries to multiple connected replier ports (asynchronous model methods that return a value of type R).

    Key Behaviors:

    • Broadcasting: When send(arg) is called, it returns an Iterator<Item = R> containing the replies from all connected replier ports.
    • Cloning: Cloning shares the underlying connections; all clones use and modify the same list of connected ports.
    • Serialization: Supports serde. Like Output, successful state restoration requires consistent ordering of Requestor instances within the model.
    // Example of using a Requestor
    let mut requestor = Requestor::<MyQuery, MyReply>::new();
    
    // Connect to a model method (replier port)
    requestor.connect(model_replier_method, address);
    
    // Broadcast a query and collect replies
    let replies: Vec<_> = requestor.send(MyQuery::GetInfo).await.collect();
  12. Use `Output<T>` to broadcast events

    main

    Output<T> ports are used to broadcast events to one or more connected input ports (asynchronous model methods that return no value) or event sinks.

    Key Behaviors:

    • Cloning: Cloning an Output shares the underlying connections; all clones modify the same list of connected ports.
    • Serialization: Supports serde. To ensure successful state restoration, Output instances must be stored in data structures that guarantee ordering (e.g., Vec), as deserialization relies on consistent ordering within the model.
    • Broadcasting: When send(arg) is called, the event is broadcast to all connected recipients.
    // Example of creating and connecting an Output
    let mut output = Output::<MyEvent>::new();
    
    // Connect to a model method (input port)
    output.connect(model_method, address);
    
    // Connect to an event sink
    output.connect_sink(event_queue_writer);
    
    // Broadcast an event
    output.send(MyEvent::Data(123)).await;