Simulation Engineering Toolkit — Presentation 01

Rust for Simulation Engineers

Why Rust suits discrete-event simulators, taught through a real kernel: a BinaryHeap event list with SimPy's tie-breaking, events as enums, ownership in an event loop (arenas and indices, not references), SimPy processes as explicit state machines, traits for cost models, error handling, rayon sweeps, and the cargo, clippy, rustfmt, proptest and criterion toolchain.

Ownership BinaryHeap Enums & traits rayon clippy proptest criterion
Events → Heap → Handle → Schedule → Test → Bench
00

Topics We'll Cover

Concepts used here, and where they are explained. Each links to a glossary entry: this series glossary, or the glossaries of LLM Inference Simulators and FHE Accelerator Simulators for concepts those series already explain.

01

Why Rust for Simulators

A discrete-event simulator is a long-running loop over a priority queue that mutates a lot of shared state. Python (SimPy) is the right place to start: quick to write, easy to read, a good reference model. When the same model has to run thousands of configurations a night, the core is usually rewritten in a compiled language. LLM Inference Simulators 02, slide 12 sketched the options; this deck does the Rust one properly, using the code in Rust_DES_Kernel.

What Rust gives a simulator

  • No garbage collector. Memory is freed deterministically when its owner goes out of scope, so there are no collection pauses in the middle of a billion-event run.
  • Shared state made explicit. The borrow checker forces you to decide who owns each request, queue and counter. In a simulator, that is exactly the design question that causes bugs when it is left implicit.
  • Fearless parallel sweeps. The compiler refuses to share mutable state between threads, so a parameter sweep across all cores is safe by construction.
  • One toolchain. cargo builds, tests, benchmarks, formats and lints, with no build system to maintain.

What it costs

  • No stable generators. SimPy's "a process is a generator that yields timeouts" has no direct equivalent: coroutines are nightly-only. Processes become explicit state machines (slide 07) or async tasks.
  • A learning curve. Ownership and lifetimes take weeks to become natural, and the first design that occurs to you often does not compile (slide 06).
  • Compile times are longer than C's, and much longer than Python's zero.
  • A smaller simulation ecosystem than C++ (SystemC, gem5, SST) or Python (SimPy).
The rule this deck follows

Keep the Python model as the reference and make the Rust port reproduce it exactly. Deck 02 is about how; this deck is about writing the Rust well.

02

The Event List: BinaryHeap and Reverse

Every discrete-event kernel is a priority queue of future events and a clock. Rust's standard library has the queue: std::collections::BinaryHeap. It is a max-heap, so wrapping each entry in std::cmp::Reverse turns it into the min-heap a simulator needs: the earliest event comes out first.

src/kernel.rs: the scheduler's state source
/// The event queue and the clock.
#[derive(Debug)]
pub struct Scheduler<E> {
    now: Time,
    seq: u64,
    processed: u64,
    // `BinaryHeap` is a max-heap; `Reverse` turns it into the min-heap a DES needs.
    heap: BinaryHeap<Reverse<Entry<E>>>,
}
src/kernel.rs: scheduling (SimPy's env.timeout is schedule_in) source
/// Schedule `event` at absolute time `at` (never in the past).
pub fn schedule_at(&mut self, at: Time, priority: Priority, event: E) {
    assert!(!at.is_nan(), "event time is NaN");
    assert!(
        at >= self.now,
        "event scheduled in the past: {at} < {}",
        self.now
    );
    self.heap.push(Reverse(Entry {
        time: at,
        priority,
        seq: self.seq,
        event,
    }));
    self.seq += 1;
}
src/kernel.rs: popping advances the clock source
/// Remove the next event and advance the clock to its time.
pub fn pop(&mut self) -> Option<(Time, E)> {
    let Reverse(e) = self.heap.pop()?;
    self.now = e.time;
    self.processed += 1;
    Some((e.time, e.event))
}
03

Ordering Floats, Breaking Ties

BinaryHeap needs its entries to be Ord: a total order. f64 is only PartialOrd, because NaN compares unequal to everything, itself included. Rust makes you choose what to do about that, where C++ and Python let it slide.

src/kernel.rs: the ordering rule source
// `f64` is not `Ord` (NaN), so order by `total_cmp`. Times are never NaN:
// `schedule` rejects them.
impl<E> Ord for Entry<E> {
    fn cmp(&self, other: &Self) -> Ordering {
        self.time
            .total_cmp(&other.time)
            .then(self.priority.cmp(&other.priority))
            .then(self.seq.cmp(&other.seq))
    }
}

1. Earlier time first

f64::total_cmp implements IEEE 754's totalOrder, so the comparison is total. schedule_at also rejects NaN, so the odd places it puts NaN never matter.

2. Urgent before normal

SimPy gives process start-ups and interrupts priority URGENT at equal times. Copying that rule is what lets this kernel reproduce SimPy's event order.

3. First scheduled, first served

A sequence number breaks every remaining tie. Without it, simultaneous events come out in an order that depends on the heap's internal layout.

Why ties are not a corner case

With integer token counts and identical prompts, two transfers often finish at exactly the same instant, and requests that arrive in bursts share a timestamp. The Rust_DES_Kernel golden and differential tests include fixed-length and bursty workloads built to create such ties, because that is where a wrong ordering rule would show (deck 02, slide 09). LLM Inference Simulators 02, slide 10 lists the other determinism pitfalls.

04

Interactive: The Event Queue's Ordering Rule

Schedule events, then pop them. The queue below is a real binary heap (the same algorithm as BinaryHeap). Switch the tie-break rule to see why the sequence number is there: without it, the order of simultaneous events depends on the order the heap happens to hold them in.

Heap array (index 0 is the root)
Popped, in order

The "unit-test example" is the kernel's own test earlier_first_then_urgent_then_fifo: popping should give urgent, n1, n2, late. With six simultaneous events and no sequence number, the pop order is not the insertion order, and it changes if you insert in a different order: harmless here, fatal for reproducibility.

05

Events as Enums, Models as Traits

In Python an event is usually a callback. In Rust it is more natural to make events plain data, an enum, and to let the model decide what each one means. The compiler then checks that every handler deals with every kind of event.

src/kernel.rs: the whole model interface source
/// A model reacts to one event at a time and may schedule more.
pub trait Model {
    type Event;

    /// Handle one event. Return `false` to stop the run.
    fn handle(&mut self, sched: &mut Scheduler<Self::Event>, event: Self::Event) -> bool;
}
src/kernel.rs: the run loop source
/// Run `model` until it asks to stop or the queue empties. Returns the final time.
pub fn run<M: Model>(model: &mut M, sched: &mut Scheduler<M::Event>) -> Time {
    while let Some((_, ev)) = sched.pop() {
        if !model.handle(sched, ev) {
            break;
        }
    }
    sched.now()
}
src/queueing.rs: an M/D/1 queue in about twenty lines source
impl Model for Md1 {
    type Event = Event;

    fn handle(&mut self, s: &mut Scheduler<Event>, ev: Event) -> bool {
        match ev {
            Event::Arrive => {
                self.arrived += 1;
                if self.arrived < self.customers {
                    let gap = self.rng.expovariate(self.rate);
                    s.schedule_in(gap, Event::Arrive);
                }
                if self.busy {
                    self.queue.push_back(s.now());
                } else {
                    self.start_service(s, s.now());
                }
            }
            Event::Depart => {
                self.served += 1;
                self.busy = false;
                if let Some(t) = self.queue.pop_front() {
                    self.start_service(s, t);
                }
            }
        }
        self.served < self.customers
    }
}
06

Ownership in an Event Loop

The first design most people write holds a reference to a request while calling a method on the simulation that owns it. The borrow checker rejects it, and it is right to: finish() could push to requests, reallocate the vector and leave r dangling. This is the real compiler output:

borrow_error: rejected source
impl Sim {
    fn finish(&mut self, r: &mut Request) {
        r.finish = Some(self.now);
        self.done += 1;
    }

    fn step(&mut self) {
        let r = &mut self.requests[0];   // a mutable borrow of part of self ...
        self.finish(r);                  // ... while self is borrowed mutably again
    }
}
borrow_fixed: an index instead of a reference source
impl Sim {
    fn finish(&mut self, rid: usize) {
        self.requests[rid].finish = Some(self.now);
        self.done += 1;
    }

    fn step(&mut self) {
        let rid = 0;          // events and queues carry indices into an arena
        self.finish(rid);
    }
}
cargo build (rustc 1.99), recorded in snippets/RESULTS.md
error[E0499]: cannot borrow `*self` as mutable more than once at a time
  --> src/main.rs:15:9
   |
14 |         let r = &mut self.requests[0];   // a mutable borrow of part of self ...
   |                      ------------- first mutable borrow occurs here
15 |         self.finish(r);                  // ... while self is borrowed mutably again
   |         ^^^^        - first borrow later used here
   |         |
   |         second mutable borrow occurs here
Simulation owns everything; everything else holds indices Simulation reqs: Vec<Request> inst: Vec<Instance> kv_queue: VecDeque<usize> link: LinkStats handle(&mut self, sched, ev) borrows end at each statement Ev::StepDone(2)event = tag + index Instance.queueVecDeque<usize> of rids InFlight::PrefillVec<usize> batch Not needed Rc<RefCell<Request>> lifetimes on events unsafe pointers An index is Copy, 8 bytes, serialisable: log it, replay it.

The pattern is an arena with indices: the simulation owns every request and instance in a Vec; events, queues and batches carry usize indices; each borrow lasts one statement. Where a loop must call &mut self methods while walking a collection, take the collection out and put it back:

src/disagg/engine.rs: take, process, put back source
// Take the batch out so the loop can call `finish(&mut self)`.
let running = std::mem::take(&mut self.inst[i].running);
let mut still = Vec::with_capacity(running.len());
for r in running {
    let q = &mut self.reqs[r];
    q.itls.push(now - q.last_token);
    q.tokens_out += 1;
    q.last_token = now;
    if q.tokens_out >= q.output_len {
        self.inst[i].kv_used -= self.kv_need(r);
        self.finish(s, r);
    } else {
        still.push(r);
    }
}
self.inst[i].running = still;
07

From SimPy Processes to State Machines

In SimPy, a prefill instance is a generator: "form a batch, yield env.timeout(step), emit tokens, loop". Each yield is a point where the process sleeps. Without stable generators, the Rust engine names those sleep points as states and makes each event resume the instance from where it slept.

A prefill instance: two sleep points, two events Waiting SimPy: yield self._wake Rust: waiting = true Step in flight SimPy: yield env.timeout(t) Rust: inflight = Some(batch) Ev::Wake: form batch, schedule StepDone Ev::StepDone, queue empty: account, emit tokens Ev::StepDone, queue not empty: next batch submit while busy: just queue
src/disagg/engine.rs: submit wakes a waiting instance exactly once, as SimPy's _wake.succeed() does source
fn submit(&mut self, s: &mut Scheduler<Ev>, i: usize, rid: usize) {
    let inst = &mut self.inst[i];
    inst.queue.push_back(rid);
    if inst.waiting && !inst.wake_triggered {
        inst.wake_triggered = true;
        s.schedule_now(Ev::Wake(i));
    }
}

The flag wake_triggered mirrors SimPy's rule that an event already triggered is not triggered again. It matters only when two requests reach a waiting instance before its wake-up runs, and ordinary Poisson workloads never do that. A bursty workload does: requests queue behind a busy prefill, leave in one batch, and their KV transfers finish together into an idle decoder. That case is now a golden test; with the flag removed, the engine wakes the decoder twice and its invariant check stops the run with "a step was in flight" (slide 09). Async Rust is the other route to process-style code; the explicit machine was chosen here because its event order is easy to reason about and to match against SimPy.

08

Traits for Pluggable Cost Models

The most important structural decision in an architecture simulator is to keep time in one replaceable place (LLM Inference Simulators 02, slide 09). In Rust that place is a trait. This example compiles and its tests pass (snippets/RESULTS.md):

cost_trait/src/lib.rs: the contract source
/// What every cost model must answer. The engine knows nothing else about time.
pub trait CostModel {
    fn prefill(&self, tokens: u64) -> f64;
    fn decode(&self, batch: u64, context: u64) -> f64;
}
cost_trait/src/lib.rs: two ways to use it source
/// Static dispatch: one copy of the engine per cost model, every call inlinable.
pub fn total_decode_time<C: CostModel>(cost: &C, steps: u64) -> f64 {
    (0..steps).map(|k| cost.decode(8, 4096 + 8 * k)).sum()
}
Generic <C: CostModel>Box<dyn CostModel>
DispatchStatic: the engine is compiled once per cost model (monomorphisation)Dynamic: one engine, a virtual call through a vtable
SpeedCalls can be inlined; best for the innermost loopOne indirect call per use; usually negligible next to a step's work
Choice madeAt compile timeAt run time, from a configuration file
Use it forThe hot path once the model is fixedSwapping roofline, measured tables and calibrated models without rebuilding

Rust_DES_Kernel itself uses one concrete CostModel struct, because its job is to mirror one Python class exactly. A trait is the next step when a second model (a measured table, a calibrated fit) has to plug in.

09

Errors: Result, the ? Operator, and When to Panic

Rust separates two kinds of failure, and a simulator has both.

Expected failures: return a Result

A user asks for a link that does not exist, or a power cap below idle power. The function returns Err, the caller decides, and ? passes the error upward in one character. At the Python boundary, the same error becomes a ValueError.

Broken invariants: panic!

An event scheduled in the past, or a step completing on an instance with nothing in flight, means the simulator itself is wrong. Carrying on would produce plausible nonsense, so the kernel stops at once, with a message pointing at the cause. #[should_panic] tests that it does.

src/disagg/engine.rs: configuration errors are values source
pub fn build(&self) -> Result<SimConfig, ConfigError> {
    let unknown = |what: &str, v: &str| ConfigError(format!("unknown {what} {v:?}"));
    let names = [
        Some(&self.model),
        Some(&self.device),
        self.prefill_device.as_ref(),
        self.decode_device.as_ref(),
    ];
    for key in names.into_iter().flatten() {
        if let Some(why) = hardware::python_js_only(key) {
            return Err(ConfigError(format!("{key} {why}")));
        }
    }
    if let Some(t) = &self.kv_transit {
        return Err(ConfigError(format!(
            "kv_transit {t:?}: KV hand-off compression is Python and JS only, not in the Rust port"
        )));
    }
    let dev = |key: &Option<String>| -> Result<Option<Accelerator>, ConfigError> {
        key.as_ref()
            .map(|k| hardware::device(k).ok_or_else(|| unknown("device", k)))
            .transpose()
    };
    let mode = match self.mode.as_str() {
        "disagg" => Mode::Disagg,
        "colocated" => Mode::Colocated,
        m => return Err(unknown("mode", m)),
    };
    let mut link = hardware::link(&self.link).ok_or_else(|| unknown("link", &self.link))?;
src/python.rs: and become Python exceptions at the boundary source
/// Run one simulation. Returns (per-request stamps, summary JSON, events processed,
/// seconds simulating, seconds summarising).
#[pyfunction]
#[pyo3(signature = (config_json, rows, with_summary=true))]
fn simulate_json(
    py: Python<'_>,
    config_json: &str,
    rows: Vec<Row>,
    with_summary: bool,
) -> PyResult<Output> {
    let spec = spec(config_json)?;
    // The GIL is released for the whole run: nothing in here touches Python.
    py.detach(|| run_one(&spec, &rows, with_summary))
        .map_err(PyValueError::new_err)
}
A useful habit

Validate everything when the configuration is built, so that once a run starts, the only possible failures are bugs. Rust_DES_Kernel's ConfigSpec also uses #[serde(deny_unknown_fields)], so a misspelt option in a JSON config is an error rather than a silently ignored default.

10

Fearless Parallel Sweeps

Architecture questions are answered by sweeps: rates, pool sizes, links, power caps. Independent runs share nothing, so they parallelise perfectly. The rayon crate turns iter() into par_iter(), and the compiler checks the rest.

src/bin/disagg-rs.rs: a rate sweep across all cores source
// Independent runs share nothing, so rayon can farm them out safely:
// the compiler checks that each closure only reads `cfg`.
let rows: Vec<String> = a
    .sweep
    .par_iter()
    .map(|&rate| {
        let m = summarise(&simulate(cfg.clone(), wl(rate)).expect("valid config"));
        let g = |p: &[&str]| {
            p.iter()
                .fold(&m, |v, k| &v[*k])
                .as_f64()
                .unwrap_or(f64::NAN)
        };
        format!(
            "{rate},{:.6},{:.6},{:.4},{:.4}",
            g(&["latency_s", "ttft", "p99"]),
            g(&["latency_s", "tpot", "p99"]),
            g(&["throughput", "slo_attainment"]),
            g(&["energy", "J_per_output_token"])
        )
    })
    .collect();
WhatRunsWall timeSpeed-up over sequential
Rust via PyO3, sequential8 x 4,000 requests0.430 s1.0x
Rust via PyO3, 8 Python threads (GIL released)8 x 4,000 requests0.144 s3.0x
Rust simulate_many (rayon, one call)8 x 4,000 requests0.131 s3.3x
Python SimPy, sequential4 x 1,000 requests1.11 s1.0x
Python SimPy, 4 threads (GIL held)4 x 1,000 requests2.40 s0.5x
Python SimPy, 4 processes4 x 1,000 requests0.30 s3.7x

Source: examples/results.md in Rust_DES_Kernel

The rows that matter here are the first three: rayon inside one call, or plain Python threads calling a Rust function that releases the GIL, both reach about 3× on four physical cores. Deck 02 explains the GIL rows.

This slide's and the next one's tables were re-measured on 2026-10-03, after the simulator's cost model was corrected: steps no longer charge the whole embedding table, and decode attention includes each new token's attention to itself (deck 10).

11

The Toolchain: cargo, clippy, rustfmt, criterion

CommandWhat it doesIn Rust_DES_Kernel
cargo build --releaseCompile with optimisation; dependencies from Cargo.toml[profile.release] adds thin LTO and one codegen unit
cargo fmt --checkrustfmt: one canonical layout, so diffs show logic, not styleEnforced in CI
cargo clippy -- -D warningsHundreds of lints; -D warnings makes any finding fail the buildIt flagged !(v > bv) on floats (neg_cmp_op_on_partial_ord): with NaN that is not the same as v <= bv, so the code now says which it means
cargo testUnit tests in each module, integration tests in tests/, doc testsThe full inventory is in deck 06, slide 11
cargo benchcriterion: warm-up, many samples, confidence intervals, change detectionThe numbers below
cargo llvm-cov, cargo mutants, cargo nextestCoverage, mutation testing and a faster test runner with JUnit outputDeck 06 and the Jenkins pipeline
BenchmarkMean timeThroughput
kernel/md1_100k_customers6.78 ms29.5 M events/s
disagg/1P1D_1000req3.94 ms5.2 M events/s
disagg/colocated_1000req4.52 ms6.1 M events/s

Source: examples/results.md in Rust_DES_Kernel

The bare kernel handles about 30 million events a second; the full serving engine, which does real work per event (cost model, batching, accounting), handles 5 to 6 million.

12

Testing in Rust: cargo test and proptest

Tests live next to the code (#[cfg(test)] mod tests, which can reach private items) or in tests/ (integration tests, which see only the public API). proptest adds property-based testing: generate inputs, check a property, shrink any failure to a minimal case.

tests/kernel.rs: the ordering contract, for any sequence of schedules source
proptest! {
    /// Whatever is scheduled, events come out in (time, priority, insertion) order.
    #[test]
    fn pops_in_time_priority_fifo_order(ops in prop::collection::vec((0u8..20, any::<bool>()), 1..300)) {
        let mut s = Scheduler::new();
        for (k, &(t, urgent)) in ops.iter().enumerate() {
            let p = if urgent { Priority::Urgent } else { Priority::Normal };
            s.schedule_at(t as f64, p, (t, p, k));
        }
        let mut last: Option<(u8, Priority, usize)> = None;
        while let Some((time, e)) = s.pop() {
            prop_assert_eq!(time, e.0 as f64);
            if let Some(l) = last {
                prop_assert!(l < e, "{:?} came out before {:?}", l, e);
            }
            last = Some(e);
        }
        prop_assert_eq!(s.processed() as usize, ops.len());
    }
}
FileKindChecks
src/*.rsUnitOrdering and tie-breaks; CPython's random stream; fsum; cost-model regimes
tests/kernel.rsAnalytic + propertyM/D/1 against Pollaczek–Khinchine; pop order for random schedules
tests/props.rsPropertyFor random configurations: conservation, ordered timestamps, KV released, TDP respected, Little's law, determinism
tests/golden.rs, tests/pymath.rsGoldenRecorded Python runs, Python-generated workloads, and CPython's fsum and //, reproduced bit for bit
tests/cli.rsEnd to endThe binary's JSON, text report, sweep and exit codes

Deck 06 covers the strategy behind these layers, mutation testing (which found gaps here that 95% line coverage did not), and the Python and C++ equivalents.

13

What to Take Away