Skip to main content

perf-regression

Measure GPU performance in a UTM VM the valid way and decide whether a change made it faster, slower or no different — 3DMark single-test runs with validity checks, interleaved A/B between builds, drivers, backends or UTM apps, frame pacing from the VM window, host GPU/CPU sampling, and profiling pointers. Use when asked to benchmark, "check for a regression", compare fps, explain why something is slow, or investigate stutter or frame-rate decay.

Source facts

Repository
utmapp/utmapp-bot
Last source activity
November 5, 2026 at 00:20
Detected SKILL.md language
English
Stars
1
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
perf-regression
description
Measure GPU performance in a UTM VM the valid way and decide whether a change made it faster, slower or no different — 3DMark single-test runs with validity checks, interleaved A/B between builds, drivers, backends or UTM apps, frame pacing from the VM window, host GPU/CPU sampling, and profiling pointers. Use when asked to benchmark, "check for a regression", compare fps, explain why something is slow, or investigate stutter or frame-rate decay.
argument-hint
<what to compare or measure>
# Performance and regressions Request: `$ARGUMENTS` `UB="${CLAUDE_PLUGIN_ROOT}/bin/utmbot"` ## Rules: why most perf numbers in this stack were wrong 1. **Measure before fixing.** Name the metric. Show that it separates good from bad, and know its noise (several runs), before changing code. Change one thing, then measure again. 2. **Only interleaved, same-session comparisons count.** The host drifts within a session: the same build has lost most of its fps over hours, while every health check passed. A VM restart only partly recovers it. Never compare against a number from another session, another day or a note. `$UB ab` runs A B B A … pairs in one session. 3. **Discard the first run** after a boot, a build or a host-library swap. That run pays for shader compilation and Rosetta translation of changed x86_64 code. 4. **Warm shader cache.** A cold cache makes the first graphics test miss 3DMark's 60 s start deadline (`NO_RESPONSE`/`PROCESS_EXITED` on GT1 only). That looks exactly like a driver hang, with a clean host log. 5. **One benchmark family per boot.** Time Spy first, if both are run. A second fullscreen family in the same boot is not comparable. 6. **Host conditions:** - UTM in front (App Nap makes vCPUs ~4x slower); - display awake and screen unlocked (a locked Mac cannot be un-napped, so its numbers are not quotable); - no background CPU hogs; - same window scale and resolution. `bench` checks these and marks a run `NOT QUOTABLE` otherwise. 7. **Validity comes from the benchmark, not the score.** A run that lost focus or fell back to windowed mode still prints a number. `bench` reads 3DMark's status codes: - `1000`: windowed/lost exclusive mode, often foreground-lock poisoning; - `10000`: focus or resolution flake. 8. **Same configuration as the claim.** Use the same backend (prove it with `$UB backend`), the same D3DMetal version, Debug Log off unless that is what is measured, and GraphicsValidation off (it costs ~20%). 9. **A decay over time, or a run far slower than its siblings, is a hang in slow motion.** Treat it as a bug, not noise (`presentloop` measures decay). 10. **Every claimed improvement is a measured one.** If a change does nothing measurable, drop it. Do not tune for one title: the fix has to hold for every legal workload. ## Run **3DMark** must be installed and activated in the test VM's base image: the `content` stage of `/utmapp-bot:provision-windows-test-vm` does it (`--only content --3dmark <dir>` on an existing VM; the Linux VM's `prefix` stage likewise). `3DMarkCmd` needs a **Professional** key, from `$UTMBOT_3DMARK_KEY` or `~/.utmapp-bot/3dmark.key` (mode 600); never put it in a log, a host command line or the repository. Without one `bench` reports `NOT_ACTIVATED`. Single-test definitions are in `${CLAUDE_PLUGIN_ROOT}/bench/defs/`. `fs_gt1`/`ts_gt1` run only Fire Strike or Time Spy graphics test 1, the fastest repro loop. ```sh $UB vm up --backend d3dmetal --debug-log off && $UB install <pkg> && $UB display-fix $UB bench fs_gt1 -n 3 --warmup [--frames-at 12] # VALID/INVALID, fps, run dirs ``` **A/B** (each run is a fresh disposable boot, with an install if the arm names a driver, a discarded warm-up, and a measured run): ```sh $UB ab fs_gt1 --pairs 3 \ --a backend=d3dmetal,driver=$HOME/.utmapp-bot/drivers-old/ARM64 \ --b backend=d3dmetal,driver=$HOME/.utmapp-bot/drivers/ARM64 # arms: driver=DIR (required), backend=dxmt|d3dmetal (else UTM's current default), # app=/path/UTM.app, env=K=V;K=V, genv=K=V, debuglog=on ``` It counts only runs that are VALID and were taken on a fit host (not `NOT QUOTABLE`), and says how many it dropped. It prints each arm's mean and stdev, and `B-A` against a noise band of 2× the larger stdev. `WITHIN NOISE` means no measurable difference. Debug Log is off unless an arm says `debuglog=on`. **Frame pacing** (stutter, held frames, fps the user sees): `$UB frames 20` counts pixel-distinct frames in the VM window. The app's present count overstates it whenever frames are dropped or repeated on the way to the window. `presentloop` (from `/utmapp-bot:write-gpu-probe`) reports per-window fps and the worst frame time. `--rtv-per-frame` reproduces the "creates a view every frame" pattern, and `DECAY` means released objects are not being freed. **Where the time goes** (full stack, one in-game window per pass; profiling boots are never measurement boots): 1. **Bound first.** `$UB perfcap 20` during the test: host GPU `dev`/`ren` near 100% means GPU-bound, and no CPU-side change will raise the frame rate. Then find what fills the GPU (step 4), not what the CPU does. 2. **Window.** Start the workload in the background, then `$UB ingame`. It returns once the guest display has been animating for 6 samples in a row. Loading screens differ by ~0.1 between grabs, a game by 10–100, and a fade gives one or two large values. A fixed delay misses: Fire Strike loads for 20–30 s, and the time varies from run to run. Prove the window: `$UB shot` at start and end (3DMark's overlay shows the test time). 3. **Guest (ETW).** `$UB etw start`, wait 10 s, `$UB etw stop NAME`, then `$UB etw analyze <etl> <process> --dev '<dev vm>'`. It records context switches and ready-thread events with stacks, plus DxgKrnl/DXGI. The analysis gives: - per-thread on-CPU, ready (no vCPU) and blocked time; - off-CPU time grouped by wait stack, overall and per busiest thread; - who woke whom; - the GPU queue: DMA busy, packet latency, presents/s and the interval distribution. The dump runs on the dev VM, which has WPT and the symbol cache (the first run downloads ~2 GB of Microsoft symbols). UMD frames are bare offsets because the package is built `release`. Name them from the source: the present path waits in `tritonPresentFlushAndGate`. **Sampled CPU profiling does not work in the guest:** WPR's CPU and GPU profiles and xperf `PROFILE` fail with "The request is not supported" (0x80070032). On this host Hypervisor.framework emulates the PMU registers itself (`Arm::Emulator::read_pmccntr_el0` in QEMU's host profile), so UTM's QEMU PMU emulation never sees them, and Windows lists only the `Timer` source (`xperf -pmcsources`). Wait analysis replaces on-CPU hot spots. Say so in a report. For an x64 app (emulated in the guest), the stack walk often stops before the app's frames, so a large share of wait time is reported as `(no caller stack …)`. The per-thread on-CPU, ready and blocked times still hold. One app thread near 100% on-CPU means the guest CPU sets the frame rate. 4. **Host.** `$UB hostprof 10 --ctx <guest process>` is a system-wide Time Profiler for QEMU and that guest process's render worker, with x86_64 (Rosetta) frames named. `$UB hostprof 5 --metal` is a Metal System Trace that gives GPU time per process and channel (the game vs. UTM's display path). Use one xctrace session at a time: kperf allows a single sampling session, and two attached Time Profilers fail with "could not lock kperf". Processing a loaded system's trace takes minutes after the recording ends. Frames shown as `?!0x7ff8…` in a render worker are not in the x86_64 shared cache (`/System/Volumes/Preboot/Cryptexes/Rosetta/System/Library/dyld/*.map`): they belong to Rosetta's runtime, and no symbols exist for them. Attribute them by their named caller and callee (for example the frame between `IOGPUCommandQueueSubmitCommandBuffers` and `IOConnectCallMethod` is command-buffer submission). 5. **Timing logs.** Guest UMD: run the workload with `--genv NPT_DEBUG=present_timing` (and `--genv NPT_PRESENT_GATE_TRACE=<ms>` for slow presents) plus `$UB ods 300`, then `$UB ods get`. Every 120 presents it writes `PT n=… avg_us sig= arm= wait= cb= total= wait_max=`. `wait` is the pacing wait for frame N−depth; the log names the depth. Host render server: `vm up --env NPT_DEBUG=profile --env NPT_PROFILE_PERIOD_MS=2000`, then `$UB hostlog --grep NPT-PROF` (ring dispatch, relax and idle time, per method). **The host profiler is not free:** under Rosetta its per-command `clock_gettime(CLOCK_MONOTONIC)` was 21% of the ring thread's samples. Never take a Time Profiler capture with it on. What profiles of this stack have shown, as a guide to reading yours: - Fire Strike-class D3D11 workloads on D3DMetal were GPU-bound: the host GPU near 100%, almost all of it the game's translated work, UTM's display path a few percent. Guest and host CPU costs that looked large (pacing waits, contended locks in Hypervisor.framework) did not set the frame rate: halving the vCPUs in an interleaved A/B left fps unchanged. Check for such an ablation before chasing a CPU-side cost. - A workload can be bound on both sides at once (one guest thread near a full core and the host GPU near full). Then speeding up either side alone gains little. - An ablation that removes a bound measures the bound, not the mechanism. Do not batch or reorder asynchronous commands to "fix" throughput. Find the serializing wait first. ## Output For every comparison report: - arm definitions; - valid run counts; - per-run fps; - mean ± stdev; - delta and noise band; - host warnings; - backend + D3DMetal version; - run directories. Call a result a regression only outside the noise band, and only from interleaved runs. ## When it fails - **`NEVER_STARTED`**: the console task did not start 3DMark. Usually the session is wedged after a fullscreen run; start a fresh boot. - **`INVALID` 1000 on every run**: fullscreen is being denied. Check that `install` ran `fglock` and that `fgforce` was pushed. A console window stealing focus causes it too. - **`NO_RESULT`**: the export was caught mid-write, or the run was cancelled. `bench` already falls back to the newest result in Documents. - **Big variance**: check `check-host` during the run, Spotlight indexing, the other VMs, and thermal state. Add pairs rather than trusting a single outlier.
View on GitHub