Files
libakgl/PERFORMANCE.md

367 lines
19 KiB
Markdown
Raw Normal View History

Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
# 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 and **what the numbers say** is the argument. The six
defects the suites turned up on the way — two of them process-killing — are in
`TODO.md` under **Performance**, along with the targets these numbers are
measured against.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
## 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 |
|---|---:|---:|---:|
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
| `akgl_heap_actors` | 64 | 400 | 25,600 |
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
| `HEAP_SPRITE` | 1024 | 176 | 180,224 |
| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 |
| `HEAP_CHARACTER` | 256 | 184 | 47,104 |
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
| `akgl_heap_strings` | 256 | 4,100 | 1,049,600 |
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
| **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 |
Draw actors at their sprite's height, and update each one once a frame Closes Defects item 26 and Performance item 32. akgl_actor_render set dest.h from curSprite->width, so every actor was drawn square and a non-square sprite was stretched or squashed. Invisible in the fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and a render backend whose draw_texture records the rectangle it is handed instead of drawing it. That recording backend is the first coverage akgl_actor_render has had at all -- every other test in the file stubs renderfunc out. akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep nested inside it and never compared an actor's layer to the layer it was on, so every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted out: updating an actor is not a per-layer operation, and akgl_render_2d_draw_world already walks the layers for the half that is. AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who wants one layer can still ask, and gets each of those actors once. Counting is the assertion, deliberately. The defect was invisible in the frame total because the tilemap blits are three orders of magnitude larger, so a timing test would have measured the rasterizer. tests/game.c counts calls into a stub updatefunc and reports 16 against the old code. PERFORMANCE.md records the timing side as what it honestly is: the gap between the akgl_game_update and draw_world rows of the same run, 92 us before and noise in both directions after. The absolute table is not re-taken -- a later run on this machine read every row about 15% high, including rows nothing has touched. 25/25 pass, reindent --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:45:34 -04:00
The `akgl_game_update` row is from before the sixteen-times-per-actor defect was
fixed in 0.5.0; see below. It sat about 92 µs above `draw_world` in the same
run, and now sits level with it. The absolute figures in this table have not
been re-taken -- a later run on this machine read every row about 15% high,
including rows nothing has touched, so the table is a record of one machine
state and the honest measurement of that fix is the *gap* between two rows of
the same run, not a new absolute.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
## 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.
Draw actors at their sprite's height, and update each one once a frame Closes Defects item 26 and Performance item 32. akgl_actor_render set dest.h from curSprite->width, so every actor was drawn square and a non-square sprite was stretched or squashed. Invisible in the fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and a render backend whose draw_texture records the rectangle it is handed instead of drawing it. That recording backend is the first coverage akgl_actor_render has had at all -- every other test in the file stubs renderfunc out. akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep nested inside it and never compared an actor's layer to the layer it was on, so every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted out: updating an actor is not a per-layer operation, and akgl_render_2d_draw_world already walks the layers for the half that is. AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who wants one layer can still ask, and gets each of those actors once. Counting is the assertion, deliberately. The defect was invisible in the frame total because the tilemap blits are three orders of magnitude larger, so a timing test would have measured the rasterizer. tests/game.c counts calls into a stub updatefunc and reports 16 against the old code. PERFORMANCE.md records the timing side as what it honestly is: the gap between the akgl_game_update and draw_world rows of the same run, 92 us before and noise in both directions after. The absolute table is not re-taken -- a later run on this machine read every row about 15% high, including rows nothing has touched. 25/25 pass, reindent --check clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:45:34 -04:00
### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0
`akgl_game_update` looped over `AKGL_TILEMAP_MAX_LAYERS` with the actor sweep
nested inside it, and never compared an actor's `layer` to the layer it was on.
Every live actor's `updatefunc` ran **16 times per frame**: at 68.4 ns per
update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the
point, every piece of a game's own per-frame actor logic running sixteen times.
The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer
operation; drawing is, and `akgl_render_2d_draw_world` walks the layers itself.
`AKGL_ITERATOR_OP_LAYERMASK` is honoured now rather than ignored, so a caller
that genuinely wants one layer can still ask for it and gets each of those
actors once.
**How it was measured.** `tests/game.c` counts calls into a stub `updatefunc`
and asserts exactly one per live actor per `akgl_game_update`; against the old
code it reports 16. The timing side is the gap between the `akgl_game_update`
and `draw_world` rows of the *same* benchmark run: 92 µs before, and across
three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.
It was invisible in the frame total because the tilemap blits are three orders
of magnitude larger, which is exactly why it needed a counting test rather than
a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the
frame doing nothing.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
### 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
Six, and they are filed where defects live: `TODO.md`, under **Performance ->
Defects the perf suites found**, items 28-33, each with its file, line,
functional consequence, and what fixing it would touch. They are not repeated
here.
Worth saying in this document, because it is the argument for having written the
suites at all: stress testing breaks things unit tests do not reach. Two of the
six are process-killing — an error-context leak that aborted on the 129th path
resolution (fixed, with a regression test), and a pooled-string leak that turns
into a segfault rather than an `AKGL_ERR_HEAP` around the 52nd map load. Neither
is reachable by a suite that calls each function a handful of times, and neither
had anything to do with speed. They came out of loops that ran the same call ten
thousand times in one process, which is what a running game does and what
nothing else in this tree did.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
## 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.
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
- **No heap profiling beyond the static footprint.** Nothing here measures peak
or steady-state heap usage. Correctness of the heap *is* covered now, by
`cmake --build build --target memcheck`, which runs these same binaries under
valgrind — `tests/benchutil.h` cuts the iteration counts by three orders of
magnitude when it finds itself there, so a benchmark becomes a path-coverage
program. That run found six more defects, including a leak of the parsed JSON
document in every asset loader; they are in `TODO.md` under **Memory
checking**. What is still missing is a profile: how much a running game
actually holds at once, and how that grows over an hour.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
- **Single-threaded throughout.** The library is not thread-safe by design
(`akgl_game_state_lock` guards one field), and nothing here tests contention.