Build a discrete-event simulator from an empty file: the event queue, simulated time, processes, resources and probes; then SimPy idioms, how to structure a simulator so it survives contact with architects, modelling buses, memories and pipelines, and making the simulator itself fast.
Concepts used here, and where they are explained. Each links to its series glossary entry: a short explanation, then links to the slides that explain it in depth, in this series, the Local LLM Hosting and Key Publications decks, or the FHE Accelerator Simulators series.
A discrete-event simulator (DES) models a system whose state only changes at instants: a request arrives, a transfer finishes, a batch completes. Between those instants nothing interesting happens, so the simulator jumps straight from one event to the next. This is next-event time advance, and it is why a DES can cover an hour of traffic in seconds.
now.now to its time.That is the entire engine. Everything else is the model.
A fixed-step simulator advances by Δt and checks everything each tick. It wastes work when little happens and blurs ordering when much happens within one tick. Clocked hardware is the exception: inside a synchronous block, cycle stepping is natural, which is exactly what RTL and cycle-accurate simulators do.
Write this once yourself before using a library. Every DES library, SimPy included, is a refinement of it.
import heapq, itertools
class Sim:
def __init__(self):
self.now = 0.0
self._q = [] # heap of (time, seq, handler, args)
self._seq = itertools.count() # tie-breaker: FIFO among equal times
def schedule(self, delay, handler, *args):
assert delay >= 0, "no time travel"
heapq.heappush(self._q, (self.now + delay, next(self._seq), handler, args))
def run(self, until=float("inf")):
while self._q and self._q[0][0] <= until:
t, _, handler, args = heapq.heappop(self._q)
self.now = t
handler(*args)
self.now = min(until, self.now) if self._q else self.now
The first model to build on any new kernel is the single-server queue, because queueing theory gives you its exact answer. With Poisson arrivals (rate λ) and exponential service (rate μ), the M/M/1 mean time in system is W = 1 / (μ − λ).
import random
sim, rng = Sim(), random.Random(42)
LAM, MU = 0.8, 1.0
queue, busy, times = [], [False], []
def arrive():
queue.append(sim.now)
sim.schedule(rng.expovariate(LAM), arrive)
if not busy[0]: start()
def start():
busy[0] = True
sim.schedule(rng.expovariate(MU), depart, queue.pop(0))
def depart(t_arrived):
times.append(sim.now - t_arrived)
busy[0] = False
if queue: start()
sim.schedule(0, arrive); sim.run(until=200_000)
print(sum(times) / len(times), "vs theory", 1 / (MU - LAM)) # ~5.0 vs 5.0
An analytic check catches kernel bugs (ordering, off-by-one in time) that no amount of eyeballing will. The simulator in this series keeps the same habit: its KV link is tested against the M/D/1 Pollaczek–Khinchine formula on every CI run.
A two-stage pipeline: jobs arrive, are computed on one unit, then transferred over one link. Step through and watch the clock jump between event times, the event list re-order, and queues form when the link is slower than the compute.
The kernel underneath is exactly the 30-line one from slide 02: a sorted list of (time, sequence, kind, job). Make the transfer slower than the compute and the link queue grows without bound: the link is the hot-spot.
Event-scheduling code scatters one job's life across several handlers (arrive, start, depart). The process-interaction worldview writes each entity's life as one sequential function that suspends whenever it has to wait. Python generators make this natural: yield hands an event to the kernel, and the kernel resumes the generator when that event fires.
import simpy, random
rng = random.Random(42)
env = simpy.Environment()
server = simpy.Resource(env, capacity=1)
times = []
def job(env):
t0 = env.now
with server.request() as req:
yield req # wait for the server
yield env.timeout(rng.expovariate(1.0))
times.append(env.now - t0)
def source(env):
while True:
yield env.timeout(rng.expovariate(0.8))
env.process(job(env))
env.process(source(env))
env.run(until=200_000)
Resource.yield, and the kernel is the scheduler.with block releases the server even on an exception or interrupt.When a process yields an event, SimPy appends the process's resume callback to that event. When the event is triggered, it goes into the heap; when popped, its callbacks run and the generator continues with send(). It is still the 30-line loop.
SimPy (v4, pure Python, MIT-licensed) is small enough to learn in an afternoon. This table is most of it.
| Concept | API | Hardware use |
|---|---|---|
| Environment and clock | simpy.Environment(), env.now, env.run(until=t | event) | One per simulation; time units are yours (pick seconds or ns and stick to it) |
| Process | env.process(gen), which is itself an event that fires when the generator returns | A compute engine, DMA, scheduler loop, traffic source |
| Delay | yield env.timeout(dt) | Compute time from the cost model, wire latency |
| Raw event | ev = env.event(); ev.succeed(value) | Interrupt lines, doorbells, "data ready" flags |
| Conditions | yield a & b, yield a | b (AllOf / AnyOf) | Wait for all DMA channels; wait for data or timeout |
| Resource | Resource, PriorityResource, PreemptiveResource | Buses, links, functional units, arbiters with priority |
| Container | Container(capacity, init), put(n), get(n) | Credits, buffer occupancy, memory capacity in bytes |
| Store | Store(capacity), FilterStore, PriorityStore | FIFOs with back-pressure, mailboxes, packet queues |
| Interrupt | proc.interrupt(cause) raises simpy.Interrupt inside the process | Pre-emption, reset, power-gating, error injection |
| Real time | simpy.rt.RealtimeEnvironment | Hardware-in-the-loop demos (rarely needed) |
No statistics, no tracing, no plotting, no configuration system. That is a feature: you write the probes and metrics that your architecture needs, which is most of the engineering in a real simulator (deck 06).
Most SoC building blocks map onto three primitives. A bounded FIFO between a producer and a consumer shows the most important one: back-pressure.
fifo = simpy.Store(env, capacity=4) # put() blocks when full: back-pressure
hbm = simpy.Resource(env, capacity=2) # two memory channels
def dma(env, tiles):
for tile in tiles:
with hbm.request() as ch:
yield ch
yield env.timeout(LAT + tile.nbytes / CH_BW) # cost model, not magic numbers
yield fifo.put(tile) # stalls here if compute falls behind
def compute(env, stats):
while True:
tile = yield fifo.get() # stalls here if DMA falls behind
t0 = env.now
yield env.timeout(tile.flops / PEAK)
stats.busy += env.now - t0 # utilisation probe
Anything with N identical servers that a job holds for a while: links, channels, engines, ports. Use PriorityResource to model an arbiter.
Anything that holds items: FIFOs, queues, packet buffers. A finite capacity gives you back-pressure for free.
Anything that holds an amount: credits, bytes of SRAM, KV-cache tokens. Getting more than is there blocks until someone puts some back.
The most common modelling decision in a data-movement simulator, and the one most often made by accident. Two transfers want the same link at once. What happens?
Resource)The first transfer owns the whole link until it finishes; the second waits, then runs at full rate. Simple and cheap: one event per transfer. Right for packet-switched links with large messages and for DMA engines that serialise.
Both proceed at half rate. Every arrival or departure changes every active transfer's finish time, so you must re-compute and re-schedule (or use a "fluid" model). Closer to many-flow networks and to NoCs with fine-grained interleaving.
| Two 10 ms transfers start together | Transfer A done | Transfer B done | Mean |
|---|---|---|---|
| FCFS hold | 10 ms | 20 ms | 15 ms |
| Processor sharing | 20 ms | 20 ms | 20 ms |
Same throughput, same utilisation, different latency distribution. If your metric is a tail latency, this choice can change the answer. Write it down in the model's specification and test it.
Simulators that architects actually use share one structure: separate the things that change at different rates.
| Pitfall | Symptom | Cure |
|---|---|---|
| Simultaneous events | Results change when unrelated code is reordered | Deterministic tie-break (sequence number, explicit priorities); never rely on hash or set order |
| Floating-point time | Events at "the same" time drift apart after millions of additions; 0.1+0.2 != 0.3 | Integer time base (ps or cycles) for clocked models; or compare with tolerance |
| Zero-delay loops | Simulator hangs with the clock frozen | Assert progress; cap events per timestamp; watchdog on wall-clock time |
| Shared RNG | Adding one random draw in component A changes every result in component B | One RNG stream per component, seeded from a master seed |
| Warm-up bias | Latencies look great because the system started empty | Discard the warm-up period; check stationarity (deck 06) |
| End-of-run bias | Long requests still running at the end are silently dropped | Run to drain, or measure only requests that arrived in the window |
| Hidden infinite capacity | Queues never fill, so there is no back-pressure anywhere | Give every buffer a capacity; report its high-water mark |
Same config + same seed must give bit-identical output. It makes regressions diagnosable, lets CI compare against golden results, and lets you replay one interesting request. The simulator in this series has a test that asserts it.
Simulator speed is an architecture-team productivity metric: a sweep of 500 configurations at 10 minutes each is a three-day wait. In order of payoff (deck 08 takes each of these further, with measurements):
cProfile or py-spy for where the time goes; count events per simulated second. Most slow simulators are slow because of probes, logging or per-event object allocation, not the kernel.multiprocessing, a job array, or Jenkins agents. This usually beats any single-run optimisation.The IEEE 1666 C++ library used for virtual platforms. TLM-2.0 offers loosely-timed modelling (blocking transport, temporal decoupling with a time quantum, fast enough to boot software) and approximately-timed modelling (phased non-blocking transport for contention). It is the standard interchange format for IP models.
gem5 (CPU and memory systems), SST (Sandia; parallel, component-based), ASTRA-sim (distributed ML), Accel-Sim (GPUs). When Python runs out of speed, this is where the field lives, and their source code is the best textbook on structuring large simulators.
Memory safety without a garbage collector makes Rust attractive for simulation kernels: a BinaryHeap event list, components as structs, and PyO3 to expose it to Python so the configuration and analysis stay in notebooks. Async-based Rust DES frameworks (for example NeXosim) are emerging too.
use std::{cmp::Reverse, collections::BinaryHeap};
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Ev { t_ps: u64, seq: u64, kind: Kind } // integer picoseconds: exact ordering
fn run(q: &mut BinaryHeap<Reverse<Ev>>, model: &mut Model) {
while let Some(Reverse(ev)) = q.pop() {
model.now = ev.t_ps;
model.handle(ev.kind, q); // handlers push follow-on events
}
}
Each takes an evening and builds a skill this series relies on. The Disaggregated_Inference_Sim repo is the worked solution for the last three.
W for ρ from 0.1 to 0.95. Watch the variance grow near saturation.L = λW) on your model, and wire it into CI.Deck 03 covers the physics of the workload we want to simulate: prefill, decode, the roofline and the KV cache.