| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 246 |
|
| 247 |
| 1 bpp | 120 | 60 | |
| 248 |
| 2 bpp | 120 | 60 | |
| 249 |
| 4 bpp | 120 | 100 | |
| 250 |
| 8 bpp | 200 | 180 | |
| 251 |
|
| 252 |
|
| 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 |
|
| 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 |
|