This is the repeatable benchmark protocol for diagnosing iRacing FPS loss,
overlay stutter, and long-session degradation. It complements
PERFORMANCE_TEST_SUMMARY.md, whose earlier
runs established the memory and telemetry-processing targets.
When launched through npm run perf:run, irDashies records structured samples
without changing the saved dashboard:
- iRacing-reported FPS, foreground/background CPU usage, and GPU usage
- irDashies total and per-process CPU and working-set memory
- telemetry tick cadence and
processTelemetry/ IPC broadcast percentiles - native telemetry/session reads, lifecycle work, payload projection, session publication, and renderer telemetry-callback percentiles
- Electron main-process event-loop stalls
- per-renderer animation-frame timing and frames over 25 ms / 50 ms
The metrics are disabled during normal app launches.
For quick development iteration, the default target is the Vite development
build. Before accepting a result as a release baseline, package the app and add
--target packaged to every run:
npm run package
npm run perf:run -- --target packaged --mode observer --scenario release-observer --duration-seconds 420Do not compare a development run with a packaged run. Vite, source maps, React development checks, and hot-module tooling materially change renderer memory and startup work.
The iRacing FPS values come from the simulator telemetry stream. They are good for controlled A/B regressions but are not a substitute for PresentMon present-to-present frame times. If these runs show an FPS-neutral hitch, use an ETW/PresentMon trace as the second-stage investigation.
For an isolated .irdt tape, the iRacing FPS/CPU/GPU variables are historical
values recorded in the tape. They do not measure the host while the benchmark
is running and must not be used for A/B conclusions.
Use a replay with a representative field size and the same fixed cockpit camera for every run. A replay is preferred because live-session traffic, weather, and physics load change between samples.
Before starting:
- Keep iRacing resolution, graphics, mirrors, FPS cap, and camera identical.
- Do not resize or edit overlays during a run.
- Close browsers, launchers, recording software, and other variable GPU loads.
- Let the replay and camera settle for 60 seconds.
- Run each mode for at least 6 minutes. Stop it with Ctrl+C.
For the curated native telemetry tape, pass --replay-input. The runner selects
the isolated SDK addon, waits until the development native rebuild is complete,
starts the publisher, and stops it with the app:
$replay = 'test-data\telemetry\ai-race-10min.irdt'This connects to iRacing and samples telemetry, but creates no Electron overlay or settings windows.
npm run perf:run -- --mode observer --scenario replay-observer --duration-seconds 420 --replay-input $replayThis creates the normal transparent overlay windows with every widget disabled. It measures the window, renderer bootstrap, and global-provider substrate without widget rendering. It does not isolate Windows composition alone because each renderer still mounts the dashboard, telemetry, session, pit-lane, and reference-lap providers.
npm run perf:run -- --mode empty --scenario replay-empty --duration-seconds 420 --replay-input $replayTo distinguish renderer/window substrate from telemetry IPC and store notification work, run empty mode once with renderer delivery disabled and once with it enabled:
npm run perf:run -- --mode empty --scenario replay-empty-off --duration-seconds 420 --telemetry-delivery off --replay-input $replay
npm run perf:run -- --mode empty --scenario replay-empty-on --duration-seconds 420 --telemetry-delivery on --replay-input $replayThis loads the current dashboard, but omits the settings and gamepad-host windows so the measurement is focused on overlays.
npm run perf:run -- --mode full --scenario replay-full --duration-seconds 420 --replay-input $replay--telemetry-payload raw is a benchmark-only counterfactual for measuring the
cost of sending every SDK variable. Normal launches and the default benchmark
path retain the renderer allowlist.
Repeat only if the full dashboard materially regresses FPS or frame pacing. Comma-separated widget types can be tested together. The filter is applied in memory and is never persisted.
npm run perf:run -- --mode full --widgets standings --scenario replay-standings --duration-seconds 420 --replay-input $replay
npm run perf:run -- --mode full --widgets relative --scenario replay-relative --duration-seconds 420 --replay-input $replay
npm run perf:run -- --mode full --widgets map --scenario replay-map --duration-seconds 420 --replay-input $replayUse the widget type from WidgetIndex.tsx, not a widget instance ID.
Each run writes perf-results/<run-id>.log. Analyse a single run:
npm run perf:analyze -- perf-results/<run-id>.log --warmup-seconds 60Compare the empty or full dashboard against the observer:
npm run perf:analyze -- perf-results/<candidate>.log --baseline perf-results/<observer>.log --warmup-seconds 60The analyzer writes adjacent .summary.json and .summary.md files. Its
initial regression gates are:
| Metric | Gate |
|---|---|
| iRacing average FPS vs observer | no worse than -2% |
| iRacing sampled FPS p1 mean vs observer | no worse than -5% |
processTelemetry p99 mean |
< 3 ms |
| Minimum interval telemetry rate | >= 20 Hz |
| Steady-state app memory slope | < 5 MB/min |
| Renderer frames over 50 ms | < 0.1% |
Treat a failed gate as a signal to inspect, not proof of causality. Repeat any failed A/B pair once before making an architectural decision.
Use a real multiplayer practice after the replay A/B matrix. Run the full dashboard for at least 20 minutes, ideally covering a join burst and a session transition:
npm run perf:run -- --mode full --scenario live-practice-churn --duration-seconds 1500Record these notes alongside the result:
- session and series
- car/track and field/class count
- monitor resolution and refresh rate
- iRacing FPS cap and whether VSync/G-Sync is active
- approximate times of joining, entering the car, session transition, and any visible stutter
The live run validates memory slope, join/transition spikes, and subjective stutter. Do not compare its FPS directly with the replay baseline.
| Observation | Likely next investigation |
|---|---|
| Observer already hurts iRacing | SDK polling/main loop or CPU scheduling |
| Empty regresses vs observer | renderer/provider bootstrap, transparent composition, window bounds |
| Full regresses vs empty | React work, canvas/SVG paint, telemetry fanout, widget allocation |
| One widget reproduces most of the full delta | profile that widget's render/paint path |
| FPS is stable but 50 ms renderer frames rise | renderer main-thread stalls or GC |
| Renderer timing is clean but visible hitch remains | compositor/GPU present trace with PresentMon/ETW |
Do not begin the worker-thread SDK loop, channel bus, binary IPC, or native rewrite solely from subjective stutter. Use the smallest change matching the measured layer:
- SDK observer regression: investigate the blocking SDK loop and worker thread.
- Full-vs-empty CPU regression: reduce renderer wake-ups or move derived telemetry to typed, rate-limited channels/processors.
- Empty-window GPU regression: reduce transparent surface area/window count or change the Chromium composition path.
- A single widget's paint regression: optimize that renderer before moving application-wide architecture.
Follow-up work, evidence gates, and measurable exit criteria are tracked in
IMPLEMENTATION_PLAN.md.