OXYGENHUB

Performance Metrics

Andy McCall edited this page · 2 revisions

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.

SectionWhat it is
budgetthe frame and its budget: targetFps, vsyncHz, cyclesPerFrame, scanlinesPerFrame, cyclesPerScanline, budgetCycles per framesPerBudget frames, historyFrames, totalFrames, idleMode
currentthe 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
platformthe 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):

WriteMeaning
$01–$3Fenter region n (they nest; cycles go to the innermost)
$80 | n / $00leave region n / the innermost
$F0the frame's work begins
$F1the frame's work ends: the rest of the frame is idle
$FEreset 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=1 adds 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 until POST /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[].zones over 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 workCycles equal to the whole frame (134,400 on the X16) and idleCycles = 0, never more. Count frames over budget by idleCycles == 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 -headless run never completes a video frame, so /step and /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:false can free the history. Use reset:true to start a measurement clean.