Simulation Engineering Toolkit — Presentation 11

Performance Analysis of Simulators and Systems

Measuring before optimising, on the simulators in this series: the USE method and the profile-first loop, Linux perf and what to do when it is locked down, sampling profilers and interactive flame graphs from py-spy, instruction counts with cachegrind (Rust against Python), a hot spot found in the metrics code and fixed with identical answers, benchmark distributions, and regression gates designed from measured noise.

USE method py-spy Flame graphs cachegrind Benchmark statistics Regression gates
Measure → Profile → Hypothesise → Change → Re-measure → Gate
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

Measure Before You Optimise

A simulator that takes a minute gets run a hundred times a day; one that takes an hour gets run once, and its users start guessing instead. So simulator speed matters, and the first rule of making it faster is the one LLM Inference Simulators 08 calls step zero: profile first. Intuition about where time goes is wrong often enough that it is not worth acting on.

This deck applies the standard tools to the simulators in this series, on one desktop machine (an Intel i7-3770 with 8 logical CPUs, with a desktop session running), and reports what they found, including two things that were not expected:

Every number comes from snippets/t11/run_t11.py and profile_first.py, recorded in RESULTS.md. They were all re-measured on 2026-10-03, after three changes. The profiled simulators' cost model was corrected (deck 10). The sort-once fix of slide 09 was applied. And an administrator enabled perf, which slide 04 now uses.

02

Two Methods: USE for Resources, Profile First for Code

measurea baseline, N runs profilewhere does it go? hypothesisone cause changeone thing re-measuresame answers? repeat until the profile is flat or the goal is met
03

USE in Practice: a Parallel Sweep

Rust_DES_Kernel's disagg-rs --sweep runs one simulation per arrival rate in parallel with rayon. Here are eight rates of 4,000 requests each, under /usr/bin/time -v, on one thread and on eight:

ThreadsWall timeCPUUtilisation of 8 CPUsMax RSSVoluntary switchesInvoluntary switches
10.47 s99%12.4%29.6 MB310
80.17 s609%76.1%213.7 MB89201

Source: snippets/RESULTS.md in _simeng_build

04

Linux perf: Counters and Call Stacks

perf is the Linux profiler. perf stat counts hardware events: cycles, instructions, cache and branch misses, and from them instructions per cycle (IPC). perf record samples call stacks for a profile, and perf report and perf annotate show where the samples landed, down to the instruction. The perf wiki covers it in depth. Both were run here on the Rust and Python versions of the same simulator:

The commands (run by snippets/t11/run_t11.py)
perf stat -r 5 -e cycles,instructions,cache-misses,branch-misses,... -- ./disagg-rs ...
perf record -e cycles:u -F 4999 --call-graph fp -- ./disagg-rs ...       # a frame-pointer build
perf record -e cycles:u -F 4999 --call-graph fp -- python3 -X perf -m disagg_sim ...
perf script | (fold the stacks) > flame graph                            # slide 06
ProgramRunsTimeCyclesInstructionsIPCLLC miss ratioBranch miss ratio
Rust, 20k requests5331 ms1.26 G2.59 G2.0650.3%1.09%
Python, 3k requests51,208 ms4.60 G6.30 G1.3714.4%2.85%

Source: snippets/RESULTS.md in _simeng_build

Permission, and what changed on this machine
kernel.perf_event_paranoid is 1 (an administrator lowered it from 4, Ubuntu's default, on 2026-10-03); perf version 7.0.14 runs unprivileged. Before that, every perf command here was refused.

So the first version of this deck relied on py-spy and cachegrind alone. kernel.perf_event_paranoid = 1 lets users profile their own processes, kernel included, but kptr_restrict still hides kernel symbols, so the profiles here sample user space only (cycles:u). See the kernel's perf security guide. Where perf is not allowed, as on many CI runners and containers, the rest of this deck still applies. py-spy reads a Python process's memory to sample its stacks, and cachegrind runs the program on a simulated CPU and counts every instruction. Neither needs privileges.

05

Sampling Profilers and Flame Graphs

A sampling profiler interrupts the program at a fixed rate (here 500 times a second) and records the call stack. Functions that take more time appear in more samples. The overhead is small and the program does not need changing. The price is statistical noise for anything that takes only a few samples.

samples (stacks, leaf last) main;run;stepmain;run;stepmain;run;decodemain;summarise;percentile merge -> main (4 of 4) run (3) summarise (1) step (2) decode (1) percentile width = share of samples; left-to-right order is alphabetical, not time

A flame graph merges identical stack prefixes. The width of a frame is the share of samples in which it was on the stack (its total time). The top edge of each tower is where the time is actually spent (self time). The x-axis is not time, so a wide frame is a hot spot, not a long phase.

06

Interactive: Flame Graphs of Two Simulators

Real profiles. With py-spy: Disaggregated_Inference_Sim simulating 3,000 requests (792 samples), and Memory_System_Sim simulating 20,000 random requests on one HBM pseudo-channel (4,944 samples). With perf: the Rust port simulating 20,000 requests (1,723 samples), and the Python simulator under -X perf (8,091 samples; each stack keeps its Python frames plus the native function at the top). Green frames are the simulator's code, amber are SimPy's, blue are the standard library (Python's or Rust's), red are native C code and purple the rest. Frames under 0.4% of the samples are omitted.

Click a frame to zoom into it; hover for its share of the samples.
07

Reading a Profile: Self and Total Time

Function (file)Self timeFunction (file)Total time
decode_step_done (disagg_sim/sim.py)16.4%main (disagg_sim/cli.py)88.0%
decode_once (disagg_sim/sim.py)7.3%run_once (disagg_sim/cli.py)87.8%
step_time (disagg_sim/hardware.py)6.6%simulate (disagg_sim/sim.py)76.8%
__init__ (<string>)5.8%run (disagg_sim/sim.py)76.8%
_dist (disagg_sim/metrics.py)5.8%run (simpy/core.py)76.8%
_resume (simpy/events.py)3.8%step (simpy/core.py)74.9%
step (disagg_sim/sim.py)3.8%_resume (simpy/events.py)71.2%
decode_sum (disagg_sim/hardware.py)3.5%decode_once (disagg_sim/sim.py)51.6%

Source: snippets/RESULTS.md in _simeng_build

Function (file)Self timeFunction (file)Total time
run (memsim/controller.py)40.9%simulate (memsim/controller.py)99.0%
t_act (memsim/controller.py)19.5%run (memsim/controller.py)98.0%
_hits_queued (memsim/controller.py)9.9%t_act (memsim/controller.py)19.5%
_bank (memsim/controller.py)7.8%_hits_queued (memsim/controller.py)9.9%
t_col (memsim/controller.py)7.3%_bank (memsim/controller.py)7.8%
consider (memsim/controller.py)5.5%t_col (memsim/controller.py)7.3%
__eq__ (<string>)3.0%consider (memsim/controller.py)5.5%
_col (memsim/controller.py)1.2%_col (memsim/controller.py)5.1%

Source: snippets/RESULTS.md in _simeng_build

Memory_System_Sim is a different shape: 40% of its time is in the controller's main loop itself, and 19% in t_act, which computes the earliest legal activate time against every timing constraint. That is the model's intrinsic work, and a candidate for the Rust core its README proposes (deck 02).

08

Counting Instructions With Cachegrind

Cachegrind runs a program on a simulated CPU and counts every instruction, with a simple model of the caches. It is 20–100× slower than native, but deterministic: the same run gives the same count, so small changes can be compared without timing noise. Here are the same 500-request simulation in Rust and in Python:

ImplementationInstructionsInstructions per requestI1 miss rateD1 miss rateLL miss rate
Rust (disagg-rs)55,728,681111,4570.01%1.5%0.1%
Python (disagg-sim, SimPy)1,308,813,9422,617,6270.83%3.2%0.0%

Source: snippets/RESULTS.md in _simeng_build

The Rust run's top functions by instructions executed:

FunctionShare of instructions
core::slice::sort::unstable::quicksort::quicksort::<f64, <[f64]>::sort_unstable_by<<f64>::total_cmp>::{closure#0}>49.3%
rust_des_kernel::pymath::fmean17.5%
<rust_des_kernel::disagg::engine::Simulation>::run12.5%

Source: snippets/RESULTS.md in _simeng_build

The simulation engine (Simulation::run) is 12.5%. Sorting floats is 49.3%, and fmean another 17.5%: summarise, computing the latency percentiles. Three independent measurements agree. perf samples a 20,000-request run and puts 63.5% of the time under summarise, with the sort at 37.6% self (slide 04's profile). And Rust_DES_Kernel times summarise at 8.5 ms against 4.0 ms for simulate (deck 02).

09

Closing the Loop: Sort Once

Hypothesis: _dist called percentile for p50, p90 and p99, and each call sorted the whole list again. For inter-token latencies that list has 763,474 values. Change: sort once and take all three percentiles from the sorted list. Check: summarise must return exactly the same answers. The change is now in Disaggregated_Inference_Sim (2026-10-03), with a property test that the two versions agree, and profile_first.py keeps the original for this comparison:

Disaggregated_Inference_Sim/src/disagg_sim/metrics.py (applied) source
def _dist(xs: list[float]) -> dict:
    # Sort once for all three percentiles (profiling showed the repeated sorts dominating
    # summarise). The mean stays fmean of the data, so every value is unchanged.
    s = sorted(xs)
    return {"mean": fmean(xs) if xs else math.nan, "p50": _percentile_sorted(s, 50),
            "p90": _percentile_sorted(s, 90), "p99": _percentile_sorted(s, 99),
            "max": s[-1] if s else math.nan}
summariseMedian of 9 (alternating)Runs (ms)
original: one sort per percentile316.3 ms318, 315, 302, 308, 317, 311, 329, 316, 317
sort once (applied)140.6 ms146, 139, 136, 140, 135, 142, 141, 151, 147

Source: snippets/RESULTS.md in _simeng_build

Rust runInstructionsIn sorting functionsShare
before: stable sort (sort_by), uncorrected cost model59,162,94330,467,70451.5%
after: unstable sort (sort_unstable_by), corrected cost model55,728,68128,287,99150.8%

Source: snippets/RESULTS.md in _simeng_build

10

Benchmarks Are Distributions

The same command, run 40 times on an unchanged machine, takes 40 different times:

BenchmarkRunsMedianMeanMinMaxCVMAD95% CI of the median (bootstrap)
disagg-rs, whole process (wall clock)4051.9 ms52.7 ms48.1 ms67.4 ms7.0%1.9 ms50.6 ms to 53.0 ms
memsim, in-process repeats 2 to 4039902.5 ms911.2 ms883.3 ms967.5 ms2.4%10.7 ms900.8 ms to 913.1 ms

Source: snippets/RESULTS.md in _simeng_build

11

Interactive: Designing a Regression Gate

A speed gate compares the median of k runs of the new build with the median of k runs of the baseline, and fails if the new one is slower by more than a margin. Using the 40 measured runs above, each setting was tried 20,000 times on unchanged code (how often it raises a false alarm) and on code made 5% and 10% slower (how often it catches the regression):

12

Regression Detection in CI

13

What to Take Away