Use when asked to profile, perf, or speed up the ServingStudio Sim *simulator wallclock* (how fast a run executes), not the modeled cluster throughput and not L1 kernel profiling. Covers building a symbol-rich release binary, capturing a `perf record` of a representative run, and reading the per-thread / flat breakdown to find the bottleneck.
Install
npx skillscat add syfi-servingstudio/servingstudiosim/operate-profile-sim-speed Install via the SkillsCat registry.
Profile Sim Speed
This is for the question "why is the sim slow to run" — wallclock seconds, thex real-time ratio, where CPU goes across the sim thread and the backgroundvibesim-logger thread. It is NOT about modeled tok/s (that's the run's output
metric) and NOT about L1 kernel profile.db (that'soperate-profile-existing-kernel).
Why the env wiring matters
The simulator embeds Python via PyO3 to query profile.db at build time. A bare./target/release/simulator or cargo run links/loads the wrong interpreter
(system 3.9) and crashes with init_fs_encoding / libpython3.12.so.1.0: cannot open shared object file. The launcher's launcher.exec._build_subprocess_env
sets PYO3_PYTHON, LD_LIBRARY_PATH, PYTHONHOME, and PYTHONPATH correctly.
Always run the binary under that env. The --profile launcher flag (Step 2)
does this for you: it reuses the launcher's preset expansion + env and wraps the
run with perf record. The inline python -c recipe below it is the manual
fallback for ad-hoc argv that no preset expresses.
Step 1 — build a symbol-rich release binary
Profile release, not dev (dev is ~100× slower and unrepresentative). The
workspace [profile.release] already sets debug = true, so release carries
DWARF for inline attribution — no separate profiling profile needed.
cd "$(git rev-parse --show-toplevel)"
uv run cargo build --release -p simulatoruv run is mandatory (pins PyO3 to the 3.12 venv). See memoryvibesim_pyo3_build_python_pin.
Step 2 — capture a representative run under perf
Two rules for representativeness:
- Isolate the steady state from the one-time build. The L4 build (profile.db
queries + JIT + MoE cost-curve build) shows aspython_build/_PyEval_EvalFrameDefault/__memcmp/for_each_routed_token/simulate_oncein the profile, all front-loaded at startup. Two ways to keep it
out of the numbers:- PREFERRED — modest run + window a ~10s steady slice (Step 3a). Decouple the
run length from the sample window: run long enough that the steady regime is
well-developed and representative — aim for ~1min of wall (e.g.duration_ms
≈ 600000-1000000; the KV pool has filled, admission has reached its saturating
rhythm) — but still restrict the perf analysis to a ~10s window taken from the
middle-to-late part of the run withperf report --time <start>,<stop>. That
region is fully warmed up (build long done, caches hot, steady state settled) yet
the slice is small, soperf reporton a 500MB+perf.datareturns in seconds
instead of minutes. This is the fast iteration loop — a modest capture plus a
windowed read beats a 20000s capture for both build-exclusion and analysis
speed. 10s of steady sim is plenty of samples (at -F 499 that's ~5000
sim-thread samples). Don't sample the last second or two — window a slice that is
safely mid-steady, not at the ramp-down tail. - Alternative — run so long the build amortizes globally: at 5000s it's still
~18%, at 20000s ~4%. Only worth it when you specifically want a whole-run
average rather than a clean steady slice; it costs far more wall to capture.
- PREFERRED — modest run + window a ~10s steady slice (Step 3a). Decouple the
- Pin BLAS/OMP to 1 thread so the build's numpy/torch calls don't smear
samples across worker threads.--profiledoes this for you.
Preferred: the --profile launcher flag
python -m launcher <preset>.json --profile reuses the launcher's preset
expansion, validation, and PyO3 env, and wraps the run subprocess with perf record -F 499 --call-graph dwarf under a single-threaded BLAS/OMP env. Theperf.data lands in that run's own log_dir/perf.data.
uv run python -m launcher preset/unified_aime_perf.json --profile
# custom sampling Hz (499/999 to avoid lock-step with timers):
uv run python -m launcher preset/unified_aime_perf.json --profile --profile-freq 999--profile requires the preset to expand to exactly one run (perf on a
parallel sweep is meaningless) — narrow a sweep with --override if needed. The
flag enforces this and exits early. <log_dir>/perf.data is binary — read it
with perf report, never cat/Read.
Fallback: inline perf for ad-hoc argv
When no preset expresses the argv you want, wrap the binary directly. -F 499
(sampling Hz), --call-graph dwarf (release omits frame pointers); the grep
keeps the end-of-run summary (wall seconds + x real-time) and perf's
sample/byte count.
uv run python -c "
import os
from launcher.exec import _build_subprocess_env
env=_build_subprocess_env(); env['OPENBLAS_NUM_THREADS']='1'; env['OMP_NUM_THREADS']='1'
b='target/release/simulator'
args=['run','unified','--cp-plan','no-cp','--duration-ms','20000000','--request-rate','150.0','--log-dir','logs/unified_aime_perf','--attn-gpu-memory-gb','80.0','--model-config','model/config/llama3_8b.json','--trace-files','trace/aime_long.csv']
argv=['perf','record','-F','499','--call-graph','dwarf','-o','logs/perf_aime.data','--',b]+args
os.execvpe('perf',argv,env)
" 2>&1 | grep -E 'time:|finished|prefill:|decode:|total:|samples'Swap the workload to whatever you're profiling. trace/aime_long.csv (150k
single-round rows, rate 150) is the standard saturating workload — it fills the
KV pool, so most arrivals pile up in the pending queue (a stress case for
logging and the tick loop).
Step 3 — read the breakdown (native perf, not perf script)
Fast path — one command for the whole breakdown.tools/sim-speed-perf/perf_breakdown.sh <perf.data> runs all five views below
concurrently over a single auto-derived steady window (Step 3a), auto-annotates the
top-N hot simulator:: symbols to source lines (Step 3b), and saves the full raw
output (every symbol / annotated line) next to the perf.data underperf_breakdown/ (*.full.txt + a head-limited breakdown.txt also tee'd to
stdout). Reach for it first; the manual perf report invocations below are the
fallback when you want a view it doesn't cover or need to tweak a filter.
tools/sim-speed-perf/perf_breakdown.sh <log_dir>/perf.data # auto window, NSYM=4
NSYM=6 tools/sim-speed-perf/perf_breakdown.sh <log_dir>/perf.data 1234 1244 # explicit abs-second windowperf script piping through Python folding is slow on 100MB+ data. Useperf report's native filters. The -i paths below use the inline-recipelogs/perf_aime.data; for a --profile run substitute <log_dir>/perf.data.
Per-thread CPU split (is the cost in the sim thread or the logger thread?):
perf report -i logs/perf_aime.data --stdio -F overhead --sort comm 2>/dev/null | grep -v '^#' | head -8Flat top symbols across all threads:
perf report -i logs/perf_aime.data --stdio -g none -F overhead,symbol 2>/dev/null | grep -v '^#' | head -20One thread at a time (thread names: simulator = sim thread,vibesim-logger = background parquet writer):
perf report -i logs/perf_aime.data --stdio -g none -F overhead,symbol --comm=vibesim-logger 2>/dev/null | grep -v '^#' | head -12
perf report -i logs/perf_aime.data --stdio -g none -F overhead,symbol --comm=simulator 2>/dev/null | grep -v '^#' | head -14Step 3a — window to a warmed-up ~10s steady slice (do this first)
perf's absolute sample clock lets you cut the one-time build out of the read
without re-running. Get the sim-thread sample time bounds, then take a ~10s window
from the middle-to-late range (warmed up, build excluded):
# 1. sim-thread sample time bounds (first .. last, in seconds). One awk pass;
# filtered to --comm=simulator so only the sim thread counts. ~30-60s on 500MB.
perf script -i <log_dir>/perf.data --comm=simulator -F time 2>/dev/null \
| awk 'NR==1{f=$1} {l=$1} END{gsub(/:/,"",f); gsub(/:/,"",l); print "first="f" last="l" span="l-f}'
# e.g. first=1558739.57 last=1558793.39 span=53.82 (≈11s build + ≈43s steady sim)
# 2. pick a ~10s window well past the build front (here the build is ~first 11s,
# so start ~20s in) and read the flat self-times inside it:
perf report -i <log_dir>/perf.data -g none -F overhead,symbol --comm=simulator \
--percentage relative --time 1558770,1558780 --stdio 2>/dev/null | grep -vE '^#|^$' | head -20--time <start>,<stop>takes absolute seconds from the same clockperf script -F timeprints (NOT offsets, NOT percentages — the45%,100%form errors
withInvalid time stringon this perf build).--percentage relative --comm=simulatorrescales so the sim thread = 100%.- Confirm the window is steady, not build: the build symbols (
__memcmp,for_each_routed_token,simulate_once,_PyEval_EvalFrameDefault) should be
gone inside the window. If they're still there, movestartlater. - Windowing a 10s slice also makes
perf reportitself fast (fewer samples to
fold) — the whole point of the short-run + window loop.
Step 3b — the release-inlining caveat (read before trusting the call tree)
In thin-LTO release the DWARF call graph is unreliable — perf report -g graph shows broken frames (0x3, 0xb, raw addresses) and OOMs on 100MB+ data.
Do NOT present a literal nested call tree from it. Two consequences:
- Trust flat self-times, not the call graph.
perf report -g none -F overhead,symbol --comm=simulatoris clean and reliable. Build the cost tree by
logically grouping those self-time symbols (see Presenting), not by reading
perf's-g graphoutput. - Self-time absorbs inlined callees. A fat self-time on an outer function is
often inlined work, not the function's own body. Example:run_simshowed 35%
self — most of it was the inlined stuck-watchdogprogress_signatureO(n)
store scan, not loop overhead. Confirm inlining by absence: if a function
you expect (progress_signature,state_entry,drain_due) does NOT appear as
its own symbol in the flat list, it was inlined into its caller's self-time.
Then read the source of the hot outer function to find what it inlines.
Method that works: flat self-times (where) → read the hot symbol's source (what
it inlines / how often it runs) → form a hypothesis → confirm with dhat (allocs)
or a targeted code-level count (tick count × per-iter cost).
Step 4 — attribute allocations with dhat (when alloc symbols dominate)
When _int_malloc/_int_free/realloc/memmove/from_iter are a big slice
(they were ~14% of the sim thread once the watchdog was fixed), perf tells you
allocation is costly but not which call sites. dhat gives exact Rust
allocation call stacks. It is wired behind an opt-in cargo feature so normal
builds are byte-identical (zero cost): dhat is an optional dep and both the#[global_allocator] and the dhat::Profiler guard in main.rs are#[cfg(feature = "dhat-heap")].
dhat's allocator only intercepts Rust's GlobalAlloc, so Python/torch C-side
allocations bypass it — the dump is dominated by the sim's own Rust allocations
(exactly what we want). It IS ~10× slower, so profile a short run (the
per-iteration allocation pattern is the same at 300s as at 20000s).
uv run cargo build --release --features dhat-heap -p simulator
# run the SHORT workload directly under the launcher's PyO3 env (dhat writes
# dhat-heap.json to CWD on exit):
uv run python -c "
import subprocess, os
from launcher.exec import _build_subprocess_env
env=_build_subprocess_env(); env['OPENBLAS_NUM_THREADS']='1'; env['OMP_NUM_THREADS']='1'
b='target/release/simulator'
args=['run','unified','--cp-plan','no-cp','--duration-ms','300000','--request-rate','150.0','--log-dir','logs/dhat_run','--attn-gpu-memory-gb','80.0','--model-config','model/config/llama3_8b.json','--trace-files','trace/aime_long.csv']
r=subprocess.run([b]+args, env=env); print('exit', r.returncode)
"Rank the call sites by block count (allocation churn → malloc/free overhead)
and by bytes, mapping each program point to its first simulator:: frame:
uv run python -c "
import json
d=json.load(open('dhat-heap.json')); ftbl=d['ftbl']
def sim_frame(fs):
for fi in fs:
f=ftbl[fi]
if 'simulator::' in f and 'dhat' not in f: return f.split(' (')[0]
return ftbl[fs[0]] if fs else '?'
rows=[(p.get('tbk',0), p.get('tb',0), sim_frame(p.get('fs',[]))) for p in d['pps']]
print('blocks=%d bytes=%d'%(sum(r[0] for r in rows), sum(r[1] for r in rows)))
for tbk,tb,fr in sorted(rows, reverse=True)[:18]:
print(f'{tbk:>9} blk {tb:>12,} B {fr[:92]}')
"tbk (blocks) ≈ malloc/free calls = the churn that shows as _int_* in perf;tb (bytes) flags large transient buffers. The recurring ServingStudio Sim finding is
per-iteration scratch Vecs (build_arch_input, complete_iter,CostTree::aggregate, projected_peak) that should be reused buffers held on the
worker (the cost_slots pattern), plus stray .clone()s (eval_buf).
Presenting results
Three artifacts, in this order:
- Per-thread split (
--sort comm): is cost insimulator(sim thread) orvibesim-logger/vibesim-cost-logg(background writers)? Quote each thread's %. - Sim-thread cost tree, normalized to "sim thread = 100%" — built from flat
self-times grouped into logical buckets (driver loop / allocation / cost-model
eval / admission+sort / one-time build / tail), NOT from perf's-g graph.
Always note the inlining caveat for any fat self-time bucket. - Before/after table for any change:
wall,× real-time, the moved
symbol's self-%, and termination cause + finished count (prove behavior is
unchanged — a speedup that changes the sim result is a bug, not a win).
Optional: export a flamegraph SVG from an existing perf.data with theflamegraph binary (~/.cargo/bin/flamegraph, bundles inferno) — it streams viaperf script so it tolerates the 100MB+ file the in-memory -g graph chokes on:~/.cargo/bin/flamegraph --perfdata <log_dir>/perf.data -o <log_dir>/flamegraph.svg.
Interpreting — known cost centers and what they mean
sim::run::run_sim,SimpleDpFlow::tick,BareboneWorker::tick— the real
per-tick simulation loop. If these dominate, the lever is the loop itself
(e.g. event-driven next-event advance instead of fixed 100µs ticks), not I/O.vibesim-loggerthread /parquet::*/Interner::intern/compare_greater— parquet encode + ZSTD on the background writer. If this is
large, the lever is logged row volume (the denserequest_statesnapshot)
or per-row allocations._int_malloc/_int_free/realloc/memmove/Vec::from_iter—
allocation. perf can't name the call sites (broken DWARF); use dhat (Step 4)
to attribute. The recurring driver is per-iteration scratchVecs inbuild_arch_input/complete_iter/CostTree::aggregate/projected_peak.
Past win:final_phaseper-rowString→#[repr(u8)]enum killed ~30M allocs._PyEval_EvalFrameDefault/python_build— the one-time L4 build. Should be
small at 20000s; if large, you ran too short.slice::sort— the per-batch-formationprojected_peak_kvdecode sort
(admission_helpers.rs). The TPOT per-token-gap sort was moved OFF the sim
thread to the writer thread (log::rows::tpot_stats_ms); if you see TPOT
sorting onsimulator, that regressed. Check--commto see which thread.
Reference: optimizations already landed (don't re-discover)
History (50s sim 37s→0.19s ≈ 195×; aime_long 20000s wall ~12.3s ≈ 1620×
real-time after the watchdog fix below):
RequestStoreHashMap→denseVec+ O(1) in-flight counters (was an O(n)
per-tick scan = ~87%).- Cost model
cost_whole_iter_timefast path (noLookupResulttree alloc);cost_verboseconfig keeps the full tree opt-in. - Background
vibesim-loggerthread (boundedsync_channel,CHANNEL_CAP=64so
a dense-snapshot burst queues without stalling the sim). - 100µs tick (was 1µs).
- Dense snapshot logs only the admitted prefix (
RequestStore::iter_admitted),
not the never-served pending tail;final_phaseString→enum. - Stuck-watchdog made O(1) (biggest recent win: 18.6s→12.3s, −34%). The old
progress_signaturesummed token counts over all requests every 1s of sim
once the trace drained (~19k scans × 150k reqs) — inlined intorun_sim, it
was ~30pp ofrun_sim's self-time. Now it comparescompleted + RequestStore::admitted_watermark()(two monotonic O(1) counters) every 100s. - TPOT percentile sort moved sim-thread → writer thread (
slo_entryno
longer callstpot_stats;log::rows::tpot_stats_msderives it inslo_to_record_batchfrom the already-loggedoutput_token_times). Removed
~6pp of sim-thread sort. cost_logsize −31%: dropped the redundantwall_end_mscolumn (=wall_start_ms + total_time_ms, derivable; it was a high-entropy f64 that
barely compressed, 6 MiB of a 19 MiB file).
After 6–8 the top sim-thread cost is SimpleDpFlow::tick (~29%, the 200M-tick
orchestration) and per-iteration allocation (~14%, see dhat Step 4). The next
structural lever is the fixed-tick count itself (coarser tick_dt / event-driven
next-event advance).
Cleanup
The perf.data is large (~80–230MB) — logs/perf_aime.data for the inline
recipe, <log_dir>/perf.data for a --profile run. Leave it for follow-upperf report passes; rm it only when done profiling. dhat-heap.json is
written to CWD — move it into the run's <log_dir>/dhat/ (or rm). Thedhat-heap feature stays in the tree (off by default, zero cost); don't rip it
out — it's the canonical alloc-attribution path.
The --profile launcher flag
Implemented. python -m launcher <preset>.json --profile [--profile-freq HZ]
wraps the single expanded run with perf record using the launcher's own preset
expansion + PyO3 env, writing <log_dir>/perf.data. Wiring:launcher.exec.wrap_with_perf / _profile_env / perf_available, threaded
through launcher.sweep._launch_one → run_single, gated inlauncher.__main__ (requires exactly one run; checks perf is on PATH). Use it
in preference to the inline python -c recipe whenever a preset expresses the run.