OXYGENHUB
◆ main enhanced-x16-emulator / src / perf_budget.h
16.6 KiB · Objective-C · 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) 25 days ago source blame history raw
1
// Commander X16 Emulator — guest performance budget accounting.
2
//
3
// THE PROBLEM: "how much of my frame am I using?" has no direct answer on a
4
// 6502, because the CPU never idles. At 8 MHz a VGA frame is exactly
5
//
6
//     MHZ * 1e6 * (VGA_SCAN_WIDTH * SCAN_HEIGHT) / PIXEL_FREQ
7
//       = 8e6 * (800 * 525) / 25e6  =  134,400 cycles  (256 per scanline)
8
//
9
// and the machine burns every one of them whether the program is doing work or
10
// spinning on a vsync flag. Raw cycles-per-frame is therefore always 100% and
11
// says nothing. The number a developer actually wants is WORK versus WAITING,
12
// so this module has to classify cycles rather than merely count them.
13
//
14
// HOW CYCLES ARE CLASSIFIED, highest precedence first:
15
//
16
//   1. GUEST MARKERS ($9FBC). The program itself says where its frame work
17
//      begins and ends. Exact, and the only layer that needs no guessing --
18
//      but it needs the guest to cooperate, so it is opt-in.
19
//   2. ZONES. Address ranges named in the debugger or over DAP. A zone flagged
20
//      idle declares "cycles here are waiting", which is the manual override
21
//      for a wait loop the heuristic below cannot see.
22
//   3. AUTO. WAI, plus a store-free tight-loop detector. Zero setup, and right
23
//      for the two idioms that dominate X16 code:
24
//          lda $9F27 / and #1 / beq -      (poll VERA's ISR)
25
//          lda vsync_flag / beq -          (poll a flag an IRQ handler sets)
26
//
27
// WHAT THE AUTO LAYER CANNOT SEE: a wait loop that stores to memory or calls a
28
// subroutine -- `jsr GETIN / cmp #0 / beq -` is the common one -- reads as
29
// work, because "performs no stores" is the only cheap signal that separates a
30
// spin from a computation. That is not a bug to be fixed by a cleverer
31
// heuristic; it is why layers 1 and 2 exist. Mark such a range as an idle zone
32
// and it is counted correctly.
33
//
34
// The detector also re-anchors whenever an interrupt is taken, so a wait loop
35
// interrupted by its own vsync handler loses up to two passes before it is
36
// confirmed again. Two passes of a four-instruction loop is on the order of
37
// twenty cycles against a budget of 134,400, so the error is not worth carrying
38
// state to avoid.
39
//
40
// WHY THE BUDGET IS NOT THE FRAME PERIOD: `target_fps` asks how many vsyncs the
41
// program's frame work is allowed to span, because the frame period itself is
42
// fixed by VERA and cannot be changed by wanting it to be. Asking for 30 fps
43
// does not make frames longer; it means the work may take two of them, so the
44
// budget is two frames' worth of cycles.
45
//
46
// A target FASTER than the machine scans is a different question -- "this
47
// routine has to fit in half a frame" -- and gets a fractional budget instead.
48
// Between the two, a target within 5% of a whole number of vsyncs is snapped
49
// onto it: "60" on a 59.52 Hz machine plainly means "every frame", not "0.8%
50
// faster than the hardware can scan", and reporting the latter would put every
51
// saturated program permanently over budget for no reason a user would accept.
52
//
53
// WHY THIS FILE HAS NO EMULATOR DEPENDENCIES: it is unit-tested on its own,
54
// with no CPU, no memory, no video and no SDL -- see tests/test_perf_budget.c.
55
// Everything it needs about the machine arrives as a function argument or
56
// through perf_budget_configure(). It reaches for no globals, exactly as
57
// io_trace.c and cpu/irq_ctx.c do, and for the same reason.
58
//
59
// COST WHEN OFF: one load and one predictable branch per instruction, from the
60
// inline guards below. Nothing is allocated until profiling is switched on.
61
// COST WHEN ON: an add, one array index for the zone lookup, and a handful of
62
// branches -- the same order as the code_map_record_current() call the debugger
63
// already makes on every instruction.
64
//
65
// THREADING: the emulator steps the CPU, polls the DAP server and renders the
66
// debugger on one thread, so none of this needs locking. Do not call into it
67
// from the SDL audio callback.
68
#ifndef _PERF_BUDGET_H_
69
#define _PERF_BUDGET_H_
70
71
#include <stdbool.h>
72
#include <stdint.h>
73
74
#ifdef __cplusplus
75
extern "C" {
76
#endif
77
78
// Zones the user may define. The ring keeps per-frame history for the first
79
// PERF_RING_ZONES of them; the rest still accumulate into the current frame and
80
// are reported live, they just have no history to take percentiles over. The
81
// split exists so that the ring stays a fixed, modest size no matter how many
82
// zones somebody defines.
83
#define PERF_MAX_ZONES   16
84
#define PERF_RING_ZONES  8
85
#define PERF_ZONE_NAME_MAX 32
86
87
// How far apart the lowest and highest PC of a candidate spin loop may be. A
88
// vsync poll is three or four instructions; 32 bytes covers that with room to
89
// spare while still excluding anything that could be called a computation.
90
#define PERF_SPIN_SPAN 32
91
92
// A frame boundary is detected AFTER the instruction that crossed it, so a
93
// frame's accounted cycles can overshoot the nominal period by up to one
94
// instruction. Without a tolerance, a program that simply uses the whole
95
// machine reads as "over budget by 3 cycles" on the frames where the boundary
96
// happens to land late -- which is measurement granularity reported as a
97
// defect. This is the longest instruction on the 65C816, in cycles.
98
#define PERF_BOUNDARY_SLACK 8
99
100
// Nesting depth for guest marker regions. Deep enough for a main loop that
101
// brackets a subsystem that brackets a routine, which is as far as anyone
102
// instruments by hand.
103
#define PERF_MARKER_STACK 8
104
105
// Frames the ring holds by default: sixty seconds at 60 Hz, which is the
106
// longest window the statistics offer. Exposed so that a caller restoring the
107
// default, and the tests, cannot drift from what the module actually uses.
108
#define PERF_DEFAULT_CAPACITY 3600
109
110
// The value $9FBC reads back, so guest code can detect that it is running under
111
// an emulator that understands the marker protocol.
112
#define PERF_MARKER_PROTOCOL 1
113
114
typedef enum {
115
	PERF_IDLE_AUTO = 0, // WAI + the spin-loop detector (default)
116
	PERF_IDLE_MARKERS,  // trust only guest markers and idle zones
117
	PERF_IDLE_NONE,     // classify nothing as idle; work == total
118
} perf_idle_mode_t;
119
120
// One completed frame. 56 bytes, so a 3600-sample ring -- sixty seconds at
121
// 60 Hz -- costs about 200 KB, and only while profiling is on.
122
typedef struct {
123
	uint32_t frame;        // monotonic ordinal, never reused
124
	uint32_t total;        // every cycle in the frame period
125
	uint32_t idle;         // classified as waiting
126
	uint32_t irq;          // cycles taken with an interrupt handler open
127
	uint32_t instructions; // instructions retired
128
	uint32_t host_us;      // host wall-clock spent on this frame
129
	// Work summed over the trailing budget period -- the quantity the budget is
130
	// actually measured against. Identical to (total - idle) at 60 fps, where
131
	// the period is one frame; at 30 fps it is this frame's work plus the
132
	// previous frame's, because that is the whole span the work was allowed.
133
	uint32_t period_work;
134
	uint32_t zone[PERF_RING_ZONES];
135
} perf_frame_t;
136
137
// work == total - idle. Derived rather than stored, so the two can never
138
// disagree.
139
static inline uint32_t
140
perf_frame_work(const perf_frame_t *f)
141
{
142
	return f->total - f->idle;
143
}
144
145
typedef struct {
146
	uint32_t min, mean, p50, p95, p99, max;
147
} perf_stat_t;
148
149
typedef struct {
150
	float       seconds;       // window requested; <= 0 means the whole ring
151
	int         frames;        // samples actually in it
152
	uint32_t    budget_cycles; // what each frame was measured against
153
	perf_stat_t work;          // work cycles per frame
154
	perf_stat_t budget_work;   // work per budget period; == work at 60 fps
155
	perf_stat_t total;         // total cycles per frame
156
	perf_stat_t irq;           // interrupt cycles per frame
157
	perf_stat_t host_us;       // host time per frame
158
	int         overruns;      // frames whose work exceeded the budget
159
	float       overrun_pct;   // as a percentage of `frames`
160
	uint32_t    worst_overrun; // cycles over budget, at the worst frame
161
	uint32_t    worst_frame;   // which frame that was
162
	float       mean_utilization; // work as a percentage of the budget
163
	float       p95_utilization;
164
	float       max_utilization;
165
} perf_window_t;
166
167
// A bucket cycles are attributed to. Two kinds share the table so that the
168
// debugger and DAP have ONE breakdown to show rather than two that have to be
169
// read side by side: an address range the user drew around some code, and a
170
// region the guest itself opened with a marker write. They differ only in how
171
// membership is decided -- a PC test versus an explicit enter/leave -- which is
172
// not a difference worth duplicating the whole reporting surface over.
173
typedef struct {
174
	char     name[PERF_ZONE_NAME_MAX];
175
	uint16_t start;     // inclusive; unused for a marker region
176
	uint16_t end;       // exclusive; unused for a marker region
177
	int16_t  bank;      // < 0 matches any bank
178
	bool     is_idle;   // cycles here are waiting, not work
179
	bool     is_marker; // opened by the guest rather than bounded by address
180
	uint8_t  marker_id; // which $9FBC region id, when is_marker
181
	bool     used;
182
} perf_zone_t;
183
184
// What the DAP overrun event reports. Drained rather than polled, so the
185
// server can coalesce a burst of bad frames into one message.
186
typedef struct {
187
	uint32_t frames;   // frames elapsed since the previous drain
188
	uint32_t overruns;
189
	uint32_t worst_overrun;
190
	uint32_t worst_frame;
191
	float    worst_utilization;
192
} perf_overrun_report_t;
193
194
// ─── The hot path ───────────────────────────────────────────────────────────
195
// Read directly by the inline guards, so flipping it takes effect on the next
196
// instruction. Use perf_budget_set_enabled(), which also allocates the ring.
197
extern bool perf_budget_enabled;
198
199
void perf_budget_step_(uint32_t clocks, uint16_t pc, uint8_t pbank, bool waiting, int irq_depth);
200
void perf_budget_note_store_(void);
201
202
// Call once per instruction, AFTER the step, with the PC of the instruction
203
// that just ran -- not regs.pc, which by then addresses the next one.
204
// `irq_depth` is cpu_irq_depth(); `waiting` is the CPU's WAI state.
205
static inline void
206
perf_budget_step(uint32_t clocks, uint16_t pc, uint8_t pbank, bool waiting, int irq_depth)
207
{
208
	if (perf_budget_enabled)
209
		perf_budget_step_(clocks, pc, pbank, waiting, irq_depth);
210
}
211
212
// Call on every CPU store. The spin detector needs to know that a loop had a
213
// side effect, which is what separates waiting from computing.
214
static inline void
215
perf_budget_note_store(void)
216
{
217
	if (perf_budget_enabled)
218
		perf_budget_note_store_();
219
}
220
221
// Close the current frame and push it into the ring. `host_us` is the host
222
// wall-clock time the frame took, or 0 where nobody measured it.
223
void perf_budget_frame_end(uint32_t host_us);
224
225
// ─── Configuration ──────────────────────────────────────────────────────────
226
// Who has asked for profiling. It runs while ANY owner wants it, so a DAP
227
// client polling statistics is not switched off the moment somebody closes the
228
// debugger panel, and closing the panel does not strand a client with a dead
229
// feed. This mirrors the ownership rule the core already applies to
230
// breakpoints, and for the same reason: two consumers, one piece of state, and
231
// neither is entitled to revoke the other's.
232
typedef enum {
233
	PERF_OWNER_UI = 0,  // the ImGui Performance panel
234
	PERF_OWNER_DAP,     // a debug adapter client
235
	PERF_OWNER_REMOTE,  // an RRDC control-port client (GET/POST /perf)
236
	PERF_OWNER_COUNT
237
} perf_owner_t;
238
239
// Returns false only if the ring could not be allocated.
240
bool perf_budget_arm(perf_owner_t owner, bool want);
241
bool perf_budget_owner_wants(perf_owner_t owner);
242
243
// The unconditional form, ignoring ownership. Switching this on allocates the
244
// ring; off frees it and discards history. Returns false if the allocation
245
// failed, in which case profiling stays off.
246
bool perf_budget_set_enabled(bool enabled);
247
bool perf_budget_is_enabled(void);
248
249
// The machine's own parameters. `machine_khz` is the native clock (NOT the
250
// current speed override -- a budget measured against a machine slowed to
251
// 1 MHz for debugging would be meaningless), `cycles_per_frame` the cycles in
252
// one vsync period, `scanlines_per_frame` the total including blanking.
253
void perf_budget_configure(uint32_t machine_khz, uint32_t cycles_per_frame,
254
                           uint16_t scanlines_per_frame);
255
256
void  perf_budget_set_target_fps(float fps);
257
float perf_budget_get_target_fps(void);
258
float perf_budget_vsync_hz(void);
259
260
uint32_t perf_budget_cycles_per_frame(void);
261
uint32_t perf_budget_cycles_per_scanline(void);
262
uint32_t perf_budget_budget_cycles(void);  // what work is measured against
263
int      perf_budget_frames_per_budget(void); // vsyncs the budget spans
264
265
void             perf_budget_set_idle_mode(perf_idle_mode_t mode);
266
perf_idle_mode_t perf_budget_get_idle_mode(void);
267
268
// Ring capacity in frames. Resizing discards history. Returns false on a failed
269
// allocation, leaving the previous ring in place.
270
bool perf_budget_set_capacity(int frames);
271
int  perf_budget_capacity(void);
272
273
void perf_budget_reset(void); // forget all history; keep the configuration
274
275
// ─── Guest markers ($9FBC) ──────────────────────────────────────────────────
276
//   $01-$3F  enter region n            $80|n  leave region n
277
//   $00      leave the innermost       $F0    frame work begins
278
//   $F1      frame work ends           $FE    reset all statistics
279
//
280
// Any marker write puts the module in marker mode for the rest of the session,
281
// which suppresses the heuristic entirely: a program that says where its work
282
// is should not also be guessed at. A region id first seen here claims a zone
283
// slot named "region <n>", so marker regions and address zones appear in one
284
// breakdown rather than two.
285
//
286
// Unbalanced writes are tolerated rather than rejected, because the alternative
287
// is a debugging aid that itself needs debugging: leaving a region that is not
288
// open is ignored, and any region still open when the frame ends is closed by
289
// the frame boundary. A guest that forgets a leave therefore mis-attributes one
290
// frame, not every frame after it.
291
void    perf_budget_marker_write(uint8_t value);
292
uint8_t perf_budget_marker_read(void);
293
294
// ─── Zones ──────────────────────────────────────────────────────────────────
295
// Returns the zone index, or -1 if the table is full or the range is empty.
296
int  perf_budget_zone_add(const char *name, uint16_t start, uint16_t end,
297
                          int16_t bank, bool is_idle);
298
// A zone standing for the guest's marker region `id` ($01-$3F), named now --
299
// before the guest has entered it -- rather than "region <n>" when it does.
300
// Returns the zone index (an existing one for `id`, renamed), or -1 if the
301
// table is full or `id` is out of range.
302
int  perf_budget_zone_add_marker(const char *name, uint8_t id);
303
bool perf_budget_zone_remove(int index);
304
void perf_budget_zone_clear(void);
305
int  perf_budget_zone_count(void);   // highest index in use + 1
306
bool perf_budget_zone_get(int index, perf_zone_t *out);
307
308
// ─── Queries ────────────────────────────────────────────────────────────────
309
// All of these are read-only and safe to call while the machine runs. The
310
// percentiles are computed on demand, so a window nobody asks for costs
311
// nothing.
312
bool perf_budget_last_frame(perf_frame_t *out);
313
bool perf_budget_current_frame(perf_frame_t *out); // the frame in progress
314
int  perf_budget_frame_count(void);                // samples retained
315
uint32_t perf_budget_total_frames(void);           // frames ever completed
316
317
// One retained sample, indexed from the NEWEST: 0 is the frame just completed,
318
// 1 the one before it. Indexing from the newest rather than the oldest is what
319
// a caller actually wants -- "the last 240 frames" does not change meaning as
320
// the ring fills, and does not shift under a reader between calls.
321
bool perf_budget_frame_at(int newest_index, perf_frame_t *out);
322
323
// `seconds` <= 0 means the whole ring. False when there is no history.
324
bool perf_budget_window(float seconds, perf_window_t *out);
325
326
// Per-zone cycles over a window. Only zones below PERF_RING_ZONES have history;
327
// higher ones report their current frame and zeroes elsewhere.
328
bool perf_budget_zone_window(int zone, float seconds, perf_stat_t *out);
329
330
// Overruns since the previous drain. False when there have been none.
331
bool perf_budget_drain_overruns(perf_overrun_report_t *out);
332
333
#ifdef __cplusplus
334
}
335
#endif
336
337
#endif // _PERF_BUDGET_H_