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.
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.
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.
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:
| Threads | Wall time | CPU | Utilisation of 8 CPUs | Max RSS | Voluntary switches | Involuntary switches |
|---|---|---|---|---|---|---|
| 1 | 0.47 s | 99% | 12.4% | 29.6 MB | 3 | 10 |
| 8 | 0.17 s | 609% | 76.1% | 213.7 MB | 89 | 201 |
Source: snippets/RESULTS.md in _simeng_build
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:
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| Program | Runs | Time | Cycles | Instructions | IPC | LLC miss ratio | Branch miss ratio |
|---|---|---|---|---|---|---|---|
| Rust, 20k requests | 5 | 331 ms | 1.26 G | 2.59 G | 2.06 | 50.3% | 1.09% |
| Python, 3k requests | 5 | 1,208 ms | 4.60 G | 6.30 G | 1.37 | 14.4% | 2.85% |
Source: snippets/RESULTS.md in _simeng_build
-C force-frame-pointers=yes. Python's -X perf names Python functions for perf (Python 3.12 and later) but unwinds only through frame pointers, so it ran on Ubuntu's python3.12, which is built with them.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.
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.
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.
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.
| Function (file) | Self time | Function (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
simulate), with half of all samples in decode_once. The cost model's step_time and decode_sum are 10% of self time between them: the roofline arithmetic is not free.percentile was the second-largest self-time function at 12.8%. It is in the metrics, after the simulation has finished, and nobody optimising the event loop would look there. Slide 09 fixed it; in this profile, taken after the fix, _dist (which now sorts once) is 5.8%._PyEval_EvalFrameDefault, CPython's bytecode loop. With -X perf the Python functions appear as callers, so perf's total time agrees with py-spy's (simulate 80%), but self time belongs to the interpreter. Use py-spy to find the Python function and perf to see what the interpreter does inside it.| Function (file) | Self time | Function (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).
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:
| Implementation | Instructions | Instructions per request | I1 miss rate | D1 miss rate | LL miss rate |
|---|---|---|---|---|---|
| Rust (disagg-rs) | 55,728,681 | 111,457 | 0.01% | 1.5% | 0.1% |
| Python (disagg-sim, SimPy) | 1,308,813,942 | 2,617,627 | 0.83% | 3.2% | 0.0% |
Source: snippets/RESULTS.md in _simeng_build
The Rust run's top functions by instructions executed:
| Function | Share of instructions |
|---|---|
core::slice::sort::unstable::quicksort::quicksort::<f64, <[f64]>::sort_unstable_by<<f64>::total_cmp>::{closure#0}> | 49.3% |
rust_des_kernel::pymath::fmean | 17.5% |
<rust_des_kernel::disagg::engine::Simulation>::run | 12.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).
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:
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}
summarise | Median of 9 (alternating) | Runs (ms) |
|---|---|---|
| original: one sort per percentile | 316.3 ms | 318, 315, 302, 308, 317, 311, 329, 316, 317 |
| sort once (applied) | 140.6 ms | 146, 139, 136, 140, 135, 142, 141, 151, 147 |
Source: snippets/RESULTS.md in _simeng_build
summarise: 2.25x; outputs identical: True. End to end (simulate + summarise): 1.24 s to 1.06 s (1.17x).summarise got 2.25× faster, the whole run only 1.17×, because the simulation itself was untouched (InfSim 08, slide 14).| Rust run | Instructions | In sorting functions | Share |
|---|---|---|---|
before: stable sort (sort_by), uncorrected cost model | 59,162,943 | 30,467,704 | 51.5% |
after: unstable sort (sort_unstable_by), corrected cost model | 55,728,681 | 28,287,991 | 50.8% |
Source: snippets/RESULTS.md in _simeng_build
The same command, run 40 times on an unchanged machine, takes 40 different times:
| Benchmark | Runs | Median | Mean | Min | Max | CV | MAD | 95% CI of the median (bootstrap) |
|---|---|---|---|---|---|---|---|---|
disagg-rs, whole process (wall clock) | 40 | 51.9 ms | 52.7 ms | 48.1 ms | 67.4 ms | 7.0% | 1.9 ms | 50.6 ms to 53.0 ms |
| memsim, in-process repeats 2 to 40 | 39 | 902.5 ms | 911.2 ms | 883.3 ms | 967.5 ms | 2.4% | 10.7 ms | 900.8 ms to 913.1 ms |
Source: snippets/RESULTS.md in _simeng_build
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):