Files
libakgl/PERFORMANCE.md
Andrew Kesterson 8a920860c5 Keep the defect list in TODO.md and point PERFORMANCE.md at it
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>
2026-07-31 14:42:41 -04:00

17 KiB

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

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 memsets 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 memseting 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.