1 unstable release
0.1.0 | Jul 8, 2024 |
---|
#94 in Simulation
47 downloads per month
Used in anysystem
165KB
2K
SLoC
SimCore
SimCore is discrete-event simulation framework aimed to provide a solid foundation for building simulation models of distributed and other kinds of systems. The framework is built around a generic event-driven programming model that can be used to model different domains. It allows to use both callbacks and asynchronous waiting to conveniently model any execution logic.
You may be looking for:
License
SimCore is licensed under the Apache-2.0 license or the MIT license, at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in SimCore by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.
lib.rs
:
SimCore is discrete-event simulation framework aimed to provide a solid foundation for building simulation models of distributed and other kinds of systems. The framework is built around a generic event-driven programming model that can be used to model different domains. It allows to use both callbacks and asynchronous waiting to conveniently model any execution logic.
Contents
Basic Concepts
SimCore supports writing arbitrary simulation models which consist of user-defined components emitting and processing events.
Component. A component represents a part of the model with some internal state and execution logic. Each component is assigned a unique identifier which can be used to emit events for this component. A component can access simulation state, emit or wait for events via context provided by the framework. The model execution is driven by the framework which calls components upon occurrence of events. The called component can examine the received event, read the current simulation time, modify internal state and emit new events according to the code written by the user. The components are added by creating named contexts and registering event handlers. The former is required for components which emit events, while the latter is required for components which process events.
Event. An event contains a timestamp, identifiers of event source and destination, and a used-defined payload. The event timestamp corresponds to the simulation time at which the event is supposed to occur. This time must be specified when the event is emitted. Due to performance reasons, event timestamps cannot be changed. However, an event can be canceled before it occurred and rescheduled by creating a new event. Each event is associated with exactly one destination component. The framework allows to model different types of events by using arbitrary data structures as event payloads. The event payload is opaque to the framework and is passed in a zero-copy fashion between the event source and destination.
The initial set of events is created before the simulation start via some of the components. For example, in case of a trace-driven simulation, a dedicated component can be used as a source of external events from a trace.
Simulation. Following the discrete-event simulation approach, the execution of a user-defined model is implemented by processing a sequence of events emitted by the model components. The framework processes events in their timestamp order by advancing the simulation clock to the event's timestamp and invoking the component specified as the event destination. When processing the event, the component can create and emit new events with arbitrary future timestamps via its context. It is also possible to cancel the previously emitted events before they are processed.
The described approach for building simulation models is chosen based on the following considerations.
First, it suits well for modeling distributed systems. Indeed, such systems are frequently modeled as a set of processes which communicate with each other by sending messages via a network. In such models, events can be either internal process events or receptions of messages from other processes. The described approach allows to model both types of events and fits well to message passing - a message can be sent to another process by simply emitting an event to the corresponding component with delay equal to the message transmission time.
Second, the described model is abstract and flexible enough to support different simulation needs, even beyond distributed systems. If the framework were instead based on a more specific and restricted model, such as message passing, it would complicate the modeling of other activities, such as computations. An alternative approach chosen by some frameworks is to provide a predefined set of built-in activities and events. However, this would introduce a trade-off between the ease of use for modeling specific types of systems and the flexibility of the framework.
We overcome this trade-off by keeping the framework as general as possible and by building separate libraries with domain-specific models on top of it. This allows the users to choose only features they need and to create new libraries when some features are missing without bloating the framework. For example, there is no notion of processes, hosts and network in SimCore. Such abstractions and their models can be added if needed via separate libraries. Depending on a purpose, some users may need a complex network model, while for others a fixed delay supported by the framework is sufficient.
Example
This example demonstrates the use of SimCore programming interfaces and receiving events via callbacks. See the next sections for details and an alternative to callback-based approach.
Programming Interfaces
Simulation
is the main interface of the framework which allows to configure and execute a simulation model. As
demonstrated in the example above, it can be instantiated with a user-defined random seed and then used to create
simulation contexts and register event handlers for components of user-defined type Process
, run the simulation
and obtain the current simulation time. Besides the step_until_no_events
method, it provides other methods for precise stepping through the simulation. It also provides access to the
simulation-wide random number generator which is initialized with the user-defined seed to support deterministic
simulations.
SimulationContext
is the interface for accessing the simulation state and emitting events from components. Each
component is associated with a uniquely named context which is created via the
Simulation::create_context
method. The context is typically passed to the
component's constructor and is stored inside the component as illustrated in the example above. This example also
illustrates the use of the stored context to emit the user-defined events Request
and Response
, to obtain the
current simulation time, and to generate random numbers using the simulation-wide generator.
SimCore allows a user to keep a reference to a component to call it directly, as illustrated by proc1_ref
in the
example above. Moving components completely inside the framework and allowing to interact with them only via events
or framework interfaces would harm the usability. It would be more cumbersome to emit a special event to proc1
instead of calling send_request
method. This also allows to easily inspect component states during the simulation.
The same observation applies to the interaction between components - if immediate request/response is assumed, it is both more convenient and efficient to interact via direct calls instead of events. For example, a component modeling CPU can be called directly by other components running on the same simulated machine to request a computation. In response, the CPU component can return the request handle and notify the requester via an event when the computation is completed. Therefore, the framework does not restrict the interaction with and between components to happen only via events. This is in contrast to similar but more strict models such as actor model for message passing.
The described interfaces deal only with calling SimCore from a user's code. However, the framework should also be able to call user's components to notify them about occurred events. There are two supported approaches for programming this logic described below.
Receiving Events via Callbacks
The default approach for receiving events in components is based on implementing the EventHandler
interface.
This interface contains a single on
method which is called by the framework to pass an
event to the destination component. This approach is illustrated in the example above where the Process
component
implements this interface to receive Request
and Response
events. The pattern matching syntax is used to
identify the type of received event. When a component implements the EventHandler
interface it must be
registered in the framework via the Simulation::add_handler
method.
Consider in detail the provided example. It describes a simulation model consisting of two components proc1
and
proc2
. The behavior of these components is defined by the Process
type. This type implements the
EventHandler
interface to receive and process events of two types: Request
and Response
:
-
The logic for processing
Request
is defined in theon_request
callback method - the process emitsResponse
to the source ofRequest
with some delay including the random request processing time and the network delay. The request sending time stored inRequest
is copied to the correspondingResponse
. -
The logic for processing
Response
is defined in theon_response
callback method - the process reads the request time fromResponse
to calculate and print the response time, i.e. the time elapsed between the sending of request and receiving the response.
The process implementation also includes the send_request
method to trigger emitting of Request
to another
process.
The example models a simple scenario where proc1
emits a request to proc2
and the simulation runs until proc1
receives a response.
Limitations of Callbacks
While the callback-based approach is simple and intuitive by organizing all event processing logic in EventHandler
,
it may also complicate the implementation of a more complex logic inside components. In particular, when modeling
some multistep activity, where each step requires awaiting some events, these steps should be spread across several
event handler functions. This makes the implementation of such complex activities more verbose and hard to follow.
For example, in the provided example, the sending of request and receiving of response are split into two separate
methods, while it would be more convenient to await
a response event in the code immediately after sending the
request. This also complicates the calculation of response time because, in order to do it in on_response
callback
method, the request sending time should be passed inside events or stored inside the process.
Also, the random processing time is modeled in on_request
by simply adding it to the response event delay, while
it would be more natural to sleep
for this time inside the code before emitting the response. The trick with delay
would also not work when the processing time is not known in advance. For example, the processing of request may
include some computation which completion is determined by a separate model and signaled to the process via an event.
In this case, the request processing logic should also be split into several methods making it harder to follow.
Async Mode
To overcome the described limitations of callback-based approach, the SimCore interfaces have been enriched with primitives for spawning asynchronous activities and awaiting events and timers. This functionality, dubbed async mode, is implemented as an optional feature that can be enabled by a user and used in conjunction with the callback-based approach.
The code below illustrates the use of async mode to improve the previously described callback-based implementation.
First, the sending of request and receiving of response are now conveniently located in a single
send_request_and_get_response
method. This method represents the asynchronous activity spawned in send_request
via SimulationContext::spawn
. Waiting for response event inside this activity
is implemented via SimulationContext::recv_event
method, which returns a
future that can be awaited without blocking the simulation. Collocating the request-response logic inside a single
method allows to calculate the response time without having to pass the request time inside events.
Second, the request processing is now modeled in process_request
method which represents the asynchronous activity
spawned upon receiving of request. The random request processing time is modeled in process_request
by calling the
SimulationContext::sleep
method, which allows to suspend the component
execution for a specified time.
The code for configuring and running the simulation is slightly changed. To be able to spawn asynchronous
activities, components must implement the special StaticEventHandler
trait and register its implementation
using the Simulation::add_static_handler
method.
As demonstrated, the async mode eliminates the described limitations of the callback-based approach. This example also illustrates that SimCore allows to use both approaches simultaneously to combine their advantages. While callbacks are convenient for describing a simple event processing logic or receiving events triggering a complex logic, the latter can be conveniently described using the async mode primitives.
Another notable feature of async mode is the support for selective receive of events by a user-defined key (see
SimulationContext::recv_event_by_key
). This is convenient in cases
when component performs multiple asynchronous activities, and each activity must wait for events of the same type.
It is also possible to wait for multiple events simultaneously using the join
and select
primitives from the
futures crate.
On the downside, async mode has additional performance overhead in comparison to callbacks. The observed slowdown depends on an application and is around 10-50% according to our experience.
Dependencies
~3–14MB
~119K SLoC