LLM Inference Simulators — Presentation 02

Simulator Development — a Hands-On Tutorial

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.

Discrete-event simulation SimPy Event queue Resources Determinism Performance
Events → Processes → Resources → Cost model → Probes → Tests
00

Topics We'll Cover

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.

01

Anatomy of a Discrete-Event Simulator

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.

The four ingredients

  • Clock: the current simulated time, now.
  • State: queues, busy flags, counters: the model.
  • Event list: a priority queue of future events ordered by time.
  • Handlers: code that runs at an event, changes state and schedules more events.

The main loop

  1. Pop the earliest event.
  2. Set now to its time.
  3. Run its handler.
  4. Repeat until the list is empty or a stop condition holds.

That is the entire engine. Everything else is the model.

Versus fixed time-step

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.

02

Step 1 — A Kernel in 30 Lines

Write this once yourself before using a library. Every DES library, SimPy included, is a refinement of it.

des.py: a complete event-scheduling kernel
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
03

Step 2 — A Queue, Checked Against Theory

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 / (μ − λ).

mm1.py: event-scheduling style
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
Why this is step 2, not an afterthought

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.

04

Interactive: Step Through the Event List

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.

0.8
1.1
1.2
event list (next first)
state

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.

05

Step 3 — Processes: Generators as Coroutines

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.

The same M/M/1, in SimPy
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)

What changed

  • A job's life is one readable function.
  • The queue is implicit, inside Resource.
  • Waiting is yield, and the kernel is the scheduler.
  • The with block releases the server even on an exception or interrupt.

The kernel underneath

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.

06

SimPy: The Whole Vocabulary on One Page

SimPy (v4, pure Python, MIT-licensed) is small enough to learn in an afternoon. This table is most of it.

ConceptAPIHardware use
Environment and clocksimpy.Environment(), env.now, env.run(until=t | event)One per simulation; time units are yours (pick seconds or ns and stick to it)
Processenv.process(gen), which is itself an event that fires when the generator returnsA compute engine, DMA, scheduler loop, traffic source
Delayyield env.timeout(dt)Compute time from the cost model, wire latency
Raw eventev = env.event(); ev.succeed(value)Interrupt lines, doorbells, "data ready" flags
Conditionsyield a & b, yield a | b (AllOf / AnyOf)Wait for all DMA channels; wait for data or timeout
ResourceResource, PriorityResource, PreemptiveResourceBuses, links, functional units, arbiters with priority
ContainerContainer(capacity, init), put(n), get(n)Credits, buffer occupancy, memory capacity in bytes
StoreStore(capacity), FilterStore, PriorityStoreFIFOs with back-pressure, mailboxes, packet queues
Interruptproc.interrupt(cause) raises simpy.Interrupt inside the processPre-emption, reset, power-gating, error injection
Real timesimpy.rt.RealtimeEnvironmentHardware-in-the-loop demos (rarely needed)
What SimPy deliberately lacks

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).

07

Modelling Hardware in SimPy

Most SoC building blocks map onto three primitives. A bounded FIFO between a producer and a consumer shows the most important one: back-pressure.

A DMA engine feeding a compute unit through a 4-deep FIFO
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

Resource

Anything with N identical servers that a job holds for a while: links, channels, engines, ports. Use PriorityResource to model an arbiter.

Store

Anything that holds items: FIFOs, queues, packet buffers. A finite capacity gives you back-pressure for free.

Container

Anything that holds an amount: credits, bytes of SRAM, KV-cache tokens. Getting more than is there blocks until someone puts some back.

08

Shared Bandwidth: Hold the Link or Share It?

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?

FCFS hold (a 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.

Processor sharing (fair share)

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 togetherTransfer A doneTransfer B doneMean
FCFS hold10 ms20 ms15 ms
Processor sharing20 ms20 ms20 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.

09

Structuring a Simulator That Lasts

Simulators that architects actually use share one structure: separate the things that change at different rates.

Workload
request streamsoperator graphsreplayed traces
Architecture config
counts, sizes, bandwidthstopologyplain data (YAML or dataclasses)
Cost models
how long does X take?roofline, table, ML regressor, RTL-calibrated
Engine & behaviour
schedulers, arbiters, batchingevent kernel
Probes & metrics
counters, tracesreports, dashboards
10

Time, Ordering and Determinism Pitfalls

PitfallSymptomCure
Simultaneous eventsResults change when unrelated code is reorderedDeterministic tie-break (sequence number, explicit priorities); never rely on hash or set order
Floating-point timeEvents at "the same" time drift apart after millions of additions; 0.1+0.2 != 0.3Integer time base (ps or cycles) for clocked models; or compare with tolerance
Zero-delay loopsSimulator hangs with the clock frozenAssert progress; cap events per timestamp; watchdog on wall-clock time
Shared RNGAdding one random draw in component A changes every result in component BOne RNG stream per component, seeded from a master seed
Warm-up biasLatencies look great because the system started emptyDiscard the warm-up period; check stationarity (deck 06)
End-of-run biasLong requests still running at the end are silently droppedRun to drain, or measure only requests that arrived in the window
Hidden infinite capacityQueues never fill, so there is no back-pressure anywhereGive every buffer a capacity; report its high-water mark
Determinism is a feature

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.

11

Making the Simulator Fast

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):

  1. Measure first. 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.
  2. Simulate fewer events. Model a whole batch step as one event, not one per token or per layer. Replace a detailed sub-model with an analytic one where it does not affect the answer (a fluid approximation for bulk transfers, say).
  3. Run sweeps in parallel. Independent configurations are embarrassingly parallel: multiprocessing, a job array, or Jenkins agents. This usually beats any single-run optimisation.
  4. Try a faster interpreter such as PyPy, which suits generator-heavy SimPy code; measure, as gains vary.
  5. Move the hot path to a compiled language. Keep the Python API and configuration, and push the event loop and cost models into C++ or Rust (pybind11, PyO3/maturin).
  6. Parallel discrete-event simulation (PDES) is the last resort: conservative (Chandy–Misra–Bryant, needs lookahead) or optimistic (Time Warp, needs rollback). Powerful, but it imposes structure on the whole model; SST and parallel SystemC kernels go this way.
12

Beyond Python: C++, Rust, SystemC

SystemC / TLM-2.0

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.

C++ simulators

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.

Rust

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.

The Rust kernel is the Python kernel with types
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
    }
}
13

Exercises

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.

  1. Type in the 30-line kernel. Add event cancellation with lazy deletion. Reproduce the M/M/1 result and plot simulated against theoretical W for ρ from 0.1 to 0.95. Watch the variance grow near saturation.
  2. Rewrite the M/M/1 in SimPy and time both versions. How many events per second does each manage?
  3. Model the DMA → FIFO → compute pipeline of slide 07. Sweep FIFO depth from 1 to 16 and find where throughput stops improving.
  4. Implement the link both ways (FCFS hold and processor sharing). Show that the mean throughput matches and the latency distributions differ.
  5. Add a probe layer: per-resource utilisation, time-weighted queue length, and a Chrome-trace exporter. Open the trace in Perfetto.
  6. Put a roofline cost model behind an interface, then replace it with a lookup table without touching the engine.
  7. Write a test that checks Little's law (L = λW) on your model, and wire it into CI.
  8. Attach an energy model: static watts × time plus energy per operation and per byte moved. Report joules per job, and add a power cap that stretches compute-bound work (deck 07).
14

What to Take Away

Next

Deck 03 covers the physics of the workload we want to simulate: prefill, decode, the roofline and the KV cache.