OXYGENHUB
# 9120e87480a6 enhanced-x16-emulator / tests / test_perf_budget.c
23.1 KiB · C · last changed chore(): initial commit 26 days ago source blame history raw
9120e874 Andy McCall 1
// Checks for guest performance budget accounting (src/perf_budget.c).
2
//
3
// The module's whole job is to decide which cycles were WORK and which were
4
// WAITING, so most of what is worth testing is the classification: that a spin
5
// loop is recognised, that something which merely looks like one is not, and
6
// that the three layers override each other in the documented order. The
7
// statistics on top of that are ordinary arithmetic, but they are the numbers a
8
// developer would act on, so the percentile and overrun maths is pinned too.
9
//
10
// perf_budget.c reaches for no emulator state, so this drives it directly: no
11
// CPU, no memory, no video, no SDL.
12
13
#include "perf_budget.h"
14
#include "support/harness.h"
15
16
#include <stdio.h>
17
#include <string.h>
18
19
// The machine this file assumes throughout: 8 MHz, VGA. Stated once here so a
20
// change of default in the module cannot quietly rewrite what the checks mean.
21
#define KHZ              8000
22
#define CYCLES_PER_FRAME 134400
23
#define SCANLINES        525
24
25
static void
26
configure(void)
27
{
28
	perf_budget_zone_clear();
29
	perf_budget_set_enabled(false);
30
	perf_budget_set_capacity(PERF_DEFAULT_CAPACITY);
31
	perf_budget_configure(KHZ, CYCLES_PER_FRAME, SCANLINES);
32
	perf_budget_set_target_fps(60.0f);
33
	perf_budget_set_idle_mode(PERF_IDLE_AUTO);
34
	perf_budget_set_enabled(true);
35
	perf_budget_reset();
36
}
37
38
// Run `count` instructions of straight-line work, each costing `clocks`.
39
static void
40
run_work(uint32_t clocks, int count)
41
{
42
	for (int i = 0; i < count; i++)
43
		perf_budget_step(clocks, (uint16_t)(0x2000 + i * 3), 0, false, 0);
44
}
45
46
// Run `passes` iterations of a three-instruction loop at `base` that stores
47
// nothing -- the shape of `lda $9F27 / and #1 / beq -`.
48
static void
49
run_spin(uint16_t base, int passes)
50
{
51
	for (int p = 0; p < passes; p++) {
52
		perf_budget_step(4, base, 0, false, 0);
53
		perf_budget_step(2, (uint16_t)(base + 3), 0, false, 0);
54
		perf_budget_step(3, (uint16_t)(base + 5), 0, false, 0);
55
	}
56
}
57
58
int
59
main(void)
60
{
61
	// ── Derived machine figures ─────────────────────────────────────────────
62
	{
63
		configure();
64
		check_eq(perf_budget_cycles_per_frame(), CYCLES_PER_FRAME,
65
		         "a VGA frame at 8 MHz is 134,400 cycles");
66
		check_eq(perf_budget_cycles_per_scanline(), 256,
67
		         "which is 256 cycles per scanline over 525 lines");
68
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
69
		         "and at 60 fps the budget is one frame's worth");
70
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1,
71
		         "spanning a single vsync");
72
73
		const float hz = perf_budget_vsync_hz();
74
		check(hz > 59.0f && hz < 60.5f, "vsync is derived at ~59.5 Hz");
75
	}
76
77
	// ── A lower target fps buys more frames, not longer ones ────────────────
78
	{
79
		configure();
80
		perf_budget_set_target_fps(30.0f);
81
		check_eq((uint32_t)perf_budget_frames_per_budget(), 2,
82
		         "30 fps lets the work span two vsyncs");
83
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME * 2,
84
		         "so the budget is two frames' cycles");
85
86
		perf_budget_set_target_fps(20.0f);
87
		check_eq((uint32_t)perf_budget_frames_per_budget(), 3, "20 fps spans three");
88
89
		perf_budget_set_target_fps(60.0f);
90
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1, "and 60 fps is back to one");
91
	}
92
93
	// ── Nothing is idle until something says so ─────────────────────────────
94
	{
95
		configure();
96
		run_work(4, 100);
97
		perf_budget_frame_end(0);
98
99
		perf_frame_t f;
100
		check(perf_budget_last_frame(&f), "a completed frame is retained");
101
		check_eq(f.total, 400, "every cycle stepped is counted");
102
		check_eq(f.idle, 0, "straight-line code is never idle");
103
		check_eq(perf_frame_work(&f), 400, "so all of it is work");
104
		check_eq(f.instructions, 100, "and the instructions are counted");
105
	}
106
107
	// ── WAI is idle, unambiguously ──────────────────────────────────────────
108
	{
109
		configure();
110
		run_work(4, 10);                             //  40 work
111
		for (int i = 0; i < 10; i++)                 // 100 idle
112
			perf_budget_step(10, 0x3000, 0, true, 0);
113
		perf_budget_frame_end(0);
114
115
		perf_frame_t f;
116
		perf_budget_last_frame(&f);
117
		check_eq(f.idle, 100, "WAI cycles are idle");
118
		check_eq(perf_frame_work(&f), 40, "and the rest is work");
119
	}
120
121
	// ── A store-free tight loop is recognised as waiting ────────────────────
122
	{
123
		configure();
124
		run_work(4, 10); // 40 work
125
		run_spin(0x4000, 20);
126
		perf_budget_frame_end(0);
127
128
		perf_frame_t f;
129
		perf_budget_last_frame(&f);
130
		// Each pass is 9 cycles. The detector re-anchors on the loop head and
131
		// only credits confirmed passes, so a couple go to work; the point is
132
		// that the great majority is recognised.
133
		check(f.idle >= 9 * 16, "a store-free spin loop is mostly idle");
134
		check(perf_frame_work(&f) < 40 + 9 * 4,
135
		      "and only the run-up is charged as work");
136
	}
137
138
	// ── A loop that stores is computing, not waiting ────────────────────────
139
	{
140
		configure();
141
		for (int p = 0; p < 20; p++) {
142
			perf_budget_step(4, 0x4000, 0, false, 0);
143
			perf_budget_step(2, 0x4003, 0, false, 0);
144
			perf_budget_note_store(); // the difference from the case above
145
			perf_budget_step(3, 0x4005, 0, false, 0);
146
		}
147
		perf_budget_frame_end(0);
148
149
		perf_frame_t f;
150
		perf_budget_last_frame(&f);
151
		check_eq(f.idle, 0, "a loop with a side effect is never idle");
152
		check_eq(perf_frame_work(&f), 9 * 20, "all of it counts as work");
153
	}
154
155
	// ── A loop wider than the span is not a spin ────────────────────────────
156
	{
157
		configure();
158
		for (int p = 0; p < 20; p++) {
159
			perf_budget_step(4, 0x5000, 0, false, 0);
160
			perf_budget_step(2, 0x5000 + PERF_SPIN_SPAN + 8, 0, false, 0);
161
		}
162
		perf_budget_frame_end(0);
163
164
		perf_frame_t f;
165
		perf_budget_last_frame(&f);
166
		check_eq(f.idle, 0, "a loop spanning more than PERF_SPIN_SPAN is not a spin");
167
	}
168
169
	// ── Interrupt cycles are counted, and are never idle ────────────────────
170
	{
171
		configure();
172
		run_spin(0x4000, 20);
173
		for (int i = 0; i < 10; i++)
174
			perf_budget_step(5, (uint16_t)(0x9000 + i * 2), 0, false, 1);
175
		perf_budget_frame_end(0);
176
177
		perf_frame_t f;
178
		perf_budget_last_frame(&f);
179
		check_eq(f.irq, 50, "cycles at interrupt depth are attributed to the handler");
180
		check(f.idle > 0, "the spin before it is still recognised");
181
		check(f.idle <= f.total - 50, "but the handler's cycles are not idle");
182
	}
183
184
	// ── An idle zone overrides the heuristic ────────────────────────────────
185
	{
186
		configure();
187
		const int z = perf_budget_zone_add("wait", 0x6000, 0x6100, -1, true);
188
		check(z >= 0, "an idle zone can be defined");
189
190
		// A loop that stores, which the heuristic would call work.
191
		for (int p = 0; p < 10; p++) {
192
			perf_budget_step(4, 0x6000, 0, false, 0);
193
			perf_budget_note_store();
194
			perf_budget_step(3, 0x6002, 0, false, 0);
195
		}
196
		perf_budget_frame_end(0);
197
198
		perf_frame_t f;
199
		perf_budget_last_frame(&f);
200
		check_eq(f.idle, 70, "an idle zone declares its cycles waiting regardless");
201
		check_eq(perf_frame_work(&f), 0, "leaving no work at all");
202
	}
203
204
	// ── Zones attribute cycles, and respect their bank ──────────────────────
205
	{
206
		configure();
207
		const int z0 = perf_budget_zone_add("sprites", 0x2000, 0x2100, -1, false);
208
		const int z1 = perf_budget_zone_add("banked", 0xA000, 0xA100, 3, false);
209
		check(z0 == 0 && z1 == 1, "zones take the first free slots");
210
211
		run_work(4, 4);                                    // $2000,$2003,$2006,$2009
212
		perf_budget_step(7, 0xA000, 3, false, 0);          // matching bank
213
		perf_budget_step(9, 0xA000, 5, false, 0);          // wrong bank
214
		perf_budget_frame_end(0);
215
216
		perf_frame_t f;
217
		perf_budget_last_frame(&f);
218
		check_eq(f.zone[0], 16, "a zone collects the cycles inside it");
219
		check_eq(f.zone[1], 7, "a banked zone collects only its own bank");
220
		check_eq(f.total, 32, "while the frame total counts everything");
221
	}
222
223
	// ── Removing a zone stops attribution ───────────────────────────────────
224
	{
225
		configure();
226
		perf_budget_zone_add("gone", 0x2000, 0x2100, -1, false);
227
		check(perf_budget_zone_remove(0), "a zone can be removed");
228
		check(!perf_budget_zone_get(0, NULL), "and is then unknown");
229
		run_work(4, 4);
230
		perf_budget_frame_end(0);
231
232
		perf_frame_t f;
233
		perf_budget_last_frame(&f);
234
		check_eq(f.zone[0], 0, "cycles are no longer attributed to it");
235
	}
236
237
	// ── Markers: the guest says where its work is ───────────────────────────
238
	{
239
		configure();
240
		check_eq(perf_budget_marker_read(), PERF_MARKER_PROTOCOL,
241
		         "the marker register reads back a protocol version");
242
243
		perf_budget_marker_write(0xF0); // work begins
244
		run_work(4, 10);                // 40 work
245
		perf_budget_marker_write(0xF1); // work ends
246
		run_work(4, 10);                // 40 idle -- and it stores, and it is
247
		perf_budget_note_store();       // straight-line, so only the marker
248
		run_work(4, 10);                // could possibly call this waiting
249
		perf_budget_frame_end(0);
250
251
		perf_frame_t f;
252
		perf_budget_last_frame(&f);
253
		check_eq(perf_frame_work(&f), 40, "only the marked span is work");
254
		check_eq(f.idle, 80, "everything after the end marker is idle");
255
	}
256
257
	// ── Marker regions claim zone slots, and nest ───────────────────────────
258
	{
259
		configure();
260
		perf_budget_marker_write(0xF0);
261
		perf_budget_marker_write(0x01); // enter region 1
262
		run_work(4, 5);                 // 20 -> region 1
263
		perf_budget_marker_write(0x02); // enter region 2, nested
264
		run_work(4, 5);                 // 20 -> region 2 (innermost wins)
265
		perf_budget_marker_write(0x82); // leave region 2
266
		run_work(4, 5);                 // 20 -> region 1 again
267
		perf_budget_marker_write(0x81); // leave region 1
268
		run_work(4, 5);                 // 20 -> unattributed
269
		perf_budget_frame_end(0);
270
271
		perf_frame_t f;
272
		perf_budget_last_frame(&f);
273
		check_eq(f.zone[0], 40, "an outer region collects its own cycles");
274
		check_eq(f.zone[1], 20, "the innermost open region wins while nested");
275
276
		perf_zone_t z;
277
		check(perf_budget_zone_get(0, &z), "a marker region claims a zone slot");
278
		check(z.is_marker && z.marker_id == 1, "tagged with the region it stands for");
279
		check(strcmp(z.name, "region 1") == 0, "and named after it");
280
	}
281
282
	// ── Unbalanced markers cost one frame, not every frame ──────────────────
283
	{
284
		configure();
285
		perf_budget_marker_write(0xF0);
286
		perf_budget_marker_write(0x05); // entered and never left
287
		run_work(4, 10);
288
		perf_budget_frame_end(0);
289
290
		run_work(4, 10); // the next frame must not still be inside region 5
291
		perf_budget_frame_end(0);
292
293
		perf_frame_t f;
294
		perf_budget_last_frame(&f);
295
		check_eq(f.zone[0], 0, "a region left open is closed by the frame boundary");
296
297
		// Leaving a region that was never entered is ignored rather than
298
		// corrupting the stack.
299
		perf_budget_marker_write(0x93);
300
		run_work(4, 5);
301
		perf_budget_frame_end(0);
302
		perf_budget_last_frame(&f);
303
		check_eq(f.total, 20, "an unmatched leave is ignored");
304
	}
305
306
	// ── Marker mode does not outlive a reset ────────────────────────────────
307
	{
308
		// Marker mode suppresses the heuristic. If a reset did not clear it, a
309
		// program that wrote one marker would disable automatic detection for
310
		// the rest of the session, with no way back.
311
		configure();
312
		perf_budget_marker_write(0xF0);
313
		run_work(4, 5);
314
		perf_budget_frame_end(0);
315
316
		perf_budget_reset();
317
		run_spin(0x4000, 20);
318
		perf_budget_frame_end(0);
319
320
		perf_frame_t f;
321
		perf_budget_last_frame(&f);
322
		check(f.idle > 0, "a reset gives automatic detection back after markers");
323
324
		// And a guest still driving markers re-asserts the mode immediately.
325
		perf_budget_reset();
326
		perf_budget_marker_write(0xF1); // work has ended: all of this is idle
327
		run_work(4, 10);
328
		perf_budget_frame_end(0);
329
		perf_budget_last_frame(&f);
330
		check_eq(f.idle, 40, "while the next marker write takes control again");
331
	}
332
333
	// ── Idle modes ──────────────────────────────────────────────────────────
334
	{
335
		configure();
336
		perf_budget_set_idle_mode(PERF_IDLE_NONE);
337
		run_spin(0x4000, 20);
338
		for (int i = 0; i < 5; i++)
339
			perf_budget_step(10, 0x3000, 0, true, 0);
340
		perf_budget_frame_end(0);
341
342
		perf_frame_t f;
343
		perf_budget_last_frame(&f);
344
		check_eq(f.idle, 0, "PERF_IDLE_NONE classifies nothing as waiting");
345
346
		configure();
347
		perf_budget_set_idle_mode(PERF_IDLE_MARKERS);
348
		run_spin(0x4000, 20);
349
		perf_budget_frame_end(0);
350
		perf_budget_last_frame(&f);
351
		check_eq(f.idle, 0, "PERF_IDLE_MARKERS ignores the spin heuristic");
352
	}
353
354
	// ── Overruns, against the budget ────────────────────────────────────────
355
	{
356
		configure();
357
		// Three frames under budget, two over.
358
		const uint32_t under = CYCLES_PER_FRAME / 2;
359
		const uint32_t over  = CYCLES_PER_FRAME + 1000;
360
		const uint32_t counts[5] = {under, over, under, over, under};
361
		for (int i = 0; i < 5; i++) {
362
			perf_budget_step(counts[i], (uint16_t)(0x2000 + i), 0, false, 0);
363
			perf_budget_frame_end(1000);
364
		}
365
366
		perf_window_t w;
367
		check(perf_budget_window(0.0f, &w), "the whole ring is a valid window");
368
		check_eq((uint32_t)w.frames, 5, "holding every frame recorded");
369
		check_eq((uint32_t)w.overruns, 2, "two of which exceeded the budget");
370
		check_eq(w.worst_overrun, 1000, "by 1000 cycles at worst");
371
		check_eq(w.work.max, over, "the worst frame is the maximum");
372
		check_eq(w.work.min, under, "and the best the minimum");
373
		check(w.max_utilization > 100.0f, "so peak utilisation is over budget");
374
		check(w.mean_utilization < 100.0f, "while the mean is under it");
375
	}
376
377
	// ── The overrun drain, which the DAP event rides on ─────────────────────
378
	{
379
		configure();
380
		perf_overrun_report_t r;
381
		check(!perf_budget_drain_overruns(&r), "a clean run reports nothing");
382
383
		perf_budget_step(CYCLES_PER_FRAME + 500, 0x2000, 0, false, 0);
384
		perf_budget_frame_end(0);
385
		perf_budget_step(CYCLES_PER_FRAME + 2500, 0x2000, 0, false, 0);
386
		perf_budget_frame_end(0);
387
388
		check(perf_budget_drain_overruns(&r), "overruns are reported once seen");
389
		check_eq(r.overruns, 2, "coalescing every frame since the last drain");
390
		check_eq(r.worst_overrun, 2500, "and keeping the worst of them");
391
		check(!perf_budget_drain_overruns(&r), "draining clears the accumulator");
392
	}
393
394
	// ── Percentiles ─────────────────────────────────────────────────────────
395
	{
396
		configure();
397
		// 100 frames of 1000..100000 cycles, ascending.
398
		for (int i = 1; i <= 100; i++) {
399
			perf_budget_step((uint32_t)i * 1000, 0x2000, 0, false, 0);
400
			perf_budget_frame_end(0);
401
		}
402
		perf_window_t w;
403
		check(perf_budget_window(0.0f, &w), "a hundred frames make a window");
404
		check_eq((uint32_t)w.frames, 100, "all of them retained");
405
		check_eq(w.work.min, 1000, "minimum is the smallest sample");
406
		check_eq(w.work.max, 100000, "maximum is the largest");
407
		check_eq(w.work.mean, 50500, "the mean is the average of 1..100 thousand");
408
		check_eq(w.work.p50, 50000, "nearest-rank p50 over 100 samples");
409
		check_eq(w.work.p95, 95000, "p95");
410
		check_eq(w.work.p99, 99000, "p99");
411
	}
412
413
	// ── The ring wraps, keeping the newest ──────────────────────────────────
414
	{
415
		configure();
416
		check(perf_budget_set_capacity(8), "the ring can be resized");
417
		check_eq((uint32_t)perf_budget_capacity(), 8, "to the size asked for");
418
419
		for (int i = 0; i < 20; i++) {
420
			perf_budget_step((uint32_t)(i + 1) * 100, 0x2000, 0, false, 0);
421
			perf_budget_frame_end(0);
422
		}
423
		check_eq((uint32_t)perf_budget_frame_count(), 8, "which then holds eight frames");
424
		check_eq(perf_budget_total_frames(), 20, "having seen twenty");
425
426
		perf_frame_t f;
427
		perf_budget_last_frame(&f);
428
		check_eq(f.total, 2000, "the newest frame survives the wrap");
429
		check_eq(f.frame, 19, "with its original ordinal");
430
431
		perf_window_t w;
432
		perf_budget_window(0.0f, &w);
433
		check_eq(w.work.min, 1300, "and the window holds only the last eight");
434
		check_eq(w.work.max, 2000, "up to the newest");
435
	}
436
437
	// ── A window narrower than the ring ─────────────────────────────────────
438
	{
439
		configure();
440
		for (int i = 0; i < 600; i++) { // ten seconds at ~60 Hz
441
			perf_budget_step(1000, 0x2000, 0, false, 0);
442
			perf_budget_frame_end(0);
443
		}
444
		perf_window_t w;
445
		check(perf_budget_window(1.0f, &w), "one second is a valid window");
446
		check(w.frames >= 55 && w.frames <= 65,
447
		      "holding about one vsync period's worth of frames");
448
449
		check(perf_budget_window(5.0f, &w), "and so is five seconds");
450
		check(w.frames >= 290 && w.frames <= 305, "with about five times as many");
451
	}
452
453
	// ── A budget spanning two frames sums the work across them ──────────────
454
	{
455
		configure();
456
		perf_budget_set_target_fps(30.0f);
457
458
		// Each frame does 3/4 of a frame's work: fine at 30 fps (1.5 of a
459
		// 2-frame budget), and would be an overrun at 60.
460
		const uint32_t each = CYCLES_PER_FRAME * 3 / 4;
461
		for (int i = 0; i < 6; i++) {
462
			perf_budget_step(each, 0x2000, 0, false, 0);
463
			perf_budget_frame_end(0);
464
		}
465
		perf_window_t w;
466
		perf_budget_window(0.0f, &w);
467
		check_eq((uint32_t)w.overruns, 0, "3/4 of a frame each is within a 30 fps budget");
468
		check_eq(w.budget_work.max, each * 2, "which is measured over two frames");
469
		check(w.max_utilization > 74.0f && w.max_utilization < 76.0f,
470
		      "putting utilisation at about 75%");
471
472
		// Now overshoot it.
473
		configure();
474
		perf_budget_set_target_fps(30.0f);
475
		for (int i = 0; i < 6; i++) {
476
			perf_budget_step(CYCLES_PER_FRAME + 5000, 0x2000, 0, false, 0);
477
			perf_budget_frame_end(0);
478
		}
479
		perf_budget_window(0.0f, &w);
480
		check(w.overruns > 0, "but a full frame of work each is not");
481
	}
482
483
	// ── A tight poll nested inside a compact routine that stores ────────────
484
	{
485
		// The case a real ROM found: a whole routine small enough to fit inside
486
		// PERF_SPIN_SPAN, containing a storing loop AND a store-free one. A
487
		// detector that anchors on PC drift rather than on the backward jump
488
		// treats the entire routine as one storing "loop" and reports no idle
489
		// at all.
490
		configure();
491
		for (int outer = 0; outer < 20; outer++) {
492
			// work loop at $C004: STA / DEX / BNE, 80 passes
493
			for (int i = 0; i < 8; i++) {
494
				perf_budget_step(4, 0xC004, 0, false, 0);
495
				perf_budget_note_store();
496
				perf_budget_step(2, 0xC007, 0, false, 0);
497
				perf_budget_step(3, 0xC008, 0, false, 0);
498
			}
499
			// idle loop at $C00C: NOP / DEX / BNE, store-free
500
			for (int i = 0; i < 8; i++) {
501
				perf_budget_step(2, 0xC00C, 0, false, 0);
502
				perf_budget_step(2, 0xC00D, 0, false, 0);
503
				perf_budget_step(3, 0xC00E, 0, false, 0);
504
			}
505
			perf_budget_step(3, 0xC010, 0, false, 0); // JMP back to the top
506
		}
507
		perf_budget_frame_end(0);
508
509
		perf_frame_t f;
510
		perf_budget_last_frame(&f);
511
		const float idle_pct = 100.0f * (float)f.idle / (float)f.total;
512
		check(idle_pct > 20.0f && idle_pct < 70.0f,
513
		      "a store-free poll nested in a compact storing routine is still found");
514
		check(f.idle > 0, "and its cycles are credited as waiting");
515
	}
516
517
	// ── A target faster than the machine scans gets a fraction of a frame ────
518
	{
519
		configure();
520
		// ~120 fps against a ~59.5 Hz vsync is very close to half a frame.
521
		perf_budget_set_target_fps(120.0f);
522
		check_eq((uint32_t)perf_budget_frames_per_budget(), 1,
523
		         "a sub-frame target still measures over one frame");
524
		const uint32_t half = perf_budget_budget_cycles();
525
		check(half > CYCLES_PER_FRAME * 45 / 100 && half < CYCLES_PER_FRAME * 55 / 100,
526
		      "and gets about half a frame's cycles");
527
528
		// A routine using three quarters of a frame genuinely misses a
529
		// half-frame budget, and this is the case where an overrun means
530
		// something: work cannot exceed a whole frame, so without sub-frame
531
		// budgets there would be nothing to overrun.
532
		for (int i = 0; i < 5; i++) {
533
			perf_budget_step(CYCLES_PER_FRAME * 3 / 4, 0x2000, 0, false, 0);
534
			perf_budget_frame_end(0);
535
		}
536
		perf_window_t w;
537
		perf_budget_window(0.0f, &w);
538
		check_eq((uint32_t)w.overruns, 5, "three quarters of a frame overruns a half-frame budget");
539
		check(w.max_utilization > 140.0f, "at around 150% utilisation");
540
	}
541
542
	// ── "60" means every frame, not 0.8% faster than the hardware ───────────
543
	{
544
		configure();
545
		// The machine scans at 59.52 Hz. Asking for 60 must not shave the
546
		// budget to 133,333 and put every saturated program over it.
547
		perf_budget_set_target_fps(60.0f);
548
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
549
		         "a 60 fps target snaps onto the 59.52 Hz frame");
550
		perf_budget_set_target_fps(59.0f);
551
		check_eq(perf_budget_budget_cycles(), CYCLES_PER_FRAME,
552
		         "and so does 59");
553
	}
554
555
	// ── A late frame boundary is not an overrun ─────────────────────────────
556
	{
557
		configure();
558
		// A frame boundary is detected after the instruction that crossed it,
559
		// so a saturated program overshoots the nominal period by a few cycles.
560
		// That is where the boundary landed, not a budget failure.
561
		for (int i = 0; i < 10; i++) {
562
			perf_budget_step(CYCLES_PER_FRAME + 3, 0x2000, 0, false, 0);
563
			perf_budget_frame_end(0);
564
		}
565
		perf_window_t w;
566
		perf_budget_window(0.0f, &w);
567
		check_eq((uint32_t)w.overruns, 0,
568
		         "overshooting the frame by one instruction is not an overrun");
569
570
		// Well past the slack still is.
571
		configure();
572
		for (int i = 0; i < 10; i++) {
573
			perf_budget_step(CYCLES_PER_FRAME + 4000, 0x2000, 0, false, 0);
574
			perf_budget_frame_end(0);
575
		}
576
		perf_budget_window(0.0f, &w);
577
		check_eq((uint32_t)w.overruns, 10, "but a real overshoot still is");
578
	}
579
580
	// ── Disabled costs nothing and records nothing ──────────────────────────
581
	{
582
		configure();
583
		perf_budget_set_enabled(false);
584
		check(!perf_budget_is_enabled(), "profiling can be switched off");
585
		run_work(4, 100);
586
		perf_budget_frame_end(0);
587
588
		perf_frame_t f;
589
		check(!perf_budget_last_frame(&f), "and then records nothing");
590
591
		perf_window_t w;
592
		check(!perf_budget_window(0.0f, &w), "with no window to report");
593
	}
594
595
	// ── Reset forgets history but keeps the configuration ───────────────────
596
	{
597
		configure();
598
		perf_budget_set_target_fps(30.0f);
599
		run_work(4, 10);
600
		perf_budget_frame_end(0);
601
		perf_budget_reset();
602
603
		check_eq((uint32_t)perf_budget_frame_count(), 0, "reset empties the ring");
604
		check_eq(perf_budget_total_frames(), 0, "and the frame ordinal");
605
		check_eq((uint32_t)perf_budget_frames_per_budget(), 2,
606
		         "while leaving the target fps alone");
607
608
		perf_budget_set_enabled(false);
609
	}
610
611
	return x16_test_summary("perf_budget");
612
}