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>
353 lines
18 KiB
Markdown
353 lines
18 KiB
Markdown
# 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.
|