OXYGENHUB
# 9120e87480a6 enhanced-x16-emulator / docs / perf-budget.md
16.6 KiB · Markdown · last changed chore(): initial commit 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.
84
85
Unbalanced writes are tolerated rather than rejected — leaving a region that is
86
not open is ignored, and a region still open when the frame ends is closed by the
87
frame boundary. A guest that forgets a leave mis-attributes one frame, not every
88
frame after it.
89
90
### 2. Zones — exact, no guest changes
91
92
Address ranges named in the debugger or over DAP. Cycles executed inside a zone
93
are attributed to it, giving the per-routine breakdown.
94
95
A zone flagged **idle** declares "cycles here are waiting, not work". That is the
96
manual override for a wait loop the heuristic below cannot see.
97
98
Zones are looked up through a flat 64K table (`PC -> zone`), rebuilt only when
99
the zone list changes, so attribution costs one array index per instruction
100
rather than a scan of range checks. Later zones win where two overlap, which
101
makes "draw a tighter zone inside a looser one" behave as expected.
102
103
### 3. Automatic detection — zero setup, heuristic
104
105
Two signals:
106
107
* **`WAI`** — unambiguous, and free to detect.
108
* **A store-free tight loop.** A loop is identified by the *backward jump* that
109
  closes it. Once the same backward jump has closed twice with **no memory
110
  stores** and a body spanning no more than 32 bytes, its cycles are credited as
111
  waiting.
112
113
That catches the two idioms which dominate X16 code:
114
115
```asm
116
    lda $9F27       ; poll VERA's ISR for the vsync bit
117
    and #1
118
    beq -
119
120
    lda vsync_flag  ; poll a flag an IRQ handler sets
121
    beq -
122
```
123
124
#### Why the backward jump, and not PC drift
125
126
An earlier version anchored on the PC drifting outside a window. That meant
127
whatever address the walk happened to start at became the anchor — so a compact
128
routine whose whole body fits inside the window was treated as one enormous
129
"loop" that stores, and the tight, store-free poll nested inside it was never
130
seen at all. Anchoring on the branch target instead means each loop is recognised
131
as itself, however tightly the code around it is packed. Pinned by a test.
132
133
#### What automatic detection cannot see
134
135
**A wait loop that stores, or that calls a subroutine, reads as work.**
136
`jsr GETIN / cmp #0 / beq -` is the common example. "Performs no stores" is the
137
only cheap signal that separates a spin from a computation, and a loop that
138
writes memory has done something.
139
140
This is not a bug to be fixed by a cleverer heuristic — it is precisely why
141
layers 1 and 2 exist. Mark such a range as an idle zone, or bracket the real work
142
with markers, and it is counted correctly.
143
144
The detector also re-anchors whenever an interrupt is taken, so a wait loop
145
interrupted by its own vsync handler loses up to two passes before it is
146
confirmed again. Two passes of a four-instruction loop is on the order of twenty
147
cycles against a budget of 134,400, so no state is carried to avoid it.
148
149
## Interrupts
150
151
Cycles taken with an interrupt handler open are attributed to `irq` and are never
152
idle. The split between handler and main-line work usually answers "what is
153
eating my frame?" on its own, and needs no configuration: `cpu_irq_depth()`
154
already tracks it.
155
156
## Overruns, and why a saturated program is not automatically one
157
158
A frame boundary is detected *after* the instruction that crossed it, so a
159
frame's accounted cycles can overshoot the nominal period by up to one
160
instruction. Without a tolerance, a program that simply uses the whole machine
161
reads as "over budget by 3 cycles" on the frames where the boundary happens to
162
land late — measurement granularity reported as a defect. The overrun test
163
therefore allows one maximum-length instruction (8 cycles) per frame in the
164
period.
165
166
Note that with a whole-frame budget, work can never exceed the cycles that
167
actually elapsed, so utilisation approaches but does not meaningfully pass 100%.
168
Sub-frame budgets (a target faster than the machine scans) are where an overrun
169
figure carries real information: there, work genuinely can exceed what you
170
allowed it.
171
172
## Cost
173
174
Off: one load and one predictable branch per instruction, from an inline guard.
175
Nothing is allocated until profiling is switched on.
176
177
On: an add, one array index for the zone lookup, and a handful of branches — the
178
same order as the `code_map_record_current()` call the debugger already makes on
179
every instruction. The history ring is 3,600 frames (sixty seconds at 60 Hz) at
180
about 56 bytes each.
181
182
Percentiles are computed on demand by copying the window and sorting it, so a
183
window nobody asks for costs nothing.
184
185
Profiling runs while **any** owner wants it — the ImGui panel or a DAP client —
186
mirroring the ownership rule the core already applies to breakpoints. Closing the
187
panel does not strand a client with a dead feed, and a client disconnecting does
188
not switch off a panel somebody is reading. The bandwidth accounting below
189
follows the same switch and the same ownership rule, so "profile this machine" is
190
one decision rather than two.
191
192
The panel ships open, so `-imgui` profiles from boot. That is deliberate: a
193
window that starts empty and fills over the next minute cannot answer anything
194
about the frame that made you open it, and the per-instruction cost is in the
195
same class as what the debugger already spends on the code map. A run with no
196
panel and no client still costs only the guard.
197
198
The bandwidth side costs a few arithmetic ops per scanline rather than per
199
instruction — 480 lines against 134,400 cycles — and its buffers are two fixed
200
6 KB arrays, so there is nothing to allocate and nothing that can fail to.
201
202
## VERA's bandwidth
203
204
Everything above accounts for **CPU cycles**. VERA has its own budget, and a
205
program can have cycles to spare and still tear, drop sprites or run out of room
206
to push pixels — so `src/vera_bandwidth.c` measures the VRAM bus alongside.
207
208
### What the bus is
209
210
Quoted from the RTL, which for an FPGA is not a description of the hardware but
211
*is* the hardware. Pinned to `X16Community/vera-module` tag **v47.0.2**, the same
212
commit `tests/fetch_vera_rtl.py` fetches, so every line below can be checked.
213
214
| Fact | Source |
215
| --- | --- |
216
| A scanline is 800 clocks (640 + 16 + 96 + 48), 525 lines a frame | `video_vga.v:27-37` |
217
| VRAM is 32 bits wide, so **an access is four bytes** | `main_ram.v:7-10` |
218
| An access costs **two clocks**: strobe, then ack | `layer_renderer.v:250-271` |
219
| One bus, **fixed priority: CPU > layer 0 > layer 1 > sprites** | `vram_if.v:142-157`, wired at `top.v:680-713` |
220
| A tile map word holds **two** 16-bit entries, so a map fetch every two tiles | `layer_renderer.v:113`, `:305` |
221
| The layer renderer always fills **640 pixels**, then stops | `layer_renderer.v:488` |
222
223
### Why the headline is a scanline
224
225
VERA's peak is 25 MHz × 4 bytes = 100 MB/s, and a real program uses perhaps a
226
fifth of it. "Percent of total bandwidth" would therefore sit near 20% forever
227
and say nothing — the same trap as raw cycles-per-frame always being 100%.
228
229
The **800 clocks of one scanline** is the window that genuinely runs out, and at
230
full load it exactly does:
231
232
```
233
layer 0, 8bpp bitmap    160 fetches x 2 = 320 clocks
234
layer 1, 8bpp bitmap    160 fetches x 2 = 320 clocks
235
sprites, 4bpp, full line                = 160 clocks
236
                                          ----
237
                                           800   = one scanline
238
```
239
240
So the panel reports peak, p95 and mean **clocks per scanline**, and names the
241
line the peak fell on.
242
243
### Fetches per scanline
244
245
| Tile mode | 8px tiles | 16px tiles |
246
| --- | --- | --- |
247
| 1 bpp | 120 | 60 |
248
| 2 bpp | 120 | 60 |
249
| 4 bpp | 120 | 100 |
250
| 8 bpp | 200 | 180 |
251
252
| Bitmap mode | 1 bpp | 2 bpp | 4 bpp | 8 bpp |
253
| --- | --- | --- | --- | --- |
254
| Fetches | 20 | 40 | 80 | 160 |
255
256
Two things in that table surprise people:
257
258
* **A 320-wide bitmap costs the same as a 640-wide one.** `layer_renderer` has
259
  no scale input at all; `DC_HSCALE` is applied by the composer when it *reads*
260
  the line buffer. The renderer just fills to 640 pixels and runs off the end of
261
  its own line. Halving the width buys VRAM, not bandwidth.
262
* **Scrolling is not free.** The renderer starts at `0 - subtile_hscroll`
263
  (`layer_renderer.v:499-501`), so a layer scrolled off a tile boundary draws one
264
  tile more than the screen needs — three extra fetches at 8 bpp.
265
266
### The data port, and VERA FX
267
268
`$9F23`/`$9F24` is the one piece of VERA bandwidth a program controls directly,
269
so it is reported on its own: bytes read, bytes written, and what reached VRAM.
270
An 8 MHz CPU can issue roughly 64 accesses a scanline — a 4-cycle `sta` is 12.5
271
VERA clocks — which is about **33 KB a frame** and a ceiling no program can pass.
272
273
FX is where those two numbers come apart. A cache write is one store that moves
274
**four** bytes, and an affine read costs a second fetch to translate the map. The
275
panel reports the ratio, because "bytes into VRAM per byte the CPU pushed" is the
276
whole reason to use FX.
277
278
The debugger's own reads and writes are never charged. `writeMemory` with a
279
`vram:` reference reaches VRAM through `video_space_write()`, around the port
280
entirely; a write to the CPU address `$9F23` raises `video_set_debug_write()` so
281
the accounting declines it; and the read path is already guarded by `debugOn`.
282
If opening a memory view moved the number, the number would be worthless.
283
284
### Where the model is wrong
285
286
Stated plainly, because a bandwidth figure nobody can calibrate is worse than
287
none.
288
289
* **Fetches are modelled from the layer registers, not observed.** The
290
  emulator's renderer is a *pixel* loop — 640 columns reading a byte at a time,
291
  re-reading the same byte in 320-wide modes — and never fetches 32 bits at once
292
  the way the hardware does. Counting those calls would measure the emulator's
293
  inner loop rather than VERA's bus. The cost is that a bug in the emulator's
294
  renderer will not show up here.
295
* **Clocks are a lower bound; accesses are exact.** Two clocks per access is the
296
  uncontended cost. Under contention a lower-priority client holds its strobe and
297
  waits, burning more. Trust the access counts; read the clock totals as "at
298
  least this bad".
299
* **The scroll tile is an assumption.** Which tile is "every other" for the map
300
  fetch depends on the scrolled tile counter's parity, so the count can be one
301
  out at the margin.
302
* **A line is fetched during the line before it.** The layer line buffer is
303
  double-buffered (`top.v` `active_line_buf_r`, `composer.v`). Fetches are
304
  reported against the line they *paint*, which is where a developer looks.
305
* **FX is covered where the emulator implements it** — the four-byte cache write
306
  and the affine prefetch — not as a whole model of `addr_data.v`.
307
308
### Sprites, and the contention this makes visible
309
310
Sprite render time is modelled separately and reported in the VERA panel's
311
**Multiplex → Render time** view. The Bandwidth section links to it rather than
312
drawing it twice.
313
314
The two now meet at a real number — but only after a conversion, because they
315
are measured in **different clocks**.
316
317
`SPRITE_TRACE_LINE_BUDGET` (798) is **wall clock**: `render_time_r` increments
318
every clock (`sprite_renderer.v:43-56`), including the `STATE_RENDER` clocks when
319
the renderer is painting and holding no strobe at all. Everything in the
320
Bandwidth section is **bus occupancy**. A sprite spends 2 clocks fetching a word
321
and then 8 rendering it at 4 bpp, so its render time runs about five times its
322
bus time — and a full 4 bpp sprite line is roughly 158 bus clocks, nowhere near
323
798.
324
325
Subtracting one from the other therefore overstates sprite pressure fivefold and
326
warns about lines that comfortably fit, which is worse than not reporting it at
327
all. `vera_bandwidth_sprite_bus_clocks()` does the conversion, and the per-line
328
tooltip shows both figures with their units named. Every clock in the
329
`x16/perfStats` `bandwidth` object is bus occupancy, and it says so in a
330
`clockUnits` field so a client cannot make the same mistake more quietly.
331
332
Sprites are also *last* on the bus. What layers and the CPU leave is what they
333
get, so the tooltip shows the headroom after both and says when a line's sprite
334
fetches exceed it. That is the known gap made expressible: the sprite model
335
charges `STATE_WAIT_FETCH` one clock where the bus takes two, so on a line the
336
layers have filled it flatters itself. Making the sprite model itself
337
contention-aware would mean simulating the arbiter cycle by cycle, and is not
338
done.
339
340
## Where this is implemented
341
342
| File | Role |
343
| --- | --- |
344
| `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. |
345
| `src/vera_bandwidth.{c,h}` | The VRAM bus model, dependency-free for the same reason. |
346
| `src/main.c` | Per-instruction and frame-boundary hooks. |
347
| `src/memory.c` | The store signal the spin detector needs, and the `$9FBC` register. |
348
| `src/video.c` | Per-scanline layer modes, data-port traffic, and the VERA frame boundary. |
349
| `src/debug_ui/panels/perf_panel.cpp` | The Performance panel, CPU and bandwidth in one place. |
350
| `src/debug_server.c` | `x16/perfStats`, `x16/perfConfig`, and the `x16/perfBudgetOverrun` event. |
351
| `tests/test_perf_budget.c` | The classification and statistics, driven directly. |
352
| `tests/test_vera_bandwidth.c` | The fetch model, checked against the RTL's decode tables. |
353
| `testbench/test_dap.py` | Both DAP surfaces against a real running machine. |
354