dscale 0.7.2

A fast & deterministic simulation framework for benchmarking and testing distributed systems
Documentation

DScale

Crates.io License Documentation

A fast, deterministic simulation framework for testing and benchmarking distributed systems. It simulates network latency, bandwidth constraints, and process execution in an event-driven environment with support for both single-threaded and parallel execution modes.

Usage

1. Define Messages

Messages must implement the Message trait, which allows defining a virtual_size for bandwidth simulation.

use dscale::*;

#[derive(Debug)]
struct MyMessage {
    data: u32,
}

impl Message for MyMessage {
    fn virtual_size(&self) -> usize {
        // Size in bytes used for bandwidth simulation.
        // Can be much bigger than real memory size to simulate heavy payloads.
        1000
    }
}

// Or (if there is no need in bandwidth)
impl Message for MyMessage {}

2. Implement Process Logic

Implement Process to define how your process reacts to initialization, messages, and timers.

use dscale::*;

#[derive(Default)]
struct MyProcess;

impl Process for MyProcess {
    fn on_start(&mut self) {
        schedule_timer_after(Jiffies(100));
    }

    fn on_message(&mut self, from: Pid, message: MessagePtr) {
        if let Some(msg) = message.try_as_type::<MyMessage>() {
            dscale_debug!("Received message from {from}: {}", msg.data);
        }
    }

    fn on_timer(&mut self, _id: TimerId) {
        broadcast(MyMessage { data: 42 });
    }
}

3. Run the Simulation

Use SimulationBuilder to configure the topology, network constraints, and start the simulation.

use dscale::*;

fn main() {
    let mut runner = SimulationBuilder::new()
        .add_pool::<MyClient>("Client", 1)
        .add_pool::<MyServer>("Server", 3)
        .default_latency(Distr::Uniform{low: Jiffies(1), high: Jiffies(5)})
        .between_pool_latency("Client", "Server", Distr::Normal {
            mean: Jiffies(10),
            std_dev: Jiffies(2),
            low: Jiffies(5),
            high: Jiffies(20),
        })
        .vnic_bandwidth(BandwidthConfig::Bounded{inbound: 1000, outbound: 1000})
        .time_budget(Jiffies(1_000_000))
        .name("My simulation optional name")
        .seq_sched()
        .build();

    runner.run_full_budget();
}

Parallel Execution

For large simulations, enable parallel execution to distribute process steps across multiple threads:

let mut runner = SimulationBuilder::new()
    .add_pool::<MyProcess>("Nodes", 1000)
    .within_pool_latency("Nodes", Distr::Uniform{low: Jiffies(1), high: Jiffies(10)})
    .time_budget(Jiffies(1_000_000))
    .par_sched(ThreadNumber::Specific(8)) // use 8 worker threads
    .build();

runner.run_full_budget();

When is the parallel scheduler efficient?

  1. A lot of simulated processes (at least 200-300)
  2. on_message/on_timer execution takes most of the simulation time
  3. Independent work inside on_message/on_timer handlers (not so much synchronization)

Omtimizations

For faster simulations we advise you to use these settings in your Cargo.toml:

[profile.release]
lto = "fat"           # Link Time Optimization: enables cross-crate optimizations
codegen-units = 1     # Reduces parallelism in code generation for better optimization
panic = "abort"       # Removes stack unwinding code, slightly smaller and faster binary

Distributing a Simulation Series over MPI

Enable the optional mpi feature:

dscale = { version = "0.8", features = ["mpi"] }

To sweep parameters across an MPI cluster, hand mpi::distribute your argument packs and a closure that builds and runs one simulation per pack. Launch the binary with mpirun — each rank runs its own slice of the packs.

use dscale::*;

fn main() {
    let pool_sizes = [3usize, 5, 7, 9];

    let results = mpi::distribute(pool_sizes, |size| {
        let mut sim = SimulationBuilder::new()
            .add_pool::<MyProcess>("Nodes", size)
            .within_pool_latency("Nodes", Distr::Uniform { low: Jiffies(0), high: Jiffies(10) })
            .time_budget(Jiffies(1_000_000))
            .build();
        sim.run_full_budget().steps()
    });

    // `results` holds THIS rank's outcomes — persist them per rank.
    for steps in results {
        println!("rank {}/{}: {steps} steps", mpi::rank(), mpi::size());
    }
}
mpirun -n 16 --hostfile hosts ./target/release/my_sweep

No MPI library linkage is needed — only the launcher, which sets the rank/size environment variables DScale reads (OpenMPI, MPICH/Hydra, and Slurm are supported). Without a launcher the program runs the full series in one process.

Fault Injection

DScale supports injecting network faults into simulations. Faults are scheduled as events — you specify when a fault starts and when it ends.

let mut runner = SimulationBuilder::new()
    .add_pool::<MyProcess>("Nodes", 5)
    .within_pool_latency("Nodes", Distr::Uniform{low: Jiffies(1), high: Jiffies(5)})
    .time_budget(Jiffies(1_000_000))
    // Break the link between pid 0 and pid 1 from time 100 to 500
    .break_link(Jiffies(100), Jiffies(500), 0, 1)
    // Isolate pid 2 (all links broken) from time 200 to 800
    .isolate(Jiffies(200), Jiffies(800), 2)
    .seq_sched()
    .build();

runner.run_full_budget();

The full API reference — every SimulationBuilder method, process interaction function, the key-value store, logging macros, and helpers — is documented and published on docs.rs.

Logging Configuration (RUST_LOG)

DScale output is controlled via the RUST_LOG environment variable.

  • RUST_LOG=[some_level]: Enables all dscale_[level <= some_level]! macros output.
  • RUST_LOG=full::path::to::your::file::or::crate=[level],another::path=[level]: Filter events only for your specific file or crate.

[!WARNING]
RUST_LOG=[level > info] only works without the --release flag.

Examples

You can find usage examples here

Paper

You can find paper describing algorithms behind dscale here

Thanks to