diff --git a/AGENTS.md b/AGENTS.md index 3d8c20a..81c42ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,8 @@ ctest --test-dir build --output-on-failure Run one test while iterating, for example `ctest --test-dir build -R sprite --output-on-failure`. The `rebuild.sh` script also installs into a developer-specific `/home/andrew/local` prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended. +Run only the performance suites with `ctest --test-dir build -L perf --output-on-failure`, or leave them out of an ordinary run with `-LE perf`. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. `AKGL_BENCH_SCALE` scales every iteration count — `AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf` for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in `PERFORMANCE.md`. + Run mutation testing with `cmake --build build --target mutation`. For a quick smoke run, use `scripts/mutation_test.py --target src/tilemap.c --max-mutants 10`; the harness mutates only a scratch copy and excludes the intentionally failing character test. Generate HTML and Cobertura coverage reports with `cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug`, then build and run CTest. Reports are written to `build-coverage/coverage/`; this mode requires `gcovr` and GCC or Clang. @@ -269,6 +271,14 @@ because the failure mode is silent. - `CATCH` reports failure by `break`ing, which binds to the innermost enclosing loop or `switch`. A `CATCH` written directly inside a `while` exits the loop rather than the function — put the `ATTEMPT` block inside the loop. +- **Never `return` from inside a `HANDLE` block either.** `FINISH` ends with + `RELEASE_ERROR`, so leaving before it means the handled context is never given + back to `AKERR_ARRAY_ERROR` — one leaked slot per call, and the 129th call + aborts the process with "Unable to pull an error context from the array!". + `SUCCEED_RETURN` is safe because it releases first; a bare `return`, or a + `return f(...)` that tail-calls the fallback, is not. Set a flag in the + `HANDLE` block and act on it after `FINISH`. This has already cost a + process-killing leak in `akgl_path_relative`; see TODO.md, "Performance". - Validate every pointer parameter before dereferencing it, including the ones a sibling function happens not to check. @@ -296,6 +306,33 @@ Declare no-argument functions as `(void)`, not `()`. Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold. Add focused tests as `tests/.c`, create a matching `test_` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward. +### Performance suites + +`tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that +does) are benchmarks, built and registered like any other suite but listed in +`AKGL_PERF_SUITES` so they carry the `perf` label and a longer timeout. The +harness is `tests/benchutil.h`. Three rules keep the numbers honest, and all +three were learned by getting them wrong first: + +- **Nothing that checks an error goes inside the clock.** `PASS` and `CATCH` + call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work + than several of the calls being measured. Use `BENCH_LOOP`, which stashes the + context and stops at the first failure, and hand it to `PASS` afterwards. +- **Flush the renderer before stopping the clock.** SDL batches: a `draw_*` call + queues a command and returns. `BENCH_FLUSH_STOP` in `tests/perf_render.c` is + there because the first version of that suite measured queueing, reported a + tilemap frame 250 times faster than it is, and paid the real cost at teardown + inside `SDL_DestroyTexture`. +- **A drawing benchmark needs a raw-SDL control that does the same pixel work.** + Without one there is no way to separate what libakgl costs from what the + rasterizer costs, and the answer is not the one you would guess — see + `PERFORMANCE.md`. + +Budgets are per-operation ceilings at roughly ten times the recorded baseline; +they are enforced only in an optimized build at full scale. When a change moves +a number for a good reason, re-record the baseline in `PERFORMANCE.md` in the +same commit and say why it moved. + ## Commit & Pull Request Guidelines Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes. diff --git a/CMakeLists.txt b/CMakeLists.txt index 1253a8e..a18a4b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -202,11 +202,27 @@ set(AKGL_TEST_SUITES version ) +# The performance suites are built and registered exactly like the unit suites, +# but they are benchmarks: they drive hot paths for millions of iterations, print +# a table of nanoseconds per operation, and fail only when a measurement exceeds +# a budget set at roughly ten times the recorded baseline. They carry the `perf` +# label, so `ctest -L perf` runs only them and `ctest -LE perf` leaves them out +# of an ordinary run, and they get a much longer timeout for obvious reasons. +# +# Set AKGL_BENCH_SCALE in the environment to change how long they run -- +# `AKGL_BENCH_SCALE=0.1 ctest -L perf` for a tenth of the iterations. Below 1.0 +# the budgets are measured and reported but not enforced, because a short run is +# a noisy one. +set(AKGL_PERF_SUITES + perf + perf_render +) + # The executables carry an akgl_ prefix but the CTest names do not: a vendored # dependency is free to ship its own tests/version.c, and libakstdlib now does. # Its target is created by add_subdirectory even though EXCLUDE_FROM_ALL keeps # it from being built, so an unprefixed test_version here is a configure error. -foreach(suite IN LISTS AKGL_TEST_SUITES) +foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) add_executable(akgl_test_${suite} tests/${suite}.c) add_test(NAME ${suite} COMMAND akgl_test_${suite}) endforeach() @@ -218,6 +234,14 @@ set_tests_properties( PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" TIMEOUT 30 ) +set_tests_properties( + ${AKGL_PERF_SUITES} + PROPERTIES + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" + TIMEOUT 900 + LABELS perf +) + # Specify include directories for the library's headers (if applicable) target_include_directories(akgl PUBLIC include/ @@ -243,7 +267,9 @@ if(AKGL_COVERAGE) ) set_tests_properties(coverage_reset PROPERTIES FIXTURES_SETUP akgl_coverage) # Any suite missing from this list runs outside the fixture and has its - # counters discarded by coverage_reset. + # counters discarded by coverage_reset. The perf suites are deliberately + # outside it: an instrumented -O0 build measures gcov, not libakgl, and the + # lines they cover are covered by the unit suites anyway. set_tests_properties( ${AKGL_TEST_SUITES} semver_unit PROPERTIES FIXTURES_REQUIRED akgl_coverage @@ -278,7 +304,7 @@ target_link_libraries(akgl jansson::jansson ) -foreach(suite IN LISTS AKGL_TEST_SUITES) +foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) target_link_libraries(akgl_test_${suite} PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm) target_include_directories(akgl_test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") endforeach() @@ -300,7 +326,7 @@ if(AKGL_VENDORED_DEPENDENCIES) "$" "$" ) - foreach(suite IN LISTS AKGL_TEST_SUITES) + foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES) set_target_properties(akgl_test_${suite} PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") endforeach() set_target_properties(charviewer akgl PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") @@ -324,13 +350,13 @@ if(AKGL_VENDORED_DEPENDENCIES) list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() set_tests_properties( - ${AKGL_TEST_SUITES} + ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" ) else() string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") set_tests_properties( - ${AKGL_TEST_SUITES} + ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" ) endif() diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..efbe3d6 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,352 @@ +# libakgl performance baseline + +This is where the library actually spends its time, measured rather than guessed. +The numbers below are the first recorded baseline: libakgl 0.3.0, at commit +`f35443e` plus the perf suites themselves. Everything here is reproducible with +two commands, and the suites that produced it are checked in as +`tests/perf.c` and `tests/perf_render.c`. + +Read it in this order if you only want the short version: **the frame budget** is +the part that matters, **what the numbers say** is the argument, and **defects +these tests found** is the part that cost me a day. + +## Reproducing it + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo +cmake --build build --parallel +ctest --test-dir build -L perf --output-on-failure +``` + +Both suites print a table and exit non-zero if any measurement blew its budget. +They are ordinary CTest tests, so `ctest --test-dir build` runs them along with +everything else; `ctest --test-dir build -LE perf` leaves them out when you only +want the unit suites. `AKGL_BENCH_SCALE` scales every iteration count — +`AKGL_BENCH_SCALE=0.1` for a quick look, `10` for a long one. Below 1.0 the +budgets are reported but not enforced, because a short run is a noisy one. + +The whole thing takes about 30 seconds: 4 s for `perf`, 25 s for `perf_render`. + +## The machine + +| | | +|---|---| +| CPU | AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max | +| Memory | 62 GiB | +| OS | Linux 6.8.0-136-generic | +| Compiler | gcc 13.3.0, `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -g`) | +| SDL | vendored SDL 3.4.8, `dummy` video driver, `software` renderer | +| Target | 640x480, offscreen | + +One machine, one build type, one afternoon. Treat the absolute numbers as this +laptop's and the *ratios* as the library's. + +## How it is measured + +- **Best of five.** Each benchmark runs five times and the harness keeps the + fastest. The mean is the wrong statistic on a shared machine: every source of + noise makes a run slower and none makes it faster. +- **No error checking inside the clock.** `PASS` and `CATCH` call + `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than + several of the calls being measured. The timed loop stashes the context and + checks it after the clock stops. +- **The renderer is flushed inside the measurement.** SDL batches: a `draw_*` + call queues a command and returns. The first version of this suite measured + *queueing* and reported a tilemap frame at 65 µs; the deferred work then took + 114 seconds to come out at teardown, inside `SDL_DestroyTexture`. Every + drawing benchmark now flushes before it stops the clock, and the numbers below + are 250x larger and true. +- **SDL's log output goes to a sink that discards it.** The library logs on + paths this suite calls hundreds of thousands of times; timing a write to a + terminal measures the terminal. What the *formatting* costs is measured + deliberately, by the pair of actor-spawn benchmarks. +- **Budgets are set at roughly 10x the measured baseline.** Loose enough that a + busy machine does not turn CI red, tight enough that a linear scan becoming + quadratic cannot hide. They are enforced only in an optimized build at full + scale — a coverage build measures gcov, not libakgl. + +**The renderer caveat, stated once and loudly.** These draw benchmarks run +against SDL's *software* renderer. No shipped game does that. What a software +renderer buys is that all the work stays in this process where it can be timed, +and that the per-blit cost is at least honest about how much pixel traffic was +asked for. Read every drawing number as *a count of work libakgl asked for*, not +as a frame rate anyone would ship. That is exactly why the raw-SDL control rows +exist: they do the same pixel work with none of the library in the path, so the +difference between the two is libakgl's share, and that part *does* carry over +to a GPU backend. + +## Static footprint + +libakgl does not call `malloc`. Every one of these arrays exists from process +start whether the game uses one slot or all of them. + +| Pool | Slots | Bytes each | Total | +|---|---:|---:|---:| +| `HEAP_ACTOR` | 64 | 400 | 25,600 | +| `HEAP_SPRITE` | 1024 | 176 | 180,224 | +| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 | +| `HEAP_CHARACTER` | 256 | 184 | 47,104 | +| `HEAP_STRING` | 256 | 4,100 | 1,049,600 | +| **pools, total** | | | **1,851,392** | +| `akgl_Tilemap` (one, as `_akgl_gamemap`) | 1 | 26,388,008 | 26,388,008 | +| — of which layers | 16 | 1,120,296 | 17,924,736 | +| — of which tilesets | 16 | 528,944 | 8,463,104 | + +**28 MB of BSS before `main` runs, and 94% of it is one tilemap.** A layer is +512x512 `int` cells whether the map is 512x512 or 2x2, and a tileset carries a +65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is +not a hypothetical cost: zeroing that struct is 1.37 ms, and `akgl_tilemap_load` +pays it before it has read a byte of the map file. + +## Results: everything that does not draw + +`tests/perf.c`, full scale. `ns/op` is the best of five runs; `ops/sec` is its +reciprocal. + +| Benchmark | Unit | ns/op | ops/sec | +|---|---|---:|---:| +| `heap_next_actor`, empty pool | call | 4.0 | 248,392,158 | +| `heap_next_actor`, one slot left | call | 36.9 | 27,077,696 | +| heap string claim + release cycle | cycle | 49.8 | 20,084,967 | +| `heap_next_string`, empty pool | call | 3.9 | 255,059,748 | +| `heap_next_string`, one slot left | call | 250.9 | 3,986,007 | +| `heap_release_string`, 4 KiB wipe | call | 47.2 | 21,191,918 | +| `heap_init`, all five pools | call | 45,141 | 22,153 | +| actor spawn + release, library logging on | actor | 202.3 | 4,942,862 | +| actor spawn + release, logging suppressed | actor | 162.5 | 6,153,324 | +| `akgl_actor_set_character`, registry lookup | call | 39.3 | 25,438,092 | +| `akgl_set_property` | call | 96.9 | 10,316,516 | +| `akgl_get_property`, 4 KiB copy | call | 85.3 | 11,722,134 | +| `akgl_character_sprite_get`, state to sprite | call | 37.4 | 26,734,605 | +| `akgl_actor_update`, animation advancing | actor | 68.4 | 14,609,892 | +| `akgl_actor_update`, no sprite for state | actor | 616.5 | 1,621,937 | +| `akgl_physics_simulate`, 64 live actors | frame | 1,216.8 | 821,841 | +| `akgl_physics_simulate`, empty pool | frame | 63.9 | 15,650,101 | +| logic frame: 64 updates + simulate | frame | 5,763.1 | 173,517 | +| `akgl_rectangle_points` | call | 4.0 | 248,412,026 | +| `akgl_collide_rectangles`, overlapping | call | 24.9 | 40,181,108 | +| `akgl_collide_rectangles`, disjoint | call | 57.9 | 17,261,906 | +| all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 115,023.6 | 8,694 | +| `akgl_string_initialize` | call | 32.1 | 31,197,432 | +| `akgl_string_copy`, full length | call | 32.2 | 31,075,004 | +| `json_load_file`, small document | load | 11,338.8 | 88,193 | +| `akgl_get_json_string_value` | call | 40.7 | 24,552,958 | +| `akgl_get_json_integer_value` | call | 13.8 | 72,704,562 | +| `akgl_path_relative`, `realpath` on an existing file | call | 3,481.5 | 287,232 | + +## Results: everything that draws + +`tests/perf_render.c`, full scale, 640x480 software renderer. The two **control** +rows are raw `SDL_RenderTexture` loops with no libakgl in the path. + +| Benchmark | Unit | ns/op | ops/sec | +|---|---|---:|---:| +| `frame_start` + `frame_end` (clear + present) | frame | 23,161.7 | 43,175 | +| `akgl_draw_point` | call | 132.5 | 7,547,141 | +| `akgl_draw_line`, screen diagonal | call | 605.9 | 1,650,375 | +| `akgl_draw_rect`, 200x150 outline | call | 520.8 | 1,920,055 | +| `akgl_draw_filled_rect`, 200x150 | call | 43,695.1 | 22,886 | +| `akgl_draw_circle`, radius 64 | call | 2,516.2 | 397,423 | +| `akgl_draw_copy_region`, 64x64 readback | call | 954.7 | 1,047,471 | +| `akgl_draw_paste_region`, 64x64 upload | call | 5,352.1 | 186,841 | +| `akgl_draw_flood_fill`, full 640x480 target | call | 1,967,295.6 | 508 | +| `akgl_text_measure`, 15 characters | call | 37.3 | 26,804,657 | +| `akgl_text_rendertextat`, 15 characters | call | 12,601.7 | 79,354 | +| `akgl_sprite_load_json`, sheet already loaded | load | 17,011.3 | 58,784 | +| `akgl_character_load_json`, two mappings | load | 14,506.8 | 68,933 | +| `akgl_tilemap_load` + release, fixture map | load | 11,881,190.8 | 84 | +| zeroing one `akgl_Tilemap` | call | 1,372,396.7 | 729 | +| `akgl_tilemap_compute_tileset_offsets`, 1728 tiles | call | 2,240.8 | 446,264 | +| `akgl_tilemap_draw`, 40x30 tiles, 1 tileset | frame | 16,260,566.6 | 61 | +| `akgl_tilemap_draw`, 40x30 tiles, 8 tilesets | frame | 16,395,182.4 | 61 | +| **control**: raw SDL blits, one source tile | frame | 471,676.6 | 2,120 | +| **control**: raw SDL blits, map order | frame | 16,229,501.0 | 62 | +| `akgl_actor_render`, on camera | actor | 2,992.0 | 334,220 | +| `draw_world`, 1200 tiles + 64 actors | frame | 16,484,493.6 | 61 | +| `akgl_game_update`, full frame | frame | 16,576,902.2 | 60 | + +## The frame budget + +At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a +full screen of 16-pixel tiles and 64 actors: + +| Part of the frame | Cost | Share of 16.67 ms | +|---|---:|---:| +| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% | +| All-pairs collision over 64 actors, if you do it | 0.115 ms | 0.7% | +| Clear + present | 0.023 ms | 0.1% | +| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% | +| 1200 tile blits | 16.26 ms | 97.6% | +| Six lines of HUD text | 0.076 ms | 0.5% | + +**Everything libakgl decides is free. The pixels are the whole frame.** Every +piece of bookkeeping this library does — pool scans, registry lookups, state-to- +sprite mapping, physics, the error-context machinery — adds up to well under 1% +of a frame that is 97% software rasterization. On a GPU backend those blits get +cheap and libakgl's own share rises, which is exactly why the per-operation +numbers above matter more than the frame totals. + +## What the numbers say + +### The tilemap draw is SDL, not libakgl — and I can prove it + +`akgl_tilemap_draw` takes 16.26 ms for a 1200-tile screen. A raw +`SDL_RenderTexture` loop issuing the *same 1200 blits from the same scattered +source tiles* takes 16.23 ms. The library's own per-tile work — the bounds +arithmetic, the tileset scan, the offset-table lookup, the backend indirection, +the error macros — is **0.03 ms per frame, under 0.2%**. + +That took three attempts to measure honestly. A control that walked the sheet +sequentially said libakgl cost 67%; a control that blitted one source tile over +and over said it cost 3400%. Both were wrong, and both were wrong the same way: +they changed the *memory access pattern of the source texture* rather than +isolating the library. A 16x16 tile read from a random place in a 768x576 sheet +costs about 13 µs on this software rasterizer; the same tile read from cache +costs 0.4 µs. That factor of thirty is the whole story, and none of it is +libakgl's. + +Related: the `FIXME` in `src/tilemap.c` worrying that the per-tile tileset scan +"is probably not very efficient" is, at eight tilesets, worth **0.8% of the +frame** (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it +should still be fixed, but it is not where the time is, and nobody should +reorganise the loader for it. + +### The pools are linear scans, and only the string pool cares + +Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is +36.9 ns — nine times the cost, and still nothing. + +The string pool is the exception, and it is instructive. Claiming from an empty +string pool is 3.9 ns; claiming the *last free slot* is 250.9 ns, **64 times** +slower. Same algorithm, same 256-ish entries. The difference is that each +`akgl_String` is `PATH_MAX` + 4 bytes, so the scan touches one reference count +every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is +a megabyte, and walking it is walking a megabyte. + +`akgl_heap_release_string` costs 47.2 ns because it `memset`s all 4,100 bytes +whether the string held a path or one character. Same for `akgl_string_copy` +(32.2 ns) and `akgl_get_property` (85.3 ns), which move `AKGL_MAX_STRING_LENGTH` +bytes unconditionally. None of these is expensive in isolation; all of them are +the same avoidable habit of paying for `PATH_MAX` when you used eleven bytes. + +### Errors cost about ten times what success costs + +`akgl_actor_update` on an actor whose character has a sprite for its state: +68.4 ns. The same call on an actor whose character does *not*: **616.5 ns**. + +That path is not an error in any meaningful sense — the library handles +`AKERR_KEY` and carries on, and `akgl_actor_render` logs it and draws nothing. +It is a normal condition on a partly authored character. But raising it means +claiming a context out of `AKERR_ARRAY_ERROR`, formatting a message with +`vsnprintf`, appending a stack-trace frame, walking the `PROCESS` switch, and +releasing it again. Nine times the cost of the update it replaced. + +The design conclusion is not "make errors cheaper". It is that a *routine* +condition should not be reported as an error. A character that has no sprite for +a state should answer that question with a boolean. + +### `akgl_game_update` updates every actor sixteen times + +`src/game.c:617` loops over `AKGL_TILEMAP_MAX_LAYERS`, and the actor sweep +nested inside it does not filter by layer. Every live actor's `updatefunc` runs +**16 times per frame**. At 68.4 ns per update and 64 actors that is 70 µs of +work to do 4.4 µs of work. + +It is invisible in the frame total here because the tilemap blits are three +orders of magnitude larger. On a GPU backend, where the frame might be 2 ms, it +is 3.5% of the frame doing nothing. Filed in `TODO.md`. + +### Text has no cache at all + +`akgl_text_rendertextat` is 12.6 µs for fifteen characters: it rasterizes the +string, uploads it as a texture, blits it, and destroys the texture — every +call, every frame, for a score that changes once a second. Measuring the string +first with `akgl_text_measure` is 37.3 ns, i.e. free, which tells you the whole +cost is the rasterize-and-upload. + +Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it +is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour) +would take it to nothing, and it is the single clearest optimisation in the +library. + +### Loading is dominated by things that are not the file + +A 2x2 fixture map with one tileset takes **11.9 ms** to load and release. Of +that, 1.37 ms — 11.5% — is `memset`ing the 26 MB `akgl_Tilemap` before anything +is read. Most of the rest is decoding the tileset PNG. The JSON is noise: +parsing a small document is 11.3 µs, and the accessors are 14-41 ns each. + +`akgl_path_relative` is 3.5 µs, because it is a `realpath(3)` syscall. A map +naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall +and not a string operation. + +### The collision helpers are fine; the missing broad phase is the problem + +`akgl_collide_rectangles` is 24.9 ns when the rectangles overlap (it returns at +the first corner that hits) and 57.9 ns when they do not (all eight corner tests +run). Both are fine. + +What the library does not provide is a broad phase, so a caller that wants +collision writes the all-pairs loop: 2016 pairs for 64 actors, 115 µs a frame, +0.7% of a 60 fps budget. That is affordable. It is also O(n²): raise +`AKGL_MAX_HEAP_ACTOR` to 256 and the same loop is 32,640 pairs and 1.9 ms — over +10% of the frame, for a game that has done nothing yet. + +### Spawning is cheap, and a third of it is a log line + +An actor spawn — pool claim, `memset`, registry insert, release — is 202.3 ns +with the library's logging on and 162.5 ns with SDL's log priority raised so the +message is never formatted. **20% of a spawn is formatting a log line nobody +reads**, and that is with output going to a sink that throws it away; write it to +a terminal and it is far worse. `akgl_actor_initialize` and +`akgl_character_sprite_add` both log unconditionally at `INFO`. + +## Defects these tests found + +Stress testing is worth doing because it breaks things that unit tests do not. +All six of these came out of writing this suite. Each is filed in `TODO.md` with +its file, line, and blast radius. + +1. **`akgl_path_relative` leaked an error context per call** on the root-fallback + branch, because the branch `return`ed from inside its `HANDLE` block and so + skipped the `RELEASE_ERROR` in `FINISH`. The 129th such call exhausted + `AKERR_ARRAY_ERROR` and **aborted the process**. Every map load resolves + several paths this way. *Fixed in this change*, with a regression test in + `tests/util.c` that resolves 256 paths and asserts the pool is where it + started. +2. **`akgl_tilemap_load` leaks five pooled strings per load** that + `akgl_tilemap_release` does not give back. The 52nd map load in a process + finds the string pool empty. Not fixed — it needs the loader gone over + properly, and that deserves its own commit. +3. **`akgl_get_json_string_value` turns pool exhaustion into a segfault.** It + finishes with `FINISH(errctx, false)`, so a failed `akgl_heap_next_string` is + swallowed, and the next line dereferences the pointer it never set + (`src/json_helpers.c:91`). This is what defect 2 actually looks like from the + outside: not `AKGL_ERR_HEAP`, a crash. +4. **`akgl_game_update` segfaults if `akgl_game_init` did not run.** + `akgl_game_updateFPS` calls `game.lowfpsfunc` through an unguarded pointer on + every frame under 30 fps, which includes the first frame. That is precisely + the path `renderer.h` documents for an embedder — `akgl_render_bind2d` over a + window the host already owns. +5. **`akgl_game_update`'s 16x update multiplier**, above. +6. **`akgl_heap_release_character` leaks the character's `state_sprites` + property set** and never drops the references it took on its sprites. Already + documented in `heap.h`; this suite makes it observable, since a benchmark that + loads a character 10,000 times leaks 10,000 property sets. + +## What this report does not cover + +- **One machine.** No ARM, no Raspberry Pi, no Windows, no macOS. The ratios + should travel; the absolute numbers will not. +- **No GPU backend.** Everything drawing-related is a software rasterizer under + the dummy video driver. The share of a frame that belongs to libakgl is a + floor, not an estimate. +- **No audio.** `src/audio.c` is not benchmarked at all; the mixer under the + dummy driver does not do the work a real device would. +- **No controller input.** The event path is driven by SDL and has no gamepad to + drive it under the dummy driver. +- **No memory profiling beyond the static footprint.** The leaks above were found + by counting pool slots, not by valgrind or ASan. A run under either would + likely find more. +- **Single-threaded throughout.** The library is not thread-safe by design + (`akgl_game_state_lock` guards one field), and nothing here tests contention. diff --git a/README.md b/README.md index cee0fa9..e81221e 100644 --- a/README.md +++ b/README.md @@ -374,3 +374,17 @@ scripts/mutation_test.py --threshold 40 --junit mutation-junit.xml ``` The default run covers all libakgl-owned files under `src/`. Use repeated `--target` options to narrow the scope. A surviving mutant identifies behavior that the current tests do not verify; the script prints its file, line, operator, and exact edit. The real working tree is never mutated. + +## Performance testing + +`tests/perf.c` and `tests/perf_render.c` are benchmarks rather than unit tests: they drive the hot paths — pool acquire and release, the registry, the physics sweep, the per-actor update and render, the tilemap draw, text, and asset loading — and print what each one costs per operation. + +```sh +ctest --test-dir build -L perf --output-on-failure # benchmarks only, about 30 seconds +ctest --test-dir build -LE perf # everything except the benchmarks +AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf # a tenth of the iterations +``` + +Each measurement is the best of five runs and is held to a budget set at roughly ten times the recorded baseline, so a suite that turns red means an algorithmic regression rather than a busy machine. Budgets are enforced only in an optimized build at full scale; below `AKGL_BENCH_SCALE=1.0`, and in a coverage build, they are reported without failing. + +`PERFORMANCE.md` carries the recorded baseline, the frame budget it adds up to, and what the numbers say — including the raw-SDL control rows that separate what libakgl costs from what the rasterizer costs, and the six defects the stress tests turned up. diff --git a/TODO.md b/TODO.md index c890a9d..cc55c7c 100644 --- a/TODO.md +++ b/TODO.md @@ -918,6 +918,189 @@ without coming here first. Ordered by blast radius. the case written and deliberately not asserted; it would become a `TEST_EXPECT_OK`. Until then the header carries the wart as a `@note` pointing here. +## Performance + +The first measured baseline is in `PERFORMANCE.md`, produced by `tests/perf.c` +and `tests/perf_render.c` (`ctest --test-dir build -L perf`). Both suites hold +every measurement to a budget set at roughly ten times the recorded baseline, so +an algorithmic regression fails the suite rather than being discovered by a +player. Read `PERFORMANCE.md` before arguing with anything below — every claim +here has a number behind it, and several of the things I expected to be slow are +not. + +### Defects the perf suites found + +Ordered by blast radius. Numbering continues the **Defects** list above. + +28. **`akgl_path_relative` leaked an error context on every root-fallback + resolution.** `src/util.c:118` took its `ENOENT` branch by `return`ing from + inside the `HANDLE` block, which skips the `RELEASE_ERROR` that `FINISH` + ends with. One entry of `AKERR_ARRAY_ERROR` was lost per call, and the 129th + call hit "Unable to pull an error context from the array!" and **exited the + process**. Every tilemap load resolves several paths this way, so a game + that loaded fifty levels died in the loader. + + **Fixed.** The branch now records a flag and calls + `akgl_path_relative_root` after `FINISH`. `tests/util.c` carries + `test_akgl_path_relative_releases_contexts`, which resolves + `AKERR_MAX_ARRAY_ERROR * 2` paths through that branch and asserts the pool + is where it started; against the old code that test does not fail, it + terminates the suite. + + This is the *only* `return` from inside a `HANDLE` block in `src/` — the + other candidates use `SUCCEED_RETURN`, which releases correctly. Worth a + grep before anyone writes a new one, and worth a line in AGENTS.md's + error-handling protocol, which warns about `*_RETURN` inside `ATTEMPT` but + not about returning out of `HANDLE`. + +29. **`akgl_tilemap_load` leaks five pooled strings per load.** Measured by + counting non-zero `HEAP_STRING` refcounts across load/release cycles: five + per cycle, exactly, and `akgl_tilemap_release` gives none of them back. The + 52nd map load in a process finds the pool empty. + + Blast radius: every level transition. A game with fifty levels, or one that + reloads a level on death, hits it. The fix is a sweep of the loader's + `CLEANUP` blocks — `akgl_tilemap_load`, `akgl_tilemap_load_layers`, + `akgl_tilemap_load_layer_tile`, `akgl_tilemap_load_layer_objects`, and + `akgl_tilemap_load_tilesets_each` all claim scratch strings — and it wants a + test that asserts the pool is unchanged across a load/release cycle. That is + its own commit, not a footnote to a benchmark. + + Until it is fixed, the tilemap-load benchmark in `tests/perf_render.c` + reclaims the pool by hand between iterations, and says so. + +30. **Two JSON accessors turn string-pool exhaustion into a segfault.** + `akgl_get_json_string_value` ends its `ATTEMPT` with + `FINISH(errctx, false)` at `src/json_helpers.c:89`, so a failed + `akgl_heap_next_string` is swallowed rather than passed up, and + `src/json_helpers.c:91` then `strncpy`s `AKGL_MAX_STRING_LENGTH` bytes + through the pointer it never set. `akgl_get_json_array_index_string` has the + same pair at `src/json_helpers.c:143` and `src/json_helpers.c:145`. Item 29 + is what makes this reachable, and this is what item 29 looks like from the + outside: not `AKGL_ERR_HEAP`, a crash inside `strncpy` with a NULL + destination. + + Fix: `FINISH(errctx, true)` in both. One word each, but it changes what + callers see on a path they currently cannot survive, so it wants a test that + exhausts the pool deliberately and asserts `AKGL_ERR_HEAP` comes back out of + both functions. + +31. **`akgl_game_update` segfaults if `akgl_game_init` did not run.** + `src/game.c:182` calls `game.lowfpsfunc()` through the pointer with no NULL + check, on every frame where `game.fps` is under 30 — which includes the + first frame, before there is a frame rate to compare against. `akgl_game_init` + installs the default; nothing else does. + + That matters because `include/akgl/renderer.h` documents the other path on + purpose: a host that owns its own window and calls `akgl_render_bind2d` + instead of `akgl_render_init2d`. An embedder following that documentation + and then calling `akgl_game_update` crashes on frame one. `akbasic` is + exactly that embedder. + + Fix: guard the call, or have `akgl_game_updateFPS` install the default when + it finds a NULL. `tests/perf_render.c` installs it by hand as a workaround + and points here. + +32. **`akgl_game_update` runs the actor update sweep once per tilemap layer.** + `src/game.c:617` loops `i` over `AKGL_TILEMAP_MAX_LAYERS` and the actor + sweep nested inside it never compares `actor->layer` to `i`, so every live + actor's `updatefunc` runs **16 times per frame**. Measured: 68.4 ns per + update, 64 actors, so 70 µs of work to do 4.4 µs of work. + + It hides behind the rasterizer today (a 640x480 software frame is 16 ms) and + it will not hide behind a GPU backend, where a frame is nearer 2 ms and this + is 3.5% of it. Fix: either filter by layer like `akgl_render_2d_draw_world` + does, or hoist the update sweep out of the layer loop entirely — updating an + actor is not a per-layer operation. The second is almost certainly right, + and it is the one that needs a test asserting `updatefunc` runs exactly once + per actor per `akgl_game_update`. + +33. **`akgl_heap_release_character` leaks its `state_sprites` property set.** + Already documented in `heap.h` and in **Carried over** item 1; the perf + suite makes it measurable rather than theoretical. A benchmark that loads + the fixture character 10,000 times leaks 10,000 SDL property sets and 20,000 + sprite references. Cross-referenced here only because the character-load + benchmark had to be written around it. + +### Targets + +What a library like this *should* hit. These are not predictions of what the +current code does — several are missed today, and each says which. The frame +budget throughout is 16.67 ms (60 fps); where a target is stated per-operation +it is because that is the number that survives a change of renderer. + +The one that governs the rest: **libakgl's own bookkeeping should never be the +reason a frame is late.** Everything the library decides — pool scans, registry +lookups, state-to-sprite mapping, physics, visibility, error contexts — should +fit in 5% of a frame, leaving 95% for the pixels and the game's own logic. At +64 actors and a screenful of tiles that is a ceiling of about 800 µs. + +| # | Target | Today | Verdict | +|---|---|---|---| +| 1 | Library bookkeeping under 5% of a 60 fps frame at 64 actors + 1200 tiles | ~0.3% (excluding pixel work) | **met** | +| 2 | One actor's logic update under 200 ns | 68.4 ns | **met** | +| 3 | One actor's render bookkeeping, excluding the blit, under 500 ns | ~250 ns, by subtraction rather than direct measurement | **met, weakly measured** | +| 4 | Physics sweep under 25 ns per *live* actor, and proportional to live actors rather than pool size | 19 ns per live actor, but 63.9 ns for an *empty* pool | **partly** | +| 5 | Tilemap draw bookkeeping under 100 ns per tile, excluding the blit | ~25 ns | **met** | +| 6 | Pool acquire under 100 ns regardless of how full the pool is | 3.9 ns empty, 250.9 ns on the last free string slot | **missed** | +| 7 | Pool release proportional to the bytes actually used, not to the slot's capacity | 47.2 ns, a fixed 4 KiB wipe | **missed** | +| 8 | Re-drawing an unchanged line of text under 1 µs | 12.6 µs, every frame, no cache | **missed** | +| 9 | Zero texture creation or destruction per frame in steady state | one create + one destroy per line of text per frame | **missed** | +| 10 | A handled, routine condition costs no more than twice the path that succeeds | 616.5 ns vs 68.4 ns — 9x | **missed** | +| 11 | 256 actors simulated, updated and made ready to draw in under 1 ms | ~22 µs at 64 actors (5.8 µs measured logic + estimated render bookkeeping); linear, so ~90 µs extrapolated | **met, untested at that size** | +| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | no broad phase exists; the naive loop is 1.9 ms at 256 actors | **missed** | +| 13 | Level load under 100 ms for a map with 8 tilesets, 4 layers and 64 actors | 11.9 ms for a 2x2 map with one tileset | **unknown at that size** | +| 14 | Fixed per-load overhead under 1% of a level load | 11.5% — zeroing 26 MB of `akgl_Tilemap` | **missed** | +| 15 | Static footprint under 4 MB in the default configuration | 28 MB, 94% of it one tilemap | **missed** | +| 16 | No pool leaks across a load/release cycle of any asset type | tilemap leaks 5 strings per cycle (item 29) | **missed** | +| 17 | Pool exhaustion reports `AKGL_ERR_HEAP` and never crashes | item 30 crashes | **missed** | +| 18 | Every benchmark held to 10x its recorded baseline, enforced in CI | done, `ctest -L perf` | **met** | + +Notes on the ones worth arguing about: + +- **6 and 7 are the same fix.** The acquire scan is 64x slower on a full string + pool than an empty one purely because the pool is a megabyte and the scan + touches one refcount per 4 KiB. A free-list index — one `int` per layer, + remembering where the last free slot was — takes both to constant time without + changing the "no `malloc`" rule at all. That is the change I would make first, + and it is worth doing *before* anyone raises `AKGL_MAX_HEAP_*`, because the + cost of the current design grows with the ceiling rather than with the usage. + +- **8 and 9 are one cache.** A single entry keyed on (font, string, colour, + wrap) would cover the common case — a HUD field that changes once a second — + and a four- or eight-entry ring would cover the rest. This is the clearest + optimisation in the library and it is maybe forty lines. + +- **10 is a design target, not a speed target.** The answer is not a faster + error context. It is that "this character has no sprite for this state" is a + question with a boolean answer, and reporting it through `AKERR_KEY` costs + nine times the update it replaces. `akgl_character_sprite_get` wants a + companion that returns `NULL` without raising. + +- **12 is a scope decision, not a defect.** The library deliberately does not + own a broad phase, and at 64 actors the naive all-pairs loop is 0.7% of a + frame — genuinely fine. At 256 it is 11%. Either the ceiling stays where it is + and this target is dropped, or a uniform grid keyed on tile size goes in. I do + not think a spatial index belongs here yet; I think the target belongs on + record so that raising `AKGL_MAX_HEAP_ACTOR` is a decision made with the + number in front of it. + +- **14 and 15 are the same fix too.** `akgl_Tilemap` is 26 MB because every + layer carries a 512x512 `int` grid and every tileset a 65,536-entry offset + table, sized for the worst case at compile time. The pool rule does not + require *this*: a layer could carry an index into one shared cell arena sized + by `AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT` once rather than + sixteen times, and the offset table could be sized by `tilecount` rather than + by the maximum. That is a real refactor with a real ABI break, so it belongs + to 0.4 rather than to a patch release — but 28 MB of BSS on a handheld or an + ESP32-class target is the difference between fitting and not. + +- **13 is untested and should not stay that way.** The only map fixture in the + tree is 2x2 with one tileset, which is why the load benchmark measures a PNG + decode and a `memset` rather than a map. A realistic fixture — 128x128, four + layers, several tilesets — would make target 13 measurable and would probably + find something. + ## Build notes The vendored SDL satellite libraries are built into per-project subdirectories diff --git a/tests/benchutil.h b/tests/benchutil.h new file mode 100644 index 0000000..defbc04 --- /dev/null +++ b/tests/benchutil.h @@ -0,0 +1,285 @@ +/** + * @file benchutil.h + * @brief The timing harness behind the perf suites: run it, record it, report it, hold it to a budget. + * + * A benchmark here is a timed region: bench_start() takes a timestamp, + * bench_stop() takes another and divides by the number of units of work that + * happened in between. The unit is whatever the measurement is *about* -- one + * call, one actor, one tile, one frame -- and it is printed alongside the + * number so nobody has to guess what "42 ns" was 42 nanoseconds of. + * + * Two things make the numbers usable rather than merely present: + * + * 1. **Best of #AKGL_BENCH_REPETITIONS.** Calling bench_start() again with a + * name already in the table folds the new run into the old entry and keeps + * the *lowest* ns/op seen. A benchmark loop therefore repeats itself and the + * harness reports the run that was interrupted least. The mean is the wrong + * statistic here: every source of noise on a shared machine makes a run + * slower and none makes it faster. + * 2. **The timed region contains no error checking.** `PASS` and `CATCH` both + * call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` looking + * for a match -- more work than several of the calls being measured. Use + * #BENCH_LOOP, which stashes the context and stops at the first failure, and + * hand it to `PASS` after the clock has stopped. + * + * A budget is a per-op ceiling in nanoseconds, and a benchmark that exceeds it + * fails the suite. Budgets are set at roughly ten times the measured baseline: + * loose enough that a busy machine does not turn CI red, tight enough that an + * algorithmic regression -- a linear scan that becomes quadratic, a per-frame + * allocation that becomes a per-actor one -- cannot hide. They are enforced + * only in an optimized build at full scale; a coverage build measures the + * instrumentation, not the library, and reports without judging. + * + * Set `AKGL_BENCH_SCALE` in the environment to change how long the suite runs: + * 0.1 for a tenth of the iterations, 10 for ten times as many. Below 1.0 the + * budgets are not enforced, because a short run is a noisy one. + */ + +#ifndef _AKGL_BENCHUTIL_H_ +#define _AKGL_BENCHUTIL_H_ + +#include +#include +#include +#include +#include + +/** @brief How many benchmarks one suite can record. */ +#define AKGL_BENCH_MAX_RESULTS 48 +/** @brief Longest benchmark name kept, including the terminator. */ +#define AKGL_BENCH_MAX_NAME 56 +/** @brief Longest unit name kept, including the terminator. */ +#define AKGL_BENCH_MAX_UNIT 12 +/** @brief How many times a benchmark is repeated before the best run is reported. */ +#define AKGL_BENCH_REPETITIONS 5 +/** @brief Environment variable scaling every iteration count. */ +#define AKGL_BENCH_SCALE_ENV "AKGL_BENCH_SCALE" + +/** @brief One row of the report: what was measured, how fast, and what it was allowed to cost. */ +typedef struct akgl_Benchmark { + char name[AKGL_BENCH_MAX_NAME]; /**< What was measured. Also the key runs are merged under. */ + char unit[AKGL_BENCH_MAX_UNIT]; /**< What one op is: "call", "actor", "tile", "frame". */ + double budget_ns; /**< Per-op ceiling in nanoseconds. 0 reports without judging. */ + double best_ns; /**< Lowest ns/op seen across every run under this name. */ + uint64_t ops; /**< Units of work in the run that produced best_ns. */ + uint64_t elapsed_ns; /**< Wall time of that run. */ + int runs; /**< How many runs were folded in. */ +} akgl_Benchmark; + +/** @brief Every benchmark this suite has recorded, in the order it first ran. */ +static akgl_Benchmark bench_results[AKGL_BENCH_MAX_RESULTS]; +/** @brief How many entries of #bench_results are in use. */ +static int bench_result_count = 0; +/** @brief The entry bench_stop() will write to, claimed by bench_start(). */ +static akgl_Benchmark *bench_running = NULL; +/** @brief `SDL_GetTicksNS()` at the last bench_start(). */ +static uint64_t bench_started_ns = 0; + +/** + * @brief Multiplier applied to every iteration count, from `AKGL_BENCH_SCALE`. + * + * Read once and cached. Anything unparseable, negative, or absent gives 1.0. + */ +static double bench_scale(void) +{ + static double scale = -1.0; + const char *env = NULL; + + if ( scale >= 0.0 ) { + return scale; + } + scale = 1.0; + env = SDL_getenv(AKGL_BENCH_SCALE_ENV); + if ( env != NULL ) { + scale = SDL_atof(env); + if ( scale <= 0.0 ) { + scale = 1.0; + } + } + return scale; +} + +/** + * @brief Scale a nominal iteration count, never down to zero. + * @param count The count the benchmark was written for. + * @return @p count times the scale factor, at least 1. + */ +static int bench_iterations(int count) +{ + int scaled = (int)((double)count * bench_scale()); + + if ( scaled < 1 ) { + scaled = 1; + } + return scaled; +} + +/** + * @brief Report whether budgets are being enforced in this run. + * + * An unoptimized build measures the instrumentation rather than the library, + * and a scaled-down run is too short to trust, so both report without failing. + */ +static bool bench_budgets_enforced(void) +{ +#ifdef __OPTIMIZE__ + return ( bench_scale() >= 1.0 ); +#else + return false; +#endif +} + +/** + * @brief Start timing, claiming or reusing the table entry named @p name. + * + * @param name What is being measured. Truncated at #AKGL_BENCH_MAX_NAME. + * Reusing a name folds this run into that entry. + * @param unit What one op is. Truncated at #AKGL_BENCH_MAX_UNIT. + * @param budget_ns Per-op ceiling in nanoseconds; 0 to report only. The value + * from the first run under a name wins. + * + * @note The table is fixed. Past #AKGL_BENCH_MAX_RESULTS entries the run is + * dropped with a message rather than overwriting somebody else's row. + */ +static void bench_start(char *name, char *unit, double budget_ns) +{ + int i = 0; + + bench_running = NULL; + for ( i = 0; i < bench_result_count; i++ ) { + if ( strncmp(bench_results[i].name, name, AKGL_BENCH_MAX_NAME - 1) == 0 ) { + bench_running = &bench_results[i]; + break; + } + } + if ( bench_running == NULL ) { + if ( bench_result_count >= AKGL_BENCH_MAX_RESULTS ) { + SDL_Log("benchutil: no room for benchmark '%s', raise AKGL_BENCH_MAX_RESULTS", name); + return; + } + bench_running = &bench_results[bench_result_count]; + bench_result_count += 1; + memset(bench_running, 0x00, sizeof(akgl_Benchmark)); + strncpy(bench_running->name, name, AKGL_BENCH_MAX_NAME - 1); + strncpy(bench_running->unit, unit, AKGL_BENCH_MAX_UNIT - 1); + bench_running->budget_ns = budget_ns; + bench_running->best_ns = -1.0; + } + bench_started_ns = SDL_GetTicksNS(); +} + +/** + * @brief Stop timing and keep the run if it beat every previous one. + * @param ops Units of work done since bench_start(). Zero is ignored -- a + * benchmark that did nothing has no rate to report. + */ +static void bench_stop(uint64_t ops) +{ + uint64_t elapsed = SDL_GetTicksNS() - bench_started_ns; + double per_op = 0.0; + + if ( bench_running == NULL || ops == 0 ) { + return; + } + per_op = (double)elapsed / (double)ops; + bench_running->runs += 1; + if ( bench_running->best_ns < 0.0 || per_op < bench_running->best_ns ) { + bench_running->best_ns = per_op; + bench_running->ops = ops; + bench_running->elapsed_ns = elapsed; + } + bench_running = NULL; +} + +/** + * @brief Run @p stmt @p count times, stopping at the first error. + * + * The timed loop deliberately holds no `PASS` or `CATCH`: their validity check + * walks the whole error array and costs more than some of the calls being + * measured. Hand @p errvar to `PASS` once the clock has stopped. + * + * @param errvar Receives the first non-`NULL` context returned, or `NULL`. + * @param i Loop variable, declared by the caller. + * @param count How many times to run @p stmt. + * @param stmt The call under test. Must evaluate to an `akerr_ErrorContext *`. + */ +#define BENCH_LOOP(errvar, i, count, stmt) \ + for ( i = 0; i < (count); i++ ) { \ + errvar = (stmt); \ + if ( errvar != NULL ) { \ + break; \ + } \ + } + +/** + * @brief Print the recorded benchmarks as a table and count the ones over budget. + * + * The table goes to stdout so `ctest --output-on-failure` and a plain run of the + * executable both show it. Rates are printed as ops per second, which is the + * number a frame budget is actually built from. + * + * @return How many benchmarks exceeded their budget. Always 0 when + * bench_budgets_enforced() is false. + */ +static int bench_report(void) +{ + int i = 0; + int over = 0; + bool enforced = bench_budgets_enforced(); + char *verdict = NULL; + + printf("\n"); + printf("scale %.2fx, best of %d runs, budgets %s\n", + bench_scale(), + AKGL_BENCH_REPETITIONS, + enforced ? "enforced" : "reported only"); + printf("%-54s %-6s %9s %12s %16s %10s %s\n", + "benchmark", "unit", "ops", "ns/op", "ops/sec", "budget", "verdict"); + printf("%-54s %-6s %9s %12s %16s %10s %s\n", + "------------------------------------------------------", + "------", "---------", "------------", "----------------", "----------", "-------"); + for ( i = 0; i < bench_result_count; i++ ) { + verdict = "-"; + if ( bench_results[i].budget_ns > 0.0 ) { + if ( bench_results[i].best_ns > bench_results[i].budget_ns ) { + verdict = enforced ? "OVER" : "over"; + if ( enforced ) { + over += 1; + } + } else { + verdict = "ok"; + } + } + printf("%-54s %-6s %9llu %12.1f %16.0f %10.0f %s\n", + bench_results[i].name, + bench_results[i].unit, + (unsigned long long)bench_results[i].ops, + bench_results[i].best_ns, + ( bench_results[i].best_ns > 0.0 ) ? (1000000000.0 / bench_results[i].best_ns) : 0.0, + bench_results[i].budget_ns, + verdict); + } + printf("\n"); + fflush(stdout); + return over; +} + +/** + * @brief Fail the enclosing ATTEMPT block if any benchmark blew its budget. + * + * The report is printed either way -- a run that fails still has to say what the + * numbers were. + */ +#define BENCH_REPORT_BREAK(e) \ + { \ + int __bench_over = bench_report(); \ + if ( __bench_over > 0 ) { \ + FAIL_BREAK( \ + e, \ + AKGL_ERR_BEHAVIOR, \ + "%d benchmark(s) exceeded their budget", \ + __bench_over); \ + } \ + } + +#endif // _AKGL_BENCHUTIL_H_ diff --git a/tests/perf.c b/tests/perf.c new file mode 100644 index 0000000..c93633e --- /dev/null +++ b/tests/perf.c @@ -0,0 +1,928 @@ +/** + * @file perf.c + * @brief Stress and timing benchmarks for every subsystem that does not need a renderer. + * + * These are not correctness tests. Each one drives a hot path -- the pool scans, + * the registry, the physics sweep, the per-actor update, the geometry helpers, + * the JSON accessors -- hard enough that its per-operation cost is measurable, + * and reports what that cost is. The companion suite in `tests/perf_render.c` + * covers the paths that need a renderer. + * + * The pools are the reason this matters. Every acquire is a linear scan of a + * fixed array looking for a zero reference count, so cost grows with how full + * the pool is, not with how much is being asked for. Several benchmarks below + * are therefore run twice: once against an empty pool, which is the number a + * casual reading of the code predicts, and once against a nearly full one, + * which is the number a game in its fifth minute actually gets. + * + * Everything here runs headless under the dummy drivers, and every benchmark + * rebuilds the state it needs rather than inheriting whatever the previous one + * left behind. + * + * @note SDL's log output is redirected to a sink that discards it. The library + * logs on paths this suite hammers -- akgl_actor_initialize logs every + * spawn -- and timing a write to a terminal or a CTest capture file + * measures the machine's I/O, not libakgl. What the *formatting* costs is + * measured deliberately, by the pair of actor-spawn benchmarks. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "benchutil.h" +#include "testutil.h" + +/** @brief How many actors the frame-shaped benchmarks put on the heap. */ +#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR +/** @brief The JSON document the accessor benchmarks read. */ +#define BENCH_JSON_FIXTURE "assets/snippets/test_json_helpers.json" +/** @brief A path that exists relative to the CTest working directory, for akgl_path_relative. */ +#define BENCH_PATH_FIXTURE "assets/testcharacter.json" + +/** + * @brief Print the library's fixed memory footprint. + * + * Not a benchmark, but it belongs in the same report: libakgl does not call + * `malloc`, so every one of these arrays exists from process start whether the + * game uses one slot or all of them, and several of the timings above are + * explained entirely by their size. The string pool is a megabyte of `char` + * arrays, and an akgl_Tilemap is dominated by 16 layers of 512x512 `int` cells + * and 16 tilesets of 65536 offset pairs -- which is why zeroing one costs what + * it costs, and why the header warns against putting one on the stack. + */ +static void bench_report_footprint(void) +{ + size_t total = 0; + + total = sizeof(HEAP_ACTOR) + sizeof(HEAP_SPRITE) + sizeof(HEAP_SPRITESHEET) + + sizeof(HEAP_CHARACTER) + sizeof(HEAP_STRING); + + printf("\n"); + printf("static footprint, fixed at compile time:\n"); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + "HEAP_ACTOR", AKGL_MAX_HEAP_ACTOR, sizeof(akgl_Actor), sizeof(HEAP_ACTOR)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + "HEAP_SPRITE", AKGL_MAX_HEAP_SPRITE, sizeof(akgl_Sprite), sizeof(HEAP_SPRITE)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + "HEAP_SPRITESHEET", AKGL_MAX_HEAP_SPRITESHEET, sizeof(akgl_SpriteSheet), sizeof(HEAP_SPRITESHEET)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + "HEAP_CHARACTER", AKGL_MAX_HEAP_CHARACTER, sizeof(akgl_Character), sizeof(HEAP_CHARACTER)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + "HEAP_STRING", AKGL_MAX_HEAP_STRING, sizeof(akgl_String), sizeof(HEAP_STRING)); + printf(" %-24s %5s %7s %10zu bytes\n", "pools, total", "", "", total); + printf(" %-24s %5d x %7s = %10zu bytes\n", + "akgl_Tilemap", 1, "", sizeof(akgl_Tilemap)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + " of which layers", AKGL_TILEMAP_MAX_LAYERS, sizeof(akgl_TilemapLayer), + (sizeof(akgl_TilemapLayer) * AKGL_TILEMAP_MAX_LAYERS)); + printf(" %-24s %5d x %7zu = %10zu bytes\n", + " of which tilesets", AKGL_TILEMAP_MAX_TILESETS, sizeof(akgl_Tileset), + (sizeof(akgl_Tileset) * AKGL_TILEMAP_MAX_TILESETS)); + fflush(stdout); +} + +/** + * @brief Swallow SDL's log output. + * + * Installed for the whole run. See the note in the file comment: the library + * logs on paths this suite calls hundreds of thousands of times, and the cost of + * writing that out is not a property of libakgl. + */ +static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message) +{ + return; +} + +/** + * @brief Claim a spritesheet slot without loading an image into it. + * + * akgl_spritesheet_initialize uploads a texture and so needs a renderer, which + * this suite deliberately does not have. Nothing here draws, and the only thing + * that reads the sheet is akgl_sprite_initialize storing the pointer, so an + * empty claimed slot is enough. + */ +static akerr_ErrorContext *bench_make_sheet(akgl_SpriteSheet **dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + PASS(errctx, akgl_heap_next_spritesheet(dest)); + memset(*dest, 0x00, sizeof(akgl_SpriteSheet)); + (*dest)->refcount += 1; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Build a sprite on a freshly claimed sheet and publish it in the sprite registry. + * + * @param dest Receives the sprite. Required. + * @param name Registry key. Copied at a fixed #AKGL_SPRITE_MAX_NAME_LENGTH + * bytes by akgl_sprite_initialize, so it is staged through a buffer + * of exactly that size first. + * @param speed Nanoseconds one frame is held. 0 makes every akgl_actor_update + * advance the animation, which is the expensive path. + */ +static akerr_ErrorContext *bench_make_sprite(akgl_Sprite **dest, char *name, uint32_t speed) +{ + PREPARE_ERROR(errctx); + akgl_SpriteSheet *sheet = NULL; + char namebuf[AKGL_SPRITE_MAX_NAME_LENGTH]; + + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); + + memset(&namebuf, 0x00, sizeof(namebuf)); + strncpy((char *)&namebuf, name, AKGL_SPRITE_MAX_NAME_LENGTH - 1); + + PASS(errctx, bench_make_sheet(&sheet)); + PASS(errctx, akgl_heap_next_sprite(dest)); + PASS(errctx, akgl_sprite_initialize(*dest, (char *)&namebuf, sheet)); + (*dest)->frames = 3; + (*dest)->frameids[0] = 0; + (*dest)->frameids[1] = 1; + (*dest)->frameids[2] = 2; + (*dest)->width = 48; + (*dest)->height = 48; + (*dest)->speed = speed; + (*dest)->loop = true; + SUCCEED_RETURN(errctx); +} + +/** + * @brief Build a character with one sprite mapped to the two states these benchmarks use. + * + * State 0 is what akgl_actor_initialize leaves an actor in and what + * akgl_actor_automatic_face leaves it in while it is standing still; + * `MOVING_RIGHT | FACE_RIGHT` is what bench_fill_actor_pool puts it in and what + * the face logic settles on from there. Mapping both keeps the update path off + * the missing-sprite branch, which is measured deliberately elsewhere rather + * than by accident here. + */ +static akerr_ErrorContext *bench_make_character(akgl_Character **dest, char *name, uint32_t spritespeed) +{ + PREPARE_ERROR(errctx); + akgl_Sprite *sprite = NULL; + + FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest"); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name"); + + PASS(errctx, akgl_heap_next_character(dest)); + PASS(errctx, akgl_character_initialize(*dest, name)); + (*dest)->sx = 120.0; + (*dest)->sy = 120.0; + (*dest)->sz = 0.0; + (*dest)->ax = 40.0; + (*dest)->ay = 40.0; + (*dest)->speedtime = 16; + + PASS(errctx, bench_make_sprite(&sprite, name, spritespeed)); + PASS(errctx, (*dest)->sprite_add(*dest, sprite, 0)); + PASS(errctx, + (*dest)->sprite_add( + *dest, + sprite, + (AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_FACE_RIGHT)) + ); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Populate the actor pool with @p count live actors bound to @p basechar. + * + * The heap and the actor registry are rebuilt first, so this is the state every + * frame-shaped benchmark starts from rather than something it inherits. + */ +static akerr_ErrorContext *bench_fill_actor_pool(akgl_Character *basechar, int count) +{ + PREPARE_ERROR(errctx); + akgl_Actor *actor = NULL; + char name[AKGL_ACTOR_MAX_NAME_LENGTH]; + int i = 0; + + FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar"); + + PASS(errctx, akgl_heap_init_actor()); + PASS(errctx, akgl_registry_init_actor()); + + for ( i = 0; i < count; i++ ) { + memset(&name, 0x00, sizeof(name)); + snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i); + PASS(errctx, akgl_heap_next_actor(&actor)); + PASS(errctx, akgl_actor_initialize(actor, (char *)&name)); + PASS(errctx, akgl_actor_set_character(actor, basechar->name)); + actor->visible = true; + actor->layer = 0; + actor->x = (float32_t)(i * 13); + actor->y = (float32_t)(i * 7); + actor->sx = 120.0; + actor->sy = 120.0; + actor->ax = 40.0; + actor->ay = 40.0; + AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time a claim from an empty actor pool against one from a nearly full pool. + * + * The pool scan stops at the first slot with a zero reference count, so an empty + * pool answers from index 0 and a pool with one slot left answers from index + * #AKGL_MAX_HEAP_ACTOR - 1. The ratio between these two numbers *is* the cost of + * the linear-scan allocator. + */ +static akerr_ErrorContext *bench_heap_actor_claim(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Actor *actor = NULL; + int count = bench_iterations(500000); + int i = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + PASS(errctx, akgl_heap_init_actor()); + bench_start("heap_next_actor, empty pool", "call", 100.0); + BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor)); + bench_stop(count); + PASS(errctx, inner); + + for ( i = 0; i < (AKGL_MAX_HEAP_ACTOR - 1); i++ ) { + HEAP_ACTOR[i].refcount = 1; + } + bench_start("heap_next_actor, one slot left", "call", 400.0); + BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor)); + bench_stop(count); + PASS(errctx, inner); + PASS(errctx, akgl_heap_init_actor()); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the string pool, which is the one that hurts. + * + * Every entry is #AKGL_MAX_STRING_LENGTH bytes, so 256 of them is a megabyte and + * the scan for a free slot touches one reference count every 4 KiB -- a cache + * miss per candidate. Release then wipes the whole 4 KiB whether the string held + * a path or a single character. Three numbers come out of this: the claim on an + * empty pool, the claim with one slot left, and the release on its own. + */ +static akerr_ErrorContext *bench_heap_string(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_String *str = NULL; + int count = bench_iterations(100000); + int i = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + PASS(errctx, akgl_heap_init()); + + // Claim and release together: the scratch-buffer idiom the library uses + // everywhere, and the only string benchmark whose cost a caller can + // actually observe from outside. + bench_start("heap string claim + release cycle", "cycle", 500.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_heap_next_string(&str); + if ( inner != NULL ) { + break; + } + inner = akgl_heap_release_string(str); + if ( inner != NULL ) { + break; + } + } + bench_stop(count); + PASS(errctx, inner); + + // The claim on its own. The reference the claim takes is dropped by hand + // rather than through release, so the 4 KiB wipe stays out of this one. + bench_start("heap_next_string, empty pool", "call", 100.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_heap_next_string(&str); + if ( inner != NULL ) { + break; + } + str->refcount = 0; + } + bench_stop(count); + PASS(errctx, inner); + + // The same claim with 255 of the 256 slots taken: a megabyte walked, one + // reference count read per 4 KiB page. + for ( i = 0; i < (AKGL_MAX_HEAP_STRING - 1); i++ ) { + HEAP_STRING[i].refcount = 1; + } + bench_start("heap_next_string, one slot left", "call", 2500.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_heap_next_string(&str); + if ( inner != NULL ) { + break; + } + str->refcount = 0; + } + bench_stop(count); + PASS(errctx, inner); + PASS(errctx, akgl_heap_init()); + + // Release on its own: one 4 KiB memset per call, whatever the string held. + PASS(errctx, akgl_heap_next_string(&str)); + bench_start("heap_release_string, 4 KiB wipe", "call", 500.0); + for ( i = 0; i < count; i++ ) { + str->refcount = 1; + inner = akgl_heap_release_string(str); + if ( inner != NULL ) { + break; + } + } + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time a full reset of every pool. + * + * akgl_heap_init zeroes all five arrays -- better than a megabyte, dominated by + * the string pool. A game pays this once at startup and again on every call to + * akgl_game_init; a level transition that calls it per level pays it per level. + */ +static akerr_ErrorContext *bench_heap_init(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + int count = bench_iterations(2000); + int i = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("heap_init, all five pools", "call", 450000.0); + BENCH_LOOP(inner, i, count, akgl_heap_init()); + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time an actor spawn, with and without the log line the library writes. + * + * A spawn is a pool claim, a `memset` of the whole actor, a registry insert, and + * an `SDL_Log`. The second run raises the log priority so SDL returns before it + * formats anything; the gap between the two numbers is what the log line costs + * every caller, including the ones that never read it. + */ +static akerr_ErrorContext *bench_actor_spawn(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Actor *actor = NULL; + char name[AKGL_ACTOR_MAX_NAME_LENGTH]; + int count = bench_iterations(20000); + int i = 0; + int rep = 0; + + memset(&name, 0x00, sizeof(name)); + strncpy((char *)&name, "benchspawn", AKGL_ACTOR_MAX_NAME_LENGTH - 1); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + PASS(errctx, akgl_heap_init_actor()); + PASS(errctx, akgl_registry_init_actor()); + + bench_start("actor spawn + release, library logging on", "actor", 2100.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_heap_next_actor(&actor); + if ( inner != NULL ) { + break; + } + inner = akgl_actor_initialize(actor, (char *)&name); + if ( inner != NULL ) { + break; + } + inner = akgl_heap_release_actor(actor); + if ( inner != NULL ) { + break; + } + } + bench_stop(count); + PASS(errctx, inner); + + SDL_SetLogPriorities(SDL_LOG_PRIORITY_CRITICAL); + bench_start("actor spawn + release, logging suppressed", "actor", 1700.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_heap_next_actor(&actor); + if ( inner != NULL ) { + break; + } + inner = akgl_actor_initialize(actor, (char *)&name); + if ( inner != NULL ) { + break; + } + inner = akgl_heap_release_actor(actor); + if ( inner != NULL ) { + break; + } + } + bench_stop(count); + SDL_SetLogPriorities(SDL_LOG_PRIORITY_INFO); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time a registry lookup with the actor registry full. + * + * akgl_actor_set_character is the library's own name-to-pointer lookup: an SDL + * property fetch against a registry holding #AKGL_MAX_HEAP_ACTOR entries, plus + * the four field copies that follow it. Every actor built from a tilemap object + * layer pays it once. + */ +static akerr_ErrorContext *bench_registry_lookup(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_Actor *actor = NULL; + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, bench_make_character(&basechar, "benchlookupchar", 0)); + PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT)); + actor = &HEAP_ACTOR[0]; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("actor_set_character, registry lookup", "call", 400.0); + BENCH_LOOP(inner, i, count, akgl_actor_set_character(actor, basechar->name)); + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the property store a game reads its configuration out of. + * + * akgl_get_property copies a fixed #AKGL_MAX_STRING_LENGTH bytes out of the + * property store regardless of how long the value is, so reading `"0.0"` moves + * 4 KiB. akgl_physics_init_arcade makes six of these calls. + */ +static akerr_ErrorContext *bench_properties(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_String *value = NULL; + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_registry_init_properties()); + PASS(errctx, akgl_set_property("bench.property", "a short value")); + PASS(errctx, akgl_heap_next_string(&value)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("set_property", "call", 1000.0); + BENCH_LOOP(inner, i, count, akgl_set_property("bench.property", "a short value")); + bench_stop(count); + PASS(errctx, inner); + + bench_start("get_property, 4 KiB copy", "call", 900.0); + BENCH_LOOP(inner, i, count, akgl_get_property("bench.property", &value, "unset")); + bench_stop(count); + PASS(errctx, inner); + } + PASS(errctx, akgl_heap_release_string(value)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the state-to-sprite lookup that runs twice per actor per frame. + * + * akgl_character_sprite_get renders the state bitmask to decimal with `SDL_itoa` + * and looks the result up as a property string. akgl_actor_update calls it, and + * so does akgl_actor_render, so a 64-actor frame makes 128 of these calls before + * anything is drawn. + */ +static akerr_ErrorContext *bench_character_sprite_get(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_Sprite *sprite = NULL; + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, bench_make_character(&basechar, "benchspritechar", 0)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("character_sprite_get, state to sprite", "call", 400.0); + BENCH_LOOP(inner, i, count, basechar->sprite_get(basechar, 0, &sprite)); + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time one actor's logic update, with the animation advancing every call. + * + * The sprite's frame time is 0, so every update takes the branch that changes + * frame -- the expensive path, and the one a fast animation actually takes. An + * update is a face recalculation, a sprite lookup, a clock read, and the frame + * arithmetic. + */ +static akerr_ErrorContext *bench_actor_update(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_Actor *actor = NULL; + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, bench_make_character(&basechar, "benchupdatechar", 0)); + PASS(errctx, bench_fill_actor_pool(basechar, 1)); + actor = &HEAP_ACTOR[0]; + AKGL_BITMASK_CLEAR(actor->state); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("actor_update, animation advancing", "actor", 700.0); + BENCH_LOOP(inner, i, count, actor->updatefunc(actor)); + bench_stop(count); + PASS(errctx, inner); + + // An actor in a state its character has no sprite for is not an error + // the library refuses -- akgl_actor_update handles AKERR_KEY and carries + // on, and akgl_actor_render logs it and draws nothing. It is a normal + // condition on a partly authored character, and it is worth knowing what + // one costs, because raising, formatting, and handling a context is far + // more work than the update it replaces. + AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_UP); + bench_start("actor_update, no sprite for state", "actor", 6200.0); + BENCH_LOOP(inner, i, count, actor->updatefunc(actor)); + bench_stop(count); + PASS(errctx, inner); + AKGL_BITMASK_CLEAR(actor->state); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the physics sweep over a full actor pool, and over an empty one. + * + * akgl_physics_simulate walks all #AKGL_MAX_HEAP_ACTOR slots whether or not + * anything is in them, so the empty-pool number is the floor every frame pays. + * The full-pool number is one frame of movement for 64 actors: movement logic, + * gravity, drag, clamp, and integrate, per actor. + */ +static akerr_ErrorContext *bench_physics_simulate(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_PhysicsBackend backend; + int count = bench_iterations(20000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, akgl_registry_init_properties()); + PASS(errctx, bench_make_character(&basechar, "benchphysicschar", 0)); + + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + PASS(errctx, akgl_physics_init_arcade(&backend)); + backend.gravity_y = 9.8; + backend.drag_x = 0.1; + backend.drag_y = 0.1; + + PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("physics_simulate, 64 live actors", "frame", 12500.0); + BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL)); + bench_stop(count); + PASS(errctx, inner); + } + + PASS(errctx, akgl_heap_init_actor()); + PASS(errctx, akgl_registry_init_actor()); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("physics_simulate, empty pool", "frame", 650.0); + BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL)); + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the logic half of a frame: update every actor, then simulate. + * + * This is what a host's main loop does before it draws anything, and it is the + * number to subtract from a 16.6 ms frame to find out what is left for drawing. + * Note that it is *not* what akgl_game_update does: that one runs the update + * loop once per tilemap layer, which is measured in the render suite. + */ +static akerr_ErrorContext *bench_logic_frame(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Character *basechar = NULL; + akgl_Actor *actor = NULL; + akgl_PhysicsBackend backend; + int count = bench_iterations(5000); + int i = 0; + int j = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, akgl_registry_init_properties()); + PASS(errctx, bench_make_character(&basechar, "benchframechar", 0)); + memset(&backend, 0x00, sizeof(akgl_PhysicsBackend)); + PASS(errctx, akgl_physics_init_arcade(&backend)); + PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("logic frame, 64 actors updated + simulated", "frame", 60000.0); + for ( i = 0; i < count; i++ ) { + for ( j = 0; j < AKGL_MAX_HEAP_ACTOR; j++ ) { + actor = &HEAP_ACTOR[j]; + if ( actor->refcount == 0 ) { + continue; + } + inner = actor->updatefunc(actor); + if ( inner != NULL ) { + break; + } + } + if ( inner != NULL ) { + break; + } + inner = backend.simulate(&backend, NULL); + if ( inner != NULL ) { + break; + } + } + bench_stop(count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the geometry helpers, including the all-pairs sweep a caller has to write. + * + * akgl_collide_rectangles tests eight corners and returns at the first hit, so + * an overlap is cheap and a miss is the full eight. The third benchmark is the + * broad phase the library does not provide: 64 actors is 2016 pairs, and a + * caller doing collision at all does that every frame. + */ +static akerr_ErrorContext *bench_geometry(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + RectanglePoints points; + SDL_FRect rects[BENCH_ACTOR_COUNT]; + SDL_FRect overlapping = { .x = 8.0, .y = 8.0, .w = 32.0, .h = 32.0 }; + SDL_FRect disjoint = { .x = 900.0, .y = 900.0, .w = 32.0, .h = 32.0 }; + SDL_FRect subject = { .x = 0.0, .y = 0.0, .w = 32.0, .h = 32.0 }; + bool collide = false; + int count = bench_iterations(500000); + int sweeps = bench_iterations(2000); + int pairs = 0; + int i = 0; + int j = 0; + int k = 0; + int rep = 0; + + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + rects[i].x = (float32_t)(i * 24); + rects[i].y = (float32_t)(i * 18); + rects[i].w = 32.0; + rects[i].h = 32.0; + } + pairs = (BENCH_ACTOR_COUNT * (BENCH_ACTOR_COUNT - 1)) / 2; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("rectangle_points", "call", 100.0); + BENCH_LOOP(inner, i, count, akgl_rectangle_points(&points, &subject)); + bench_stop(count); + PASS(errctx, inner); + + bench_start("collide_rectangles, overlapping", "call", 300.0); + BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &overlapping, &collide)); + bench_stop(count); + PASS(errctx, inner); + + bench_start("collide_rectangles, disjoint", "call", 600.0); + BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &disjoint, &collide)); + bench_stop(count); + PASS(errctx, inner); + + bench_start("all-pairs collision sweep, 64 actors", "sweep", 1200000.0); + for ( k = 0; k < sweeps; k++ ) { + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + for ( j = i + 1; j < BENCH_ACTOR_COUNT; j++ ) { + inner = akgl_collide_rectangles(&rects[i], &rects[j], &collide); + if ( inner != NULL ) { + break; + } + } + } + if ( inner != NULL ) { + break; + } + } + bench_stop(sweeps); + PASS(errctx, inner); + } + printf("all-pairs collision sweep covers %d pairs per sweep\n", pairs); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the pooled-string operations. + * + * Both of these move #AKGL_MAX_STRING_LENGTH bytes by default -- a copy with a + * count of 0 means "all of it" -- so the cost is the same for a filename and for + * a single character. + */ +static akerr_ErrorContext *bench_strings(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_String *src = NULL; + akgl_String *dst = NULL; + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_heap_next_string(&src)); + PASS(errctx, akgl_heap_next_string(&dst)); + PASS(errctx, akgl_string_initialize(src, "assets/testcharacter.json")); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("string_initialize", "call", 350.0); + BENCH_LOOP(inner, i, count, akgl_string_initialize(dst, "assets/testcharacter.json")); + bench_stop(count); + PASS(errctx, inner); + + bench_start("string_copy, full length", "call", 350.0); + BENCH_LOOP(inner, i, count, akgl_string_copy(src, dst, 0)); + bench_stop(count); + PASS(errctx, inner); + } + PASS(errctx, akgl_heap_release_string(src)); + PASS(errctx, akgl_heap_release_string(dst)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time parsing a document and reading values back out of it. + * + * The parse is what an asset load pays per file. The accessors are what the + * tilemap and character loaders pay per field, and every one of them that + * returns a string claims and fills a 4 KiB pooled string. + */ +static akerr_ErrorContext *bench_json(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_String *path = NULL; + akgl_String *value = NULL; + json_t *doc = NULL; + json_error_t jsonerr; + int number = 0; + int loads = bench_iterations(2000); + int count = bench_iterations(200000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_heap_next_string(&path)); + PASS(errctx, akgl_heap_next_string(&value)); + snprintf((char *)&path->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), BENCH_JSON_FIXTURE); + + doc = json_load_file((char *)&path->data, 0, &jsonerr); + FAIL_ZERO_RETURN(errctx, doc, AKERR_IO, "Unable to load %s: %s", (char *)&path->data, jsonerr.text); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("json_load_file, small document", "load", 120000.0); + for ( i = 0; i < loads; i++ ) { + json_t *scratch = json_load_file((char *)&path->data, 0, &jsonerr); + if ( scratch == NULL ) { + break; + } + json_decref(scratch); + } + bench_stop(loads); + + bench_start("get_json_string_value", "call", 450.0); + BENCH_LOOP(inner, i, count, akgl_get_json_string_value(doc, "name", &value)); + bench_stop(count); + PASS(errctx, inner); + + bench_start("get_json_integer_value", "call", 150.0); + BENCH_LOOP(inner, i, count, akgl_get_json_integer_value(doc, "count", &number)); + bench_stop(count); + PASS(errctx, inner); + } + json_decref(doc); + PASS(errctx, akgl_heap_release_string(path)); + PASS(errctx, akgl_heap_release_string(value)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time path resolution, which every asset reference goes through. + * + * akgl_path_relative calls `realpath(3)`, so this is a syscall and a walk of the + * filesystem, per reference, at load time. A tilemap naming twenty tilesets and + * sprites pays it twenty times. + */ +static akerr_ErrorContext *bench_path_relative(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_String *dst = NULL; + int count = bench_iterations(20000); + int i = 0; + int rep = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_heap_next_string(&dst)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("path_relative, realpath on an existing file", "call", 36000.0); + BENCH_LOOP(inner, i, count, akgl_path_relative(".", BENCH_PATH_FIXTURE, dst)); + bench_stop(count); + PASS(errctx, inner); + } + PASS(errctx, akgl_heap_release_string(dst)); + SUCCEED_RETURN(errctx); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetLogOutputFunction(bench_discard_log, NULL); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + CATCH(errctx, akgl_registry_init_properties()); + + CATCH(errctx, bench_heap_actor_claim()); + CATCH(errctx, bench_heap_string()); + CATCH(errctx, bench_heap_init()); + CATCH(errctx, bench_actor_spawn()); + CATCH(errctx, bench_registry_lookup()); + CATCH(errctx, bench_properties()); + CATCH(errctx, bench_character_sprite_get()); + CATCH(errctx, bench_actor_update()); + CATCH(errctx, bench_physics_simulate()); + CATCH(errctx, bench_logic_frame()); + CATCH(errctx, bench_geometry()); + CATCH(errctx, bench_strings()); + CATCH(errctx, bench_json()); + CATCH(errctx, bench_path_relative()); + + bench_report_footprint(); + BENCH_REPORT_BREAK(errctx); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +} diff --git a/tests/perf_render.c b/tests/perf_render.c new file mode 100644 index 0000000..67c0228 --- /dev/null +++ b/tests/perf_render.c @@ -0,0 +1,837 @@ +/** + * @file perf_render.c + * @brief Stress and timing benchmarks for the paths that need a renderer. + * + * The companion to `tests/perf.c`, which covers everything that does not draw. + * Everything here runs against a software renderer under the dummy video driver + * at #BENCH_SCREEN_W x #BENCH_SCREEN_H -- no display, no window shown, no GPU. + * + * A software renderer is the right instrument for this even though no shipped + * game uses one. What these benchmarks are measuring is the work libakgl does + * *per drawn thing* -- how many blits a screenful of tiles turns into, how many + * lookups an actor costs before its texture moves, what a line of HUD text + * allocates and throws away every frame. A software renderer keeps that work in + * the same process where it can be timed, and its per-blit cost is at least + * honest about how much pixel traffic was asked for. Read these numbers as + * *counts of work*, not as frame rates a real game would see. + * + * The tilemap benchmarks build their map by hand rather than loading the 2x2 + * fixture. A 2x2 map measures nothing: the point of the exercise is a full + * screen of tiles -- #BENCH_MAP_W x #BENCH_MAP_H cells at #BENCH_TILE_SIZE + * pixels, of which the camera covers 40 x 30 -- and the second variant repeats + * it with eight tilesets, because akgl_tilemap_draw scans every tileset for + * every tile and never stops at the one that matched. + */ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "benchutil.h" +#include "testutil.h" + +/** @brief Width of the offscreen render target every benchmark draws into. */ +#define BENCH_SCREEN_W 640 +/** @brief Height of that target. */ +#define BENCH_SCREEN_H 480 +/** @brief Edge of one cell in the synthetic map, in pixels. */ +#define BENCH_TILE_SIZE 16 +/** @brief Width of the synthetic map, in tiles. Bigger than the camera on purpose. */ +#define BENCH_MAP_W 128 +/** @brief Height of the synthetic map, in tiles. */ +#define BENCH_MAP_H 128 +/** @brief Tilesets in the many-tileset variant of the draw benchmark. */ +#define BENCH_MAP_TILESETS 8 +/** @brief Actors the scene-wide benchmarks put on the heap. */ +#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR +/** @brief The tileset image the synthetic map cuts its tiles from: 768x576, 48x36 tiles. */ +#define BENCH_TILESET_IMAGE "assets/World_A1.png" +/** @brief The font the text benchmarks rasterize with. */ +#define BENCH_FONT_PATH "assets/akgl_test_mono.ttf" +/** @brief Point size for that font. */ +#define BENCH_FONT_SIZE 16 +/** @brief A representative line of HUD text: a score readout. */ +#define BENCH_TEXT "SCORE 000123456" + +/** @brief The font opened once in main and used by the text benchmarks. */ +static TTF_Font *benchfont = NULL; +/** @brief The tileset texture the synthetic map borrows. Destroyed in main. */ +static SDL_Texture *benchtiles = NULL; + +/** + * @brief Stop the clock, but only after the renderer has done what it was asked. + * + * SDL batches. A `draw_*` call queues a command and returns, and the queue is + * not executed until something forces it: a present, a readback, or the + * destruction of a texture it refers to. Without a flush inside the timed + * region, every drawing benchmark here measures the cost of *queueing* work and + * none of the cost of doing it -- and the bill arrives at teardown, where + * SDL_DestroyTexture on the tileset spent nearly two minutes executing blits + * that six earlier benchmarks had queued and been given credit for not doing. + * + * So the flush goes inside the measurement: what these benchmarks report is N + * calls plus the drawing those N calls asked for, which is the number a caller + * actually pays over a frame. + */ +#define BENCH_FLUSH_STOP(e, ops) \ + { \ + bool __bench_flushed = SDL_FlushRenderer(renderer->sdl_renderer); \ + bench_stop(ops); \ + FAIL_ZERO_RETURN(e, __bench_flushed, AKGL_ERR_SDL, "SDL_FlushRenderer: %s", SDL_GetError()); \ + } + +/** + * @brief Swallow SDL's log output. See the same function in tests/perf.c. + */ +static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message) +{ + return; +} + +/** + * @brief Build a map by hand: one tile layer, @p tilesets tilesets, every cell filled. + * + * The cells all draw from the *last* tileset, which is the worst case for + * akgl_tilemap_draw's inner scan and the reason the many-tileset variant exists. + * Global ids are laid out so that tileset k owns [1 + k*tilecount, ...], which + * keeps each cell matching exactly one tileset -- the draw loop has no `break`, + * so overlapping ranges would blit the same cell more than once and measure + * something that cannot happen with a map Tiled produced. + * + * @param map The tilemap to fill in. Required. Zeroed first. + * @param tilesets How many tilesets to declare, all sharing one texture. + */ +static akerr_ErrorContext *bench_build_map(akgl_Tilemap *map, int tilesets) +{ + PREPARE_ERROR(errctx); + int columns = 0; + int rows = 0; + int tilecount = 0; + int lastgid = 0; + int i = 0; + int cell = 0; + + FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map"); + FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles"); + FAIL_NONZERO_RETURN( + errctx, + ((tilesets < 1) || (tilesets > AKGL_TILEMAP_MAX_TILESETS)), + AKERR_OUTOFBOUNDS, + "%d tilesets is outside 1..%d", + tilesets, + AKGL_TILEMAP_MAX_TILESETS); + + memset(map, 0x00, sizeof(akgl_Tilemap)); + columns = benchtiles->w / BENCH_TILE_SIZE; + rows = benchtiles->h / BENCH_TILE_SIZE; + tilecount = columns * rows; + + map->tilewidth = BENCH_TILE_SIZE; + map->tileheight = BENCH_TILE_SIZE; + map->width = BENCH_MAP_W; + map->height = BENCH_MAP_H; + map->numlayers = 1; + map->numtilesets = tilesets; + + for ( i = 0; i < tilesets; i++ ) { + map->tilesets[i].firstgid = 1 + (i * tilecount); + map->tilesets[i].tilecount = tilecount; + map->tilesets[i].columns = columns; + map->tilesets[i].tilewidth = BENCH_TILE_SIZE; + map->tilesets[i].tileheight = BENCH_TILE_SIZE; + map->tilesets[i].imagewidth = benchtiles->w; + map->tilesets[i].imageheight = benchtiles->h; + map->tilesets[i].texture = benchtiles; + snprintf((char *)&map->tilesets[i].name, AKGL_TILEMAP_MAX_TILESET_NAME_SIZE, "benchtileset%d", i); + PASS(errctx, akgl_tilemap_compute_tileset_offsets(map, i)); + } + + lastgid = map->tilesets[tilesets - 1].firstgid; + map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES; + map->layers[0].width = BENCH_MAP_W; + map->layers[0].height = BENCH_MAP_H; + map->layers[0].visible = true; + map->layers[0].opacity = 1.0; + for ( i = 0; i < (BENCH_MAP_W * BENCH_MAP_H); i++ ) { + cell = i % tilecount; + map->layers[0].data[i] = lastgid + cell; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Load the test sprite and character, then fill the actor pool with actors wearing it. + * + * These are the real assets, loaded through the real loaders, so the actors have + * a texture-backed sprite and akgl_actor_render takes its full path rather than + * bailing out at the missing-sprite branch. Facing is pinned by turning + * `movement_controls_face` off: the automatic face logic clears every facing bit + * for an actor that is not moving, and the fixture character maps its sprite to + * `ALIVE | FACE_LEFT`. + */ +static akerr_ErrorContext *bench_populate_scene(void) +{ + PREPARE_ERROR(errctx); + akgl_Actor *actor = NULL; + char name[AKGL_ACTOR_MAX_NAME_LENGTH]; + int i = 0; + + PASS(errctx, akgl_heap_init()); + PASS(errctx, akgl_registry_init()); + PASS(errctx, akgl_sprite_load_json("assets/testsprite.json")); + PASS(errctx, akgl_sprite_load_json("assets/testsprite2.json")); + PASS(errctx, akgl_character_load_json("assets/testcharacter.json")); + + for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) { + memset(&name, 0x00, sizeof(name)); + snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i); + PASS(errctx, akgl_heap_next_actor(&actor)); + PASS(errctx, akgl_actor_initialize(actor, (char *)&name)); + PASS(errctx, akgl_actor_set_character(actor, "testcharacter")); + actor->visible = true; + actor->layer = 0; + actor->movement_controls_face = false; + actor->x = (float32_t)((i * 37) % BENCH_SCREEN_W); + actor->y = (float32_t)((i * 23) % BENCH_SCREEN_H); + actor->curSpriteFrameId = 0; + AKGL_BITMASK_ADD(actor->state, (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the empty frame: clear the target and present it. + * + * Everything else a frame does is on top of this. It is also the only benchmark + * here whose cost is entirely SDL's rather than libakgl's, which makes it the + * baseline the rest are worth reading against. + */ +static akerr_ErrorContext *bench_frame(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + int count = bench_iterations(5000); + int i = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("frame_start + frame_end, 640x480", "frame", 230000.0); + for ( i = 0; i < count; i++ ) { + inner = renderer->frame_start(renderer); + if ( inner != NULL ) { + break; + } + inner = renderer->frame_end(renderer); + if ( inner != NULL ) { + break; + } + } + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the immediate-mode primitives. + * + * Each of these saves the renderer's draw colour, sets its own, draws, and puts + * the colour back, so the fixed cost per call is three SDL round trips whatever + * the shape. The shapes are sized like something a game would actually draw: a + * full-diagonal line, a dialogue-box-sized rectangle, a 64 pixel radius circle. + */ +static akerr_ErrorContext *bench_primitives(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + SDL_Color red = { 0xff, 0x00, 0x00, 0xff }; + SDL_FRect rect = { .x = 32.0, .y = 32.0, .w = 200.0, .h = 150.0 }; + int count = bench_iterations(100000); + int filled = bench_iterations(2000); + int circles = bench_iterations(20000); + int i = 0; + int rep = 0; + + PASS(errctx, renderer->frame_start(renderer)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("draw_point", "call", 1400.0); + BENCH_LOOP(inner, i, count, akgl_draw_point(renderer, 320.0, 240.0, red)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_line, screen diagonal", "call", 6200.0); + BENCH_LOOP(inner, i, count, akgl_draw_line(renderer, 0.0, 0.0, 639.0, 479.0, red)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_rect, 200x150 outline", "call", 5000.0); + BENCH_LOOP(inner, i, count, akgl_draw_rect(renderer, &rect, red)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_filled_rect, 200x150", "call", 440000.0); + BENCH_LOOP(inner, i, filled, akgl_draw_filled_rect(renderer, &rect, red)); + BENCH_FLUSH_STOP(errctx, filled); + PASS(errctx, inner); + + bench_start("draw_circle, radius 64", "call", 25000.0); + BENCH_LOOP(inner, i, circles, akgl_draw_circle(renderer, 320.0, 240.0, 64.0, red)); + BENCH_FLUSH_STOP(errctx, circles); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time the framebuffer round trips: save a region, put it back, flood fill. + * + * These are the expensive ones by construction. A copy reads pixels back off the + * render target; a paste uploads a texture, draws it, and destroys it again; and + * a flood fill reads back the *entire* target, converts the whole surface to + * RGBA32, walks it, uploads a patch, and destroys everything. That is a + * megabyte and a half of traffic for one fill at 640x480, whatever the size of + * the region that actually changed. + * + * The fill alternates colours because a fill whose seed pixel already carries + * the requested colour returns immediately, and would otherwise measure the + * early exit rather than the fill. + */ +static akerr_ErrorContext *bench_framebuffer(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + SDL_Surface *saved = NULL; + SDL_Rect region = { .x = 64, .y = 64, .w = 64, .h = 64 }; + SDL_Color red = { 0xff, 0x00, 0x00, 0xff }; + SDL_Color blue = { 0x00, 0x00, 0xff, 0xff }; + int count = bench_iterations(5000); + int fills = bench_iterations(200); + int i = 0; + int rep = 0; + + PASS(errctx, renderer->frame_start(renderer)); + PASS(errctx, akgl_draw_copy_region(renderer, ®ion, &saved)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("draw_copy_region, 64x64 readback", "call", 10000.0); + for ( i = 0; i < count; i++ ) { + SDL_Surface *scratch = NULL; + inner = akgl_draw_copy_region(renderer, ®ion, &scratch); + if ( inner != NULL ) { + break; + } + SDL_DestroySurface(scratch); + } + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_paste_region, 64x64 upload", "call", 55000.0); + BENCH_LOOP(inner, i, count, akgl_draw_paste_region(renderer, saved, 64.0, 64.0)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_flood_fill, full 640x480 target", "call", 20000000.0); + for ( i = 0; i < fills; i++ ) { + inner = akgl_draw_flood_fill(renderer, 320, 240, ( (i % 2) == 0 ) ? red : blue); + if ( inner != NULL ) { + break; + } + } + BENCH_FLUSH_STOP(errctx, fills); + PASS(errctx, inner); + PASS(errctx, renderer->frame_start(renderer)); + } + SDL_DestroySurface(saved); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time measuring and drawing one line of text. + * + * akgl_text_rendertextat rasterizes the string, uploads it as a texture, blits + * it, and destroys the texture again -- every call, with no cache anywhere. A + * HUD with six readouts pays this six times a frame for text that changes once a + * second. + */ +static akerr_ErrorContext *bench_text(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + SDL_Color white = { 0xff, 0xff, 0xff, 0xff }; + int w = 0; + int h = 0; + int count = bench_iterations(20000); + int lines = bench_iterations(5000); + int i = 0; + int rep = 0; + + PASS(errctx, renderer->frame_start(renderer)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("text_measure, 15 characters", "call", 400.0); + BENCH_LOOP(inner, i, count, akgl_text_measure(benchfont, BENCH_TEXT, &w, &h)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("text_rendertextat, 15 characters", "call", 130000.0); + BENCH_LOOP(inner, i, lines, akgl_text_rendertextat(benchfont, BENCH_TEXT, white, 0, 8, 8)); + BENCH_FLUSH_STOP(errctx, lines); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time loading assets off disk: a sprite, a character, and a whole map. + * + * This is startup and level-transition cost, not frame cost, and it is measured + * because it is the part of a game the player waits through. The sprite load + * after the first reuses the already registered spritesheet, so what is measured + * is the JSON path rather than the image decode. + * + * @note Each character load leaks the SDL property set holding its + * state-to-sprite map -- akgl_heap_release_character does not walk it. + * That is a known defect rather than something this benchmark introduces; + * it is repeated here often enough to be worth saying out loud. + */ +static akerr_ErrorContext *bench_asset_load(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Sprite *sprite = NULL; + akgl_Character *basechar = NULL; + int count = bench_iterations(2000); + int maps = bench_iterations(50); + int i = 0; + int j = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + PASS(errctx, bench_populate_scene()); + + bench_start("sprite_load_json, sheet already loaded", "load", 180000.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_sprite_load_json("assets/testsprite.json"); + if ( inner != NULL ) { + break; + } + sprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL); + if ( sprite == NULL ) { + break; + } + inner = akgl_heap_release_sprite(sprite); + if ( inner != NULL ) { + break; + } + } + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + PASS(errctx, bench_populate_scene()); + bench_start("character_load_json, two sprite mappings", "load", 150000.0); + for ( i = 0; i < count; i++ ) { + inner = akgl_character_load_json("assets/testcharacter.json"); + if ( inner != NULL ) { + break; + } + basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL); + if ( basechar == NULL ) { + break; + } + inner = akgl_heap_release_character(basechar); + if ( inner != NULL ) { + break; + } + } + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + PASS(errctx, bench_populate_scene()); + bench_start("tilemap_load + release, fixture map", "load", 120000000.0); + for ( i = 0; i < maps; i++ ) { + // The map's object layer spawns actors into the pool, and neither + // akgl_tilemap_release nor anything else gives them back, so a loop + // that only loaded and released would exhaust the actor pool on its + // 32nd iteration. Clearing the pool is part of what a host does + // between levels anyway, and it is a pair of memsets against a map + // load that decodes a 768x576 PNG. + inner = akgl_heap_init_actor(); + if ( inner != NULL ) { + break; + } + inner = akgl_registry_init_actor(); + if ( inner != NULL ) { + break; + } + // And the string pool has to be reclaimed by hand, because a map + // load leaks five pooled strings that release does not give back. + // The 52nd load would find the pool empty, and what happens then is + // not an AKGL_ERR_HEAP: akgl_get_json_string_value finishes with + // pass-up false, swallows the failed claim, and dereferences the + // pointer it never set. Both defects are filed in TODO.md; this loop + // works around the first so it never reaches the second. + for ( j = 0; j < AKGL_MAX_HEAP_STRING; j++ ) { + HEAP_STRING[j].refcount = 0; + } + inner = akgl_tilemap_load("assets/testmap.tmj", gamemap); + if ( inner != NULL ) { + break; + } + inner = akgl_tilemap_release(gamemap); + if ( inner != NULL ) { + break; + } + } + BENCH_FLUSH_STOP(errctx, maps); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time zeroing an akgl_Tilemap, which is the first thing a map load does. + * + * akgl_tilemap_load begins with `memset(dest, 0, sizeof(akgl_Tilemap))`, and an + * akgl_Tilemap is 16 layers of 512x512 cells plus 16 tilesets of 65536 offset + * pairs. The struct is that size whether the map is 2x2 or 512x512, so this is a + * fixed toll on every level transition, paid before a single byte of the map + * file has been read. Measuring it separately is the only way to tell how much + * of the load benchmark above is the map and how much is the container. + */ +static akerr_ErrorContext *bench_map_zero(void) +{ + PREPARE_ERROR(errctx); + int count = bench_iterations(200); + int i = 0; + int rep = 0; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("zeroing one akgl_Tilemap", "call", 16000000.0); + for ( i = 0; i < count; i++ ) { + memset(gamemap, 0x00, sizeof(akgl_Tilemap)); + } + BENCH_FLUSH_STOP(errctx, count); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time building a tileset's offset table. + * + * akgl_tilemap_compute_tileset_offsets fills one entry per tile in the image -- + * 1728 of them for the fixture tileset -- and a map pays it once per tileset at + * load time. It is also the function whose table costs 512 KB per tileset, + * which is the reason an akgl_Tilemap is as large as it is. + */ +static akerr_ErrorContext *bench_tileset_offsets(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + int count = bench_iterations(20000); + int i = 0; + int rep = 0; + + PASS(errctx, bench_build_map(gamemap, 1)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("tilemap_compute_tileset_offsets, 1728 tiles", "call", 23000.0); + BENCH_LOOP(inner, i, count, akgl_tilemap_compute_tileset_offsets(gamemap, 0)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time drawing a screenful of tiles, with one tileset and with eight. + * + * The camera covers 40 x 30 cells, so each of these is 1200 blits plus whatever + * the tileset scan costs on top. The gap between the two numbers is that scan: + * akgl_tilemap_draw walks every tileset for every tile and does not stop at the + * one that matched, so the cost is linear in tileset count even though at most + * one can ever answer. + */ +static akerr_ErrorContext *bench_tilemap_draw(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + int count = bench_iterations(30); + int i = 0; + int rep = 0; + + PASS(errctx, renderer->frame_start(renderer)); + PASS(errctx, bench_build_map(gamemap, 1)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("tilemap_draw, 40x30 tiles, 1 tileset", "frame", 170000000.0); + BENCH_LOOP(inner, i, count, akgl_tilemap_draw(gamemap, camera, 0)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + } + + PASS(errctx, bench_build_map(gamemap, BENCH_MAP_TILESETS)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("tilemap_draw, 40x30 tiles, 8 tilesets", "frame", 170000000.0); + BENCH_LOOP(inner, i, count, akgl_tilemap_draw(gamemap, camera, 0)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + } + printf("tilemap_draw covers %d x %d = %d tiles per frame\n", + (BENCH_SCREEN_W / BENCH_TILE_SIZE), + (BENCH_SCREEN_H / BENCH_TILE_SIZE), + ((BENCH_SCREEN_W / BENCH_TILE_SIZE) * (BENCH_SCREEN_H / BENCH_TILE_SIZE))); + SUCCEED_RETURN(errctx); +} + +/** + * @brief The control: the same 1200 blits, issued straight to SDL. + * + * Without this, every drawing number in this file is unreadable -- there is no + * way to tell how much of a 16 ms tilemap frame is libakgl deciding what to draw + * and how much is the software rasterizer moving pixels. This does the same + * amount of pixel work with none of the library in the way: same texture, same + * source rectangles, same destinations, no bounds logic, no tileset scan, no + * error contexts. Subtract it from the tilemap number and what is left is what + * libakgl costs. + */ +static akerr_ErrorContext *bench_raw_blit_control(void) +{ + PREPARE_ERROR(errctx); + SDL_FRect src = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE }; + SDL_FRect dest = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE }; + int across = (BENCH_SCREEN_W / BENCH_TILE_SIZE); + int down = (BENCH_SCREEN_H / BENCH_TILE_SIZE); + int columns = 0; + int tilecount = 0; + int tile = 0; + int count = bench_iterations(30); + int i = 0; + int x = 0; + int y = 0; + int rep = 0; + + FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles"); + columns = benchtiles->w / BENCH_TILE_SIZE; + tilecount = columns * (benchtiles->h / BENCH_TILE_SIZE); + + PASS(errctx, renderer->frame_start(renderer)); + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + // One source tile, blitted 1200 times. The floor: 1200 SDL calls and + // 1200 x 256 pixels of copying, with the source rectangle staying in + // cache the whole way. + bench_start("raw SDL blits, one source tile (control)", "frame", 5000000.0); + for ( i = 0; i < count; i++ ) { + src.x = 0.0; + src.y = 0.0; + for ( y = 0; y < down; y++ ) { + for ( x = 0; x < across; x++ ) { + dest.x = (float32_t)(x * BENCH_TILE_SIZE); + dest.y = (float32_t)(y * BENCH_TILE_SIZE); + SDL_RenderTexture(renderer->sdl_renderer, benchtiles, &src, &dest); + } + } + } + BENCH_FLUSH_STOP(errctx, count); + + // The same 1200 blits reading the same scattered source tiles the map + // asks for: cell (x, y) of a BENCH_MAP_W-wide map, so consecutive screen + // rows read parts of the sheet BENCH_MAP_W tiles apart rather than + // walking it in order. This is the honest control for + // akgl_tilemap_draw -- same call count, same pixels, same access + // pattern, none of libakgl in the path -- and the gap between the two is + // what the library's own per-tile work costs. + bench_start("raw SDL blits, map order (control)", "frame", 170000000.0); + for ( i = 0; i < count; i++ ) { + for ( y = 0; y < down; y++ ) { + for ( x = 0; x < across; x++ ) { + tile = (x + (y * BENCH_MAP_W)) % tilecount; + src.x = (float32_t)((tile % columns) * BENCH_TILE_SIZE); + src.y = (float32_t)((tile / columns) * BENCH_TILE_SIZE); + dest.x = (float32_t)(x * BENCH_TILE_SIZE); + dest.y = (float32_t)(y * BENCH_TILE_SIZE); + SDL_RenderTexture(renderer->sdl_renderer, benchtiles, &src, &dest); + } + } + } + BENCH_FLUSH_STOP(errctx, count); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time one actor's render, and then the whole scene through the backend. + * + * A render is two state-to-sprite lookups -- one for the sprite, one inside the + * visibility test -- some frame arithmetic, and one blit. The scene benchmark + * puts that together with the tilemap: one layer of tiles plus 64 actors, which + * is what akgl_render_2d_draw_world does for a modest 2D game. + */ +static akerr_ErrorContext *bench_scene(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + akgl_Actor *actor = NULL; + int count = bench_iterations(20000); + int frames = bench_iterations(30); + int i = 0; + int rep = 0; + + PASS(errctx, bench_populate_scene()); + PASS(errctx, bench_build_map(gamemap, 1)); + PASS(errctx, renderer->frame_start(renderer)); + actor = &HEAP_ACTOR[0]; + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("actor_render, on camera", "actor", 31000.0); + BENCH_LOOP(inner, i, count, actor->renderfunc(actor)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + + bench_start("draw_world, 1200 tiles + 64 actors", "frame", 170000000.0); + BENCH_LOOP(inner, i, frames, renderer->draw_world(renderer, NULL)); + BENCH_FLUSH_STOP(errctx, frames); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Time a whole frame the way akgl_game_update runs one. + * + * This is the number a host actually gets, and it is not the sum of the parts. + * akgl_game_update's update sweep is nested inside a loop over + * #AKGL_TILEMAP_MAX_LAYERS and does not filter by layer, so every live actor is + * updated sixteen times per frame rather than once. The gap between this + * benchmark and the scene benchmark plus the logic frame in tests/perf.c is that + * multiplier, and TODO.md carries it as a defect. + */ +static akerr_ErrorContext *bench_game_update(void) +{ + PREPARE_ERROR(errctx); + akerr_ErrorContext *inner = NULL; + int count = bench_iterations(30); + int i = 0; + int rep = 0; + + PASS(errctx, bench_populate_scene()); + PASS(errctx, bench_build_map(gamemap, 1)); + PASS(errctx, akgl_physics_init_null(physics)); + + for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) { + bench_start("game_update, full frame", "frame", 170000000.0); + BENCH_LOOP(inner, i, count, akgl_game_update(NULL)); + BENCH_FLUSH_STOP(errctx, count); + PASS(errctx, inner); + } + SUCCEED_RETURN(errctx); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); + SDL_SetLogOutputFunction(bench_discard_log, NULL); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + renderer = &_akgl_renderer; + physics = &_akgl_physics; + camera = &_akgl_camera; + gamemap = &_akgl_gamemap; + + FAIL_ZERO_BREAK( + errctx, + SDL_Init(SDL_INIT_VIDEO), + AKGL_ERR_SDL, + "Couldn't initialize SDL: %s", + SDL_GetError()); + FAIL_ZERO_BREAK( + errctx, + TTF_Init(), + AKGL_ERR_SDL, + "Couldn't initialize the font engine: %s", + SDL_GetError()); + FAIL_ZERO_BREAK( + errctx, + SDL_CreateWindowAndRenderer( + "net/aklabs/libakgl/test_perf_render", + BENCH_SCREEN_W, + BENCH_SCREEN_H, + 0, + &window, + &renderer->sdl_renderer), + AKGL_ERR_SDL, + "Couldn't create window/renderer: %s", + SDL_GetError()); + CATCH(errctx, akgl_render_bind2d(renderer)); + + camera->x = 0.0; + camera->y = 0.0; + camera->w = BENCH_SCREEN_W; + camera->h = BENCH_SCREEN_H; + + // akgl_game_update takes this before it does anything else. Normally + // akgl_game_init creates it; this suite brings the library up piece by + // piece so it can stay headless, so it creates the mutex itself. + game.statelock = SDL_CreateMutex(); + FAIL_ZERO_BREAK(errctx, game.statelock, AKGL_ERR_SDL, "%s", SDL_GetError()); + + // And this one is not optional either. akgl_game_updateFPS calls + // game.lowfpsfunc through the pointer, unguarded, on every frame under + // 30 fps -- which includes the first frame, before there is a frame rate + // to compare. A host that brings the library up the way renderer.h + // documents for an embedder, akgl_render_bind2d over its own window, + // segfaults on its first akgl_game_update. Filed in TODO.md; installing + // the default by hand is the workaround. + game.lowfpsfunc = &akgl_game_lowfps; + + benchfont = TTF_OpenFont(BENCH_FONT_PATH, BENCH_FONT_SIZE); + FAIL_ZERO_BREAK(errctx, benchfont, AKGL_ERR_SDL, "Couldn't open %s: %s", BENCH_FONT_PATH, SDL_GetError()); + benchtiles = IMG_LoadTexture(renderer->sdl_renderer, BENCH_TILESET_IMAGE); + FAIL_ZERO_BREAK(errctx, benchtiles, AKGL_ERR_SDL, "Couldn't load %s: %s", BENCH_TILESET_IMAGE, SDL_GetError()); + + CATCH(errctx, akgl_heap_init()); + CATCH(errctx, akgl_registry_init()); + CATCH(errctx, akgl_registry_init_properties()); + + CATCH(errctx, bench_frame()); + CATCH(errctx, bench_primitives()); + CATCH(errctx, bench_framebuffer()); + CATCH(errctx, bench_text()); + CATCH(errctx, bench_asset_load()); + CATCH(errctx, bench_map_zero()); + CATCH(errctx, bench_tileset_offsets()); + CATCH(errctx, bench_tilemap_draw()); + CATCH(errctx, bench_raw_blit_control()); + CATCH(errctx, bench_scene()); + CATCH(errctx, bench_game_update()); + + BENCH_REPORT_BREAK(errctx); + } CLEANUP { + if ( benchtiles != NULL ) { + SDL_DestroyTexture(benchtiles); + } + if ( benchfont != NULL ) { + TTF_CloseFont(benchfont); + } + TTF_Quit(); + SDL_Quit(); + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +}