The six defects the perf suites turned up were written out in full in both files. TODO.md is where a defect belongs -- file, line, functional consequence, blast radius, what closing it touches -- and duplicating that in a report guarantees the two drift, with no way to tell which copy is current. PERFORMANCE.md keeps what is actually its argument: that stress testing reaches failures a suite calling each function a handful of times does not, and that two of the six kill the process rather than slowing it down. The detail is one reference away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
339 lines
17 KiB
Markdown
339 lines
17 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 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.
|
|
|
|
## 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
|
|
|
|
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.
|
|
|
|
## 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.
|