Performance Metrics (0.6)
GET /perf answers the question a profiler answers, how much of each frame
the program uses and on what, in a form a test can assert on and a script
can graph. It is read like /mem: on the emulator thread, between whole
frames, while the machine runs or while it is paused and stepped.
Why it exists: a released game players found slow. Its port measured itself with scanline probes compiled into the game — memory it could not spare, readings that tore — while the emulator already knew the answer and had no way to say it to a test.
A server that has it lists perf in /status.capabilities. The first
backend is the enhanced X16 emulator, which answers from the same
counters as its ImGui Performance panel.
Quick start
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \
x16emu -prg GAME.PRG -run -controlport 8391 &
curl -s -XPOST localhost:8391/perf -d '{"enabled":true}'
curl -s 'localhost:8391/perf?windows=1,session&frames=60'
emu = EmuControl(8391); emu.wait_ready()
assert "perf" in emu.capabilities()
emu.perf_config(enabled=True, reset=True)
emu.pause(); emu.step(120)
p = emu.perf(windows=[1, "session"], frames=120)
worst = max(f["workCycles"] for f in p["frames"])
assert worst < p["budget"]["budgetCycles"], "a frame went over budget"
What comes back
Every count is in guest CPU cycles unless its name says otherwise (…Us
host microseconds, …Pct percent). On the X16, 256 cycles are a scanline
and 134,400 a frame.
| Section | What it is |
|---|---|
budget | the frame and its budget: targetFps, vsyncHz, cyclesPerFrame, scanlinesPerFrame, cyclesPerScanline, budgetCycles per framesPerBudget frames, historyFrames, totalFrames, idleMode |
current | the last completed frame: totalCycles, workCycles (total − idle), idleCycles, irqCycles, mainCycles (work − irq), instructions, hostUs, utilizationPct, overBudget |
windows[] | per window asked for (windows=1,10,30,60,session by default): {min, mean, p50, p95, p99, max} of work, budget work, total, IRQ and host time; overruns, overrunPct, the worst |
zones[] | the program's own breakdown (below): each zone's current cycles and its {min…max} per window |
frames[] | raw per-frame records, oldest first: the last frames=n, or every one since=f |
platform | the platform's own — on the X16, VERA's bus and the sprite renderer (below) |
Polling without missing a frame: keep the last frame you saw and ask
since= it. Each frame comes back exactly once, however irregularly you poll.
Work, idle, and the guest's word for it
A CPU never stops, so "cycles per frame" is always 100%. The backend
separates work from waiting: a halt (WAI), a recognised spin loop,
an address range marked idle, or what the program marks so (idleMode
auto, markers or none). An interrupt handler's cycles are always work,
and are reported again as irqCycles.
Zones: the program's own breakdown
- Address zones — a PC range, set by
POST /perf:{"name":"vblank wait","start":49152,"end":49408,"bank":-1,"idle":true}. - Marker regions — the program writes markers as it enters and leaves a
region of its own. Name them in advance so the breakdown reads in the
program's words:
{"name":"sound","marker":7}.
On the X16 the markers are one-byte writes to $9FBC (reading it gives
1 where the emulator understands them; elsewhere, don't write):
| Write | Meaning |
|---|---|
$01–$3F | enter region n (they nest; cycles go to the innermost) |
$80 | n / $00 | leave region n / the innermost |
$F0 | the frame's work begins |
$F1 | the frame's work ends: the rest of the frame is idle |
$FE | reset the statistics |
A frame of your own. The emulator's frame runs vsync to vsync; a game's
frame starts where it waits, so its work runs across the emulator's
boundary. A program that brackets its work with $F0 … $F1 keeps its
regions open across that boundary, so the phase running there keeps its
cycles. Per-frame zone figures then mix the end of one game frame with the
start of the next — compare zones over a run (means, windows), not frame by
frame.
The X16's platform section
bandwidth— VERA's VRAM bus for the last frame, in bus clocks (800 a scanline; a layer fetch or a CPU data-port access takes 2): the peak, p95 and mean line, lines over budget, per-layer fetches and bytes, the CPU port's traffic.?lines=1adds each of the 480 lines' clocks.sprites— the sprite renderer's time per line against its 798-clock budget, for the last frame: peak, lines exhausted, sprites dropped, slots. Off untilPOST /perf {"sprites":true}(it costs the emulator time). These are render clocks, about five times the bus clocks: convert before comparing the two.
Graphs
Everything a profiler panel shows can be drawn from /perf:
- CPU per frame —
frames[]: work (main + IRQ) and idle stacked, the budget as a line. Alternate frames that differ (logic every other frame) show as a comb. - Statistics windows —
windows[]: min, p50, p95, p99 and max per window, against the budget. - Zones —
frames[].zonesover a run: each region's share of the frame. - The bus —
platform.bandwidth.lines: clocks per scanline against 800. - Sprites —
platform.sprites.lines: render time per line against 798.
Metal Gear (X16) does all of this: its probes mark the game loop's seven
phases at $9FBC, test/perf_capture.py drives the busy rooms and a room
change over /perf, and its report draws the five views above.
A frame, accounted
flowchart LR
vb(["vertical blank"]) --> f0["$F0<br/>the frame's work begins"]
f0 --> z1["$01 … $00<br/>zone 1"] --> z2["$02 … $00<br/>zone 2"] --> zn["… zone n"]
zn --> f1["$F1<br/>the work ends"]
f1 --> idle["waiting: WAI, a spin loop,<br/>an idle range<br/>(idleCycles)"] --> vb2(["the next vertical blank"])
irq["an interrupt handler,<br/>wherever it lands"] -. "always work, also in irqCycles" .-> z2
Work runs from $F0 to $F1 (or, without markers, everything that isn't
recognised as waiting). The zones break it down, and what's left of the
frame is idle.
Pitfalls
-
A frame that runs over reads as exactly a frame. Each video frame is accounted on its own: work that runs past the vertical blank is the next frame's. A frame that overran shows
workCyclesequal to the whole frame (134,400 on the X16) andidleCycles= 0, never more. Count frames over budget byidleCycles == 0, not by work > the frame, and fail a worst frame at the frame's last line, not above it. (Found by the Super Mario Land port; Metal Gear's report had the same flaw, its #286.) -
Display, not headless. Run with a dummy video driver; a
-headlessrun never completes a video frame, so/stepand/perf's frames stall. -
Markers switch off the heuristics. Once a program writes one, only its
$F1(and idle zones) count as waiting. A reset gives the heuristics back. -
Enabling discards nothing; disabling may.
enabled:falsecan free the history. Usereset:trueto start a measurement clean.