OXYGENHUB
# 2980b7b6d7cd enhanced-x16-emulator / docs / perf-budget.md
17.8 KiB · Markdown · last changed feat: the RRDC control port (-controlport), contract 0.6: GET/POST /perf from the Performance panel's measurements, built by perf_json.c with DAP's x16/perfStats; a guest marker region named in advance; a framed guest's regions carried across the frame boundary and an interrupt after $F1 counted as work (#1) 26 days ago renderedsource blame history raw
1
# Performance budgets: telling work apart from waiting
2
3
Two budgets, in one panel and one DAP response: the **CPU's cycles**, and
4
**VERA's VRAM bandwidth**. They answer the same question — "what is eating my
5
frame?" — from the two ends that can independently run out, and the frames worth
6
looking at are the ones where they disagree.
7
8
The cycle model comes first; [VERA's bandwidth]#veras-bandwidth is below.
9
10
## The problem
11
12
"How much of my frame am I using?" has no direct answer on a 6502, because the
13
CPU never idles. At 8 MHz a VGA frame is exactly
14
15
```
16
cycles/frame = MHZ * 1e6 * (VGA_SCAN_WIDTH * SCAN_HEIGHT) / PIXEL_FREQ
17
             = 8e6 * (800 * 525) / 25e6
18
             = 134,400 cycles          (256 per scanline, over 525 lines)
19
```
20
21
and the machine burns every one of them whether your program is doing work or
22
spinning on a vsync flag. Raw cycles-per-frame is therefore always 100% and says
23
nothing at all.
24
25
The number you actually want is **work versus waiting**. Producing it means
26
classifying cycles, and classification is a judgement call — so this document
27
says exactly what judgement is made, and where it can be wrong.
28
29
## The budget
30
31
`targetFps` asks **how many vsyncs your frame work is allowed to span**. The
32
frame period is fixed by VERA and cannot be changed by wanting it to be, so
33
asking for 30 fps does not make frames longer: it means the work may take two of
34
them, and the budget becomes two frames' worth of cycles.
35
36
| Target | Budget period | Budget at 8 MHz |
37
| --- | --- | --- |
38
| 60 fps | 1 vsync | 134,400 cycles |
39
| 30 fps | 2 vsyncs | 268,800 cycles |
40
| 20 fps | 3 vsyncs | 403,200 cycles |
41
| 120 fps | 1 vsync | ~66,600 cycles |
42
43
A target *faster* than the machine scans is a different question — "must this
44
routine fit in half a frame?" — and gets a fractional budget instead. Between
45
the two, a target within 5% of a whole number of vsyncs snaps onto it: "60" on a
46
59.52 Hz machine plainly means "every frame", not "0.8% faster than the hardware
47
can scan", and reporting the latter would put every saturated program
48
permanently over budget for no reason anyone would accept.
49
50
The budget always follows the machine's **native** clock, never
51
`timing_get_speed_khz()`. The speed control exists so a routine can be watched at
52
25 kHz; a budget that shrank to match would report every frame as a catastrophic
53
overrun for a machine that is running perfectly well.
54
55
## How cycles are classified
56
57
Three layers, highest precedence first. Each exists because the one below it has
58
a limit you can hit in practice.
59
60
### 1. Guest markers (`$9FBC`) — exact, opt-in
61
62
The program itself says where its frame work begins and ends. This is the only
63
layer that involves no guessing at all, and the only one that can be right about
64
code the other two cannot see.
65
66
| Write | Meaning |
67
| --- | --- |
68
| `$01`–`$3F` | enter region *n* (nests) |
69
| `$80 \| n` | leave region *n* |
70
| `$00` | leave the innermost region |
71
| `$F0` | frame work begins |
72
| `$F1` | frame work ends — the rest of the frame is waiting |
73
| `$FE` | reset all statistics |
74
75
Reading `$9FBC` returns a protocol version, so guest code can feature-detect.
76
77
Any marker write puts the module in marker mode, which suppresses the heuristic
78
entirely: a program that says where its work is should not also be guessed at.
79
Marker mode is cleared by a reset, so a program that used markers once does not
80
lose automatic detection for the rest of the session.
81
82
A region id first seen here claims a zone slot named `region <n>`, so marker
83
regions and address zones appear in one breakdown rather than two. A client can
84
name a region before the guest first enters it — RRDC's `POST /perf` with
85
`{"name": "sound", "marker": 7}` — so the breakdown reads in the program's own
86
words.
87
88
**A frame of the guest's own.** The module's frame runs vsync to vsync; a game's
89
frame starts wherever it waits for the vertical blank, so its work routinely runs
90
across the module's boundary. A guest that frames its work with `$F0` … `$F1`
91
keeps its open regions across that boundary — the phase running there keeps its
92
cycles — until its own next `$F0` or `$F1`. (Closing them at the boundary handed
93
the rest of whichever phase was running to no zone at all: Metal Gear's "common"
94
phase read 2.8 scanlines where the guest's own cycle counter said 4.3.)
95
96
Unbalanced writes are tolerated rather than rejected — leaving a region that is
97
not open is ignored. A framed guest that forgets a leave mis-attributes the rest
98
of one of its frames, up to its next `$F0`; a guest that never writes `$F0` has a
99
region still open at the frame boundary closed there. Either way it costs one
100
frame, not every frame after it.
101
102
The cycles after `$F1` are waiting — except an interrupt handler's, which are
103
work in marker mode as in every other (see *Interrupts*).
104
105
### 2. Zones — exact, no guest changes
106
107
Address ranges named in the debugger or over DAP. Cycles executed inside a zone
108
are attributed to it, giving the per-routine breakdown.
109
110
A zone flagged **idle** declares "cycles here are waiting, not work". That is the
111
manual override for a wait loop the heuristic below cannot see.
112
113
Zones are looked up through a flat 64K table (`PC -> zone`), rebuilt only when
114
the zone list changes, so attribution costs one array index per instruction
115
rather than a scan of range checks. Later zones win where two overlap, which
116
makes "draw a tighter zone inside a looser one" behave as expected.
117
118
### 3. Automatic detection — zero setup, heuristic
119
120
Two signals:
121
122
* **`WAI`** — unambiguous, and free to detect.
123
* **A store-free tight loop.** A loop is identified by the *backward jump* that
124
  closes it. Once the same backward jump has closed twice with **no memory
125
  stores** and a body spanning no more than 32 bytes, its cycles are credited as
126
  waiting.
127
128
That catches the two idioms which dominate X16 code:
129
130
```asm
131
    lda $9F27       ; poll VERA's ISR for the vsync bit
132
    and #1
133
    beq -
134
135
    lda vsync_flag  ; poll a flag an IRQ handler sets
136
    beq -
137
```
138
139
#### Why the backward jump, and not PC drift
140
141
An earlier version anchored on the PC drifting outside a window. That meant
142
whatever address the walk happened to start at became the anchor — so a compact
143
routine whose whole body fits inside the window was treated as one enormous
144
"loop" that stores, and the tight, store-free poll nested inside it was never
145
seen at all. Anchoring on the branch target instead means each loop is recognised
146
as itself, however tightly the code around it is packed. Pinned by a test.
147
148
#### What automatic detection cannot see
149
150
**A wait loop that stores, or that calls a subroutine, reads as work.**
151
`jsr GETIN / cmp #0 / beq -` is the common example. "Performs no stores" is the
152
only cheap signal that separates a spin from a computation, and a loop that
153
writes memory has done something.
154
155
This is not a bug to be fixed by a cleverer heuristic — it is precisely why
156
layers 1 and 2 exist. Mark such a range as an idle zone, or bracket the real work
157
with markers, and it is counted correctly.
158
159
The detector also re-anchors whenever an interrupt is taken, so a wait loop
160
interrupted by its own vsync handler loses up to two passes before it is
161
confirmed again. Two passes of a four-instruction loop is on the order of twenty
162
cycles against a budget of 134,400, so no state is carried to avoid it.
163
164
## Interrupts
165
166
Cycles taken with an interrupt handler open are attributed to `irq` and are never
167
idle. The split between handler and main-line work usually answers "what is
168
eating my frame?" on its own, and needs no configuration: `cpu_irq_depth()`
169
already tracks it.
170
171
## Overruns, and why a saturated program is not automatically one
172
173
A frame boundary is detected *after* the instruction that crossed it, so a
174
frame's accounted cycles can overshoot the nominal period by up to one
175
instruction. Without a tolerance, a program that simply uses the whole machine
176
reads as "over budget by 3 cycles" on the frames where the boundary happens to
177
land late — measurement granularity reported as a defect. The overrun test
178
therefore allows one maximum-length instruction (8 cycles) per frame in the
179
period.
180
181
Note that with a whole-frame budget, work can never exceed the cycles that
182
actually elapsed, so utilisation approaches but does not meaningfully pass 100%.
183
Sub-frame budgets (a target faster than the machine scans) are where an overrun
184
figure carries real information: there, work genuinely can exceed what you
185
allowed it.
186
187
## Cost
188
189
Off: one load and one predictable branch per instruction, from an inline guard.
190
Nothing is allocated until profiling is switched on.
191
192
On: an add, one array index for the zone lookup, and a handful of branches — the
193
same order as the `code_map_record_current()` call the debugger already makes on
194
every instruction. The history ring is 3,600 frames (sixty seconds at 60 Hz) at
195
about 56 bytes each.
196
197
Percentiles are computed on demand by copying the window and sorting it, so a
198
window nobody asks for costs nothing.
199
200
Profiling runs while **any** owner wants it — the ImGui panel or a DAP client —
201
mirroring the ownership rule the core already applies to breakpoints. Closing the
202
panel does not strand a client with a dead feed, and a client disconnecting does
203
not switch off a panel somebody is reading. The bandwidth accounting below
204
follows the same switch and the same ownership rule, so "profile this machine" is
205
one decision rather than two.
206
207
The panel ships open, so `-imgui` profiles from boot. That is deliberate: a
208
window that starts empty and fills over the next minute cannot answer anything
209
about the frame that made you open it, and the per-instruction cost is in the
210
same class as what the debugger already spends on the code map. A run with no
211
panel and no client still costs only the guard.
212
213
The bandwidth side costs a few arithmetic ops per scanline rather than per
214
instruction — 480 lines against 134,400 cycles — and its buffers are two fixed
215
6 KB arrays, so there is nothing to allocate and nothing that can fail to.
216
217
## VERA's bandwidth
218
219
Everything above accounts for **CPU cycles**. VERA has its own budget, and a
220
program can have cycles to spare and still tear, drop sprites or run out of room
221
to push pixels — so `src/vera_bandwidth.c` measures the VRAM bus alongside.
222
223
### What the bus is
224
225
Quoted from the RTL, which for an FPGA is not a description of the hardware but
226
*is* the hardware. Pinned to `X16Community/vera-module` tag **v47.0.2**, the same
227
commit `tests/fetch_vera_rtl.py` fetches, so every line below can be checked.
228
229
| Fact | Source |
230
| --- | --- |
231
| A scanline is 800 clocks (640 + 16 + 96 + 48), 525 lines a frame | `video_vga.v:27-37` |
232
| VRAM is 32 bits wide, so **an access is four bytes** | `main_ram.v:7-10` |
233
| An access costs **two clocks**: strobe, then ack | `layer_renderer.v:250-271` |
234
| One bus, **fixed priority: CPU > layer 0 > layer 1 > sprites** | `vram_if.v:142-157`, wired at `top.v:680-713` |
235
| A tile map word holds **two** 16-bit entries, so a map fetch every two tiles | `layer_renderer.v:113`, `:305` |
236
| The layer renderer always fills **640 pixels**, then stops | `layer_renderer.v:488` |
237
238
### Why the headline is a scanline
239
240
VERA's peak is 25 MHz × 4 bytes = 100 MB/s, and a real program uses perhaps a
241
fifth of it. "Percent of total bandwidth" would therefore sit near 20% forever
242
and say nothing — the same trap as raw cycles-per-frame always being 100%.
243
244
The **800 clocks of one scanline** is the window that genuinely runs out, and at
245
full load it exactly does:
246
247
```
248
layer 0, 8bpp bitmap    160 fetches x 2 = 320 clocks
249
layer 1, 8bpp bitmap    160 fetches x 2 = 320 clocks
250
sprites, 4bpp, full line                = 160 clocks
251
                                          ----
252
                                           800   = one scanline
253
```
254
255
So the panel reports peak, p95 and mean **clocks per scanline**, and names the
256
line the peak fell on.
257
258
### Fetches per scanline
259
260
| Tile mode | 8px tiles | 16px tiles |
261
| --- | --- | --- |
262
| 1 bpp | 120 | 60 |
263
| 2 bpp | 120 | 60 |
264
| 4 bpp | 120 | 100 |
265
| 8 bpp | 200 | 180 |
266
267
| Bitmap mode | 1 bpp | 2 bpp | 4 bpp | 8 bpp |
268
| --- | --- | --- | --- | --- |
269
| Fetches | 20 | 40 | 80 | 160 |
270
271
Two things in that table surprise people:
272
273
* **A 320-wide bitmap costs the same as a 640-wide one.** `layer_renderer` has
274
  no scale input at all; `DC_HSCALE` is applied by the composer when it *reads*
275
  the line buffer. The renderer just fills to 640 pixels and runs off the end of
276
  its own line. Halving the width buys VRAM, not bandwidth.
277
* **Scrolling is not free.** The renderer starts at `0 - subtile_hscroll`
278
  (`layer_renderer.v:499-501`), so a layer scrolled off a tile boundary draws one
279
  tile more than the screen needs — three extra fetches at 8 bpp.
280
281
### The data port, and VERA FX
282
283
`$9F23`/`$9F24` is the one piece of VERA bandwidth a program controls directly,
284
so it is reported on its own: bytes read, bytes written, and what reached VRAM.
285
An 8 MHz CPU can issue roughly 64 accesses a scanline — a 4-cycle `sta` is 12.5
286
VERA clocks — which is about **33 KB a frame** and a ceiling no program can pass.
287
288
FX is where those two numbers come apart. A cache write is one store that moves
289
**four** bytes, and an affine read costs a second fetch to translate the map. The
290
panel reports the ratio, because "bytes into VRAM per byte the CPU pushed" is the
291
whole reason to use FX.
292
293
The debugger's own reads and writes are never charged. `writeMemory` with a
294
`vram:` reference reaches VRAM through `video_space_write()`, around the port
295
entirely; a write to the CPU address `$9F23` raises `video_set_debug_write()` so
296
the accounting declines it; and the read path is already guarded by `debugOn`.
297
If opening a memory view moved the number, the number would be worthless.
298
299
### Where the model is wrong
300
301
Stated plainly, because a bandwidth figure nobody can calibrate is worse than
302
none.
303
304
* **Fetches are modelled from the layer registers, not observed.** The
305
  emulator's renderer is a *pixel* loop — 640 columns reading a byte at a time,
306
  re-reading the same byte in 320-wide modes — and never fetches 32 bits at once
307
  the way the hardware does. Counting those calls would measure the emulator's
308
  inner loop rather than VERA's bus. The cost is that a bug in the emulator's
309
  renderer will not show up here.
310
* **Clocks are a lower bound; accesses are exact.** Two clocks per access is the
311
  uncontended cost. Under contention a lower-priority client holds its strobe and
312
  waits, burning more. Trust the access counts; read the clock totals as "at
313
  least this bad".
314
* **The scroll tile is an assumption.** Which tile is "every other" for the map
315
  fetch depends on the scrolled tile counter's parity, so the count can be one
316
  out at the margin.
317
* **A line is fetched during the line before it.** The layer line buffer is
318
  double-buffered (`top.v` `active_line_buf_r`, `composer.v`). Fetches are
319
  reported against the line they *paint*, which is where a developer looks.
320
* **FX is covered where the emulator implements it** — the four-byte cache write
321
  and the affine prefetch — not as a whole model of `addr_data.v`.
322
323
### Sprites, and the contention this makes visible
324
325
Sprite render time is modelled separately and reported in the VERA panel's
326
**Multiplex → Render time** view. The Bandwidth section links to it rather than
327
drawing it twice.
328
329
The two now meet at a real number — but only after a conversion, because they
330
are measured in **different clocks**.
331
332
`SPRITE_TRACE_LINE_BUDGET` (798) is **wall clock**: `render_time_r` increments
333
every clock (`sprite_renderer.v:43-56`), including the `STATE_RENDER` clocks when
334
the renderer is painting and holding no strobe at all. Everything in the
335
Bandwidth section is **bus occupancy**. A sprite spends 2 clocks fetching a word
336
and then 8 rendering it at 4 bpp, so its render time runs about five times its
337
bus time — and a full 4 bpp sprite line is roughly 158 bus clocks, nowhere near
338
798.
339
340
Subtracting one from the other therefore overstates sprite pressure fivefold and
341
warns about lines that comfortably fit, which is worse than not reporting it at
342
all. `vera_bandwidth_sprite_bus_clocks()` does the conversion, and the per-line
343
tooltip shows both figures with their units named. Every clock in the
344
`x16/perfStats` `bandwidth` object is bus occupancy, and it says so in a
345
`clockUnits` field so a client cannot make the same mistake more quietly.
346
347
Sprites are also *last* on the bus. What layers and the CPU leave is what they
348
get, so the tooltip shows the headroom after both and says when a line's sprite
349
fetches exceed it. That is the known gap made expressible: the sprite model
350
charges `STATE_WAIT_FETCH` one clock where the bus takes two, so on a line the
351
layers have filled it flatters itself. Making the sprite model itself
352
contention-aware would mean simulating the arbiter cycle by cycle, and is not
353
done.
354
355
## Where this is implemented
356
357
| File | Role |
358
| --- | --- |
359
| `src/perf_budget.{c,h}` | All the cycle accounting. Reaches for no emulator state — everything arrives as a function argument — so it is unit-tested with no CPU, no memory, no video and no SDL. |
360
| `src/vera_bandwidth.{c,h}` | The VRAM bus model, dependency-free for the same reason. |
361
| `src/main.c` | Per-instruction and frame-boundary hooks. |
362
| `src/memory.c` | The store signal the spin detector needs, and the `$9FBC` register. |
363
| `src/video.c` | Per-scanline layer modes, data-port traffic, and the VERA frame boundary. |
364
| `src/debug_ui/panels/perf_panel.cpp` | The Performance panel, CPU and bandwidth in one place. |
365
| `src/perf_json.{c,h}` | The measurements as JSON, one builder each for the two remote surfaces below. |
366
| `src/debug_server.c` | `x16/perfStats`, `x16/perfConfig`, and the `x16/perfBudgetOverrun` event. |
367
| `src/control_backend.c` | The RRDC control port's `GET`/`POST /perf` (contract 0.6), beside its memory, stepping and input. |
368
| `tests/test_perf_budget.c` | The classification and statistics, driven directly. |
369
| `tests/test_vera_bandwidth.c` | The fetch model, checked against the RTL's decode tables. |
370
| `testbench/test_dap.py` | Both DAP surfaces against a real running machine. |
371