Files
libakgl/tests/perf.c

1060 lines
36 KiB
C
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
/**
* @file perf.c
* @brief Stress and timing benchmarks for every subsystem that does not need a renderer.
*
* These are not correctness tests. Each one drives a hot path -- the pool scans,
* the registry, the physics sweep, the per-actor update, the geometry helpers,
* the JSON accessors -- hard enough that its per-operation cost is measurable,
* and reports what that cost is. The companion suite in `tests/perf_render.c`
* covers the paths that need a renderer.
*
* The pools are the reason this matters. Every acquire is a linear scan of a
* fixed array looking for a zero reference count, so cost grows with how full
* the pool is, not with how much is being asked for. Several benchmarks below
* are therefore run twice: once against an empty pool, which is the number a
* casual reading of the code predicts, and once against a nearly full one,
* which is the number a game in its fifth minute actually gets.
*
* Everything here runs headless under the dummy drivers, and every benchmark
* rebuilds the state it needs rather than inheriting whatever the previous one
* left behind.
*
* @note SDL's log output is redirected to a sink that discards it. The library
* logs on paths this suite hammers -- akgl_actor_initialize logs every
* spawn -- and timing a write to a terminal or a CTest capture file
* measures the machine's I/O, not libakgl. What the *formatting* costs is
* measured deliberately, by the pair of actor-spawn benchmarks.
*/
#include <SDL3/SDL.h>
#include <string.h>
#include <jansson.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/sprite.h>
#include <akgl/physics.h>
#include <akgl/staticstring.h>
Measure collision, and say which numbers moved for what reason Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
#include <akgl/collision.h>
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
#include <akgl/util.h>
#include <akgl/json_helpers.h>
#include <akgl/tilemap.h>
#include "benchutil.h"
#include "testutil.h"
/** @brief How many actors the frame-shaped benchmarks put on the heap. */
#define BENCH_ACTOR_COUNT AKGL_MAX_HEAP_ACTOR
/** @brief The JSON document the accessor benchmarks read. */
#define BENCH_JSON_FIXTURE "assets/snippets/test_json_helpers.json"
/** @brief A path that exists relative to the CTest working directory, for akgl_path_relative. */
#define BENCH_PATH_FIXTURE "assets/testcharacter.json"
/**
* @brief Print the library's fixed memory footprint.
*
* Not a benchmark, but it belongs in the same report: libakgl does not call
* `malloc`, so every one of these arrays exists from process start whether the
* game uses one slot or all of them, and several of the timings above are
* explained entirely by their size. The string pool is a megabyte of `char`
* arrays, and an akgl_Tilemap is dominated by 16 layers of 512x512 `int` cells
* and 16 tilesets of 65536 offset pairs -- which is why zeroing one costs what
* it costs, and why the header warns against putting one on the stack.
*/
static void bench_report_footprint(void)
{
size_t total = 0;
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
total = sizeof(akgl_heap_actors) + sizeof(akgl_heap_sprites) + sizeof(akgl_heap_spritesheets) +
sizeof(akgl_heap_characters) + sizeof(akgl_heap_strings);
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
printf("\n");
printf("static footprint, fixed at compile time:\n");
printf(" %-24s %5d x %7zu = %10zu bytes\n",
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", AKGL_MAX_HEAP_ACTOR, sizeof(akgl_Actor), sizeof(akgl_heap_actors));
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
printf(" %-24s %5d x %7zu = %10zu bytes\n",
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_sprites", AKGL_MAX_HEAP_SPRITE, sizeof(akgl_Sprite), sizeof(akgl_heap_sprites));
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
printf(" %-24s %5d x %7zu = %10zu bytes\n",
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_spritesheets", AKGL_MAX_HEAP_SPRITESHEET, sizeof(akgl_SpriteSheet), sizeof(akgl_heap_spritesheets));
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
printf(" %-24s %5d x %7zu = %10zu bytes\n",
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_characters", AKGL_MAX_HEAP_CHARACTER, sizeof(akgl_Character), sizeof(akgl_heap_characters));
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
printf(" %-24s %5d x %7zu = %10zu bytes\n",
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", AKGL_MAX_HEAP_STRING, sizeof(akgl_String), sizeof(akgl_heap_strings));
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
printf(" %-24s %5s %7s %10zu bytes\n", "pools, total", "", "", total);
printf(" %-24s %5d x %7s = %10zu bytes\n",
"akgl_Tilemap", 1, "", sizeof(akgl_Tilemap));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
" of which layers", AKGL_TILEMAP_MAX_LAYERS, sizeof(akgl_TilemapLayer),
(sizeof(akgl_TilemapLayer) * AKGL_TILEMAP_MAX_LAYERS));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
" of which tilesets", AKGL_TILEMAP_MAX_TILESETS, sizeof(akgl_Tileset),
(sizeof(akgl_Tileset) * AKGL_TILEMAP_MAX_TILESETS));
fflush(stdout);
}
/**
* @brief Swallow SDL's log output.
*
* Installed for the whole run. See the note in the file comment: the library
* logs on paths this suite calls hundreds of thousands of times, and the cost of
* writing that out is not a property of libakgl.
*/
static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message)
{
return;
}
/**
* @brief Claim a spritesheet slot without loading an image into it.
*
* akgl_spritesheet_initialize uploads a texture and so needs a renderer, which
* this suite deliberately does not have. Nothing here draws, and the only thing
* that reads the sheet is akgl_sprite_initialize storing the pointer, so an
* empty claimed slot is enough.
*/
static akerr_ErrorContext *bench_make_sheet(akgl_SpriteSheet **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, akgl_heap_next_spritesheet(dest));
memset(*dest, 0x00, sizeof(akgl_SpriteSheet));
(*dest)->refcount += 1;
SUCCEED_RETURN(errctx);
}
/**
* @brief Build a sprite on a freshly claimed sheet and publish it in the sprite registry.
*
* @param dest Receives the sprite. Required.
* @param name Registry key. Copied at a fixed #AKGL_SPRITE_MAX_NAME_LENGTH
* bytes by akgl_sprite_initialize, so it is staged through a buffer
* of exactly that size first.
* @param speed Nanoseconds one frame is held. 0 makes every akgl_actor_update
* advance the animation, which is the expensive path.
*/
static akerr_ErrorContext *bench_make_sprite(akgl_Sprite **dest, char *name, uint32_t speed)
{
PREPARE_ERROR(errctx);
akgl_SpriteSheet *sheet = NULL;
char namebuf[AKGL_SPRITE_MAX_NAME_LENGTH];
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
memset(&namebuf, 0x00, sizeof(namebuf));
strncpy((char *)&namebuf, name, AKGL_SPRITE_MAX_NAME_LENGTH - 1);
PASS(errctx, bench_make_sheet(&sheet));
PASS(errctx, akgl_heap_next_sprite(dest));
PASS(errctx, akgl_sprite_initialize(*dest, (char *)&namebuf, sheet));
(*dest)->frames = 3;
(*dest)->frameids[0] = 0;
(*dest)->frameids[1] = 1;
(*dest)->frameids[2] = 2;
(*dest)->width = 48;
(*dest)->height = 48;
(*dest)->speed = speed;
(*dest)->loop = true;
SUCCEED_RETURN(errctx);
}
/**
* @brief Build a character with one sprite mapped to the two states these benchmarks use.
*
* State 0 is what akgl_actor_initialize leaves an actor in and what
* akgl_actor_automatic_face leaves it in while it is standing still;
* `MOVING_RIGHT | FACE_RIGHT` is what bench_fill_actor_pool puts it in and what
* the face logic settles on from there. Mapping both keeps the update path off
* the missing-sprite branch, which is measured deliberately elsewhere rather
* than by accident here.
*/
static akerr_ErrorContext *bench_make_character(akgl_Character **dest, char *name, uint32_t spritespeed)
{
PREPARE_ERROR(errctx);
akgl_Sprite *sprite = NULL;
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
PASS(errctx, akgl_heap_next_character(dest));
PASS(errctx, akgl_character_initialize(*dest, name));
(*dest)->sx = 120.0;
(*dest)->sy = 120.0;
(*dest)->sz = 0.0;
(*dest)->ax = 40.0;
(*dest)->ay = 40.0;
(*dest)->speedtime = 16;
PASS(errctx, bench_make_sprite(&sprite, name, spritespeed));
PASS(errctx, (*dest)->sprite_add(*dest, sprite, 0));
PASS(errctx,
(*dest)->sprite_add(
*dest,
sprite,
(AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_FACE_RIGHT))
);
SUCCEED_RETURN(errctx);
}
/**
* @brief Populate the actor pool with @p count live actors bound to @p basechar.
*
* The heap and the actor registry are rebuilt first, so this is the state every
* frame-shaped benchmark starts from rather than something it inherits.
*/
static akerr_ErrorContext *bench_fill_actor_pool(akgl_Character *basechar, int count)
{
PREPARE_ERROR(errctx);
akgl_Actor *actor = NULL;
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
int i = 0;
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
PASS(errctx, akgl_heap_init_actor());
PASS(errctx, akgl_registry_init_actor());
for ( i = 0; i < count; i++ ) {
memset(&name, 0x00, sizeof(name));
snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i);
PASS(errctx, akgl_heap_next_actor(&actor));
PASS(errctx, akgl_actor_initialize(actor, (char *)&name));
PASS(errctx, akgl_actor_set_character(actor, basechar->name));
actor->visible = true;
actor->layer = 0;
actor->x = (float32_t)(i * 13);
actor->y = (float32_t)(i * 7);
actor->sx = 120.0;
actor->sy = 120.0;
actor->ax = 40.0;
actor->ay = 40.0;
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time a claim from an empty actor pool against one from a nearly full pool.
*
* The pool scan stops at the first slot with a zero reference count, so an empty
* pool answers from index 0 and a pool with one slot left answers from index
* #AKGL_MAX_HEAP_ACTOR - 1. The ratio between these two numbers *is* the cost of
* the linear-scan allocator.
*/
static akerr_ErrorContext *bench_heap_actor_claim(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Actor *actor = NULL;
int count = bench_iterations(500000);
int i = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, akgl_heap_init_actor());
bench_start("heap_next_actor, empty pool", "call", 100.0);
BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor));
bench_stop(count);
PASS(errctx, inner);
for ( i = 0; i < (AKGL_MAX_HEAP_ACTOR - 1); i++ ) {
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[i].refcount = 1;
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
}
bench_start("heap_next_actor, one slot left", "call", 400.0);
BENCH_LOOP(inner, i, count, akgl_heap_next_actor(&actor));
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_heap_init_actor());
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the string pool, which is the one that hurts.
*
* Every entry is #AKGL_MAX_STRING_LENGTH bytes, so 256 of them is a megabyte and
* the scan for a free slot touches one reference count every 4 KiB -- a cache
* miss per candidate. Release then wipes the whole 4 KiB whether the string held
* a path or a single character. Three numbers come out of this: the claim on an
* empty pool, the claim with one slot left, and the release on its own.
*/
static akerr_ErrorContext *bench_heap_string(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_String *str = NULL;
int count = bench_iterations(100000);
int i = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, akgl_heap_init());
// Claim and release together: the scratch-buffer idiom the library uses
// everywhere, and the only string benchmark whose cost a caller can
// actually observe from outside.
bench_start("heap string claim + release cycle", "cycle", 500.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_heap_next_string(&str);
if ( inner != NULL ) {
break;
}
inner = akgl_heap_release_string(str);
if ( inner != NULL ) {
break;
}
}
bench_stop(count);
PASS(errctx, inner);
// The claim on its own. The reference the claim takes is dropped by hand
// rather than through release, so the 4 KiB wipe stays out of this one.
bench_start("heap_next_string, empty pool", "call", 100.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_heap_next_string(&str);
if ( inner != NULL ) {
break;
}
str->refcount = 0;
}
bench_stop(count);
PASS(errctx, inner);
// The same claim with 255 of the 256 slots taken: a megabyte walked, one
// reference count read per 4 KiB page.
for ( i = 0; i < (AKGL_MAX_HEAP_STRING - 1); i++ ) {
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[i].refcount = 1;
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
}
bench_start("heap_next_string, one slot left", "call", 2500.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_heap_next_string(&str);
if ( inner != NULL ) {
break;
}
str->refcount = 0;
}
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_heap_init());
// Release on its own: one 4 KiB memset per call, whatever the string held.
PASS(errctx, akgl_heap_next_string(&str));
bench_start("heap_release_string, 4 KiB wipe", "call", 500.0);
for ( i = 0; i < count; i++ ) {
str->refcount = 1;
inner = akgl_heap_release_string(str);
if ( inner != NULL ) {
break;
}
}
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time a full reset of every pool.
*
* akgl_heap_init zeroes all five arrays -- better than a megabyte, dominated by
* the string pool. A game pays this once at startup and again on every call to
* akgl_game_init; a level transition that calls it per level pays it per level.
*/
static akerr_ErrorContext *bench_heap_init(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
int count = bench_iterations(2000);
int i = 0;
int rep = 0;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("heap_init, all five pools", "call", 450000.0);
BENCH_LOOP(inner, i, count, akgl_heap_init());
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time an actor spawn, with and without the log line the library writes.
*
* A spawn is a pool claim, a `memset` of the whole actor, a registry insert, and
* an `SDL_Log`. The second run raises the log priority so SDL returns before it
* formats anything; the gap between the two numbers is what the log line costs
* every caller, including the ones that never read it.
*/
static akerr_ErrorContext *bench_actor_spawn(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Actor *actor = NULL;
char name[AKGL_ACTOR_MAX_NAME_LENGTH];
int count = bench_iterations(20000);
int i = 0;
int rep = 0;
memset(&name, 0x00, sizeof(name));
strncpy((char *)&name, "benchspawn", AKGL_ACTOR_MAX_NAME_LENGTH - 1);
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, akgl_heap_init_actor());
PASS(errctx, akgl_registry_init_actor());
bench_start("actor spawn + release, library logging on", "actor", 2100.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_heap_next_actor(&actor);
if ( inner != NULL ) {
break;
}
inner = akgl_actor_initialize(actor, (char *)&name);
if ( inner != NULL ) {
break;
}
inner = akgl_heap_release_actor(actor);
if ( inner != NULL ) {
break;
}
}
bench_stop(count);
PASS(errctx, inner);
SDL_SetLogPriorities(SDL_LOG_PRIORITY_CRITICAL);
bench_start("actor spawn + release, logging suppressed", "actor", 1700.0);
for ( i = 0; i < count; i++ ) {
inner = akgl_heap_next_actor(&actor);
if ( inner != NULL ) {
break;
}
inner = akgl_actor_initialize(actor, (char *)&name);
if ( inner != NULL ) {
break;
}
inner = akgl_heap_release_actor(actor);
if ( inner != NULL ) {
break;
}
}
bench_stop(count);
SDL_SetLogPriorities(SDL_LOG_PRIORITY_INFO);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time a registry lookup with the actor registry full.
*
* akgl_actor_set_character is the library's own name-to-pointer lookup: an SDL
* property fetch against a registry holding #AKGL_MAX_HEAP_ACTOR entries, plus
* the four field copies that follow it. Every actor built from a tilemap object
* layer pays it once.
*/
static akerr_ErrorContext *bench_registry_lookup(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_Actor *actor = NULL;
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, bench_make_character(&basechar, "benchlookupchar", 0));
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
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
actor = &akgl_heap_actors[0];
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
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("actor_set_character, registry lookup", "call", 400.0);
BENCH_LOOP(inner, i, count, akgl_actor_set_character(actor, basechar->name));
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the property store a game reads its configuration out of.
*
* akgl_get_property copies a fixed #AKGL_MAX_STRING_LENGTH bytes out of the
* property store regardless of how long the value is, so reading `"0.0"` moves
* 4 KiB. akgl_physics_init_arcade makes six of these calls.
*/
static akerr_ErrorContext *bench_properties(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_String *value = NULL;
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_registry_init_properties());
PASS(errctx, akgl_set_property("bench.property", "a short value"));
PASS(errctx, akgl_heap_next_string(&value));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("set_property", "call", 1000.0);
BENCH_LOOP(inner, i, count, akgl_set_property("bench.property", "a short value"));
bench_stop(count);
PASS(errctx, inner);
bench_start("get_property, 4 KiB copy", "call", 900.0);
BENCH_LOOP(inner, i, count, akgl_get_property("bench.property", &value, "unset"));
bench_stop(count);
PASS(errctx, inner);
}
PASS(errctx, akgl_heap_release_string(value));
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the state-to-sprite lookup that runs twice per actor per frame.
*
* akgl_character_sprite_get renders the state bitmask to decimal with `SDL_itoa`
* and looks the result up as a property string. akgl_actor_update calls it, and
* so does akgl_actor_render, so a 64-actor frame makes 128 of these calls before
* anything is drawn.
*/
static akerr_ErrorContext *bench_character_sprite_get(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_Sprite *sprite = NULL;
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, bench_make_character(&basechar, "benchspritechar", 0));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("character_sprite_get, state to sprite", "call", 400.0);
BENCH_LOOP(inner, i, count, basechar->sprite_get(basechar, 0, &sprite));
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time one actor's logic update, with the animation advancing every call.
*
* The sprite's frame time is 0, so every update takes the branch that changes
* frame -- the expensive path, and the one a fast animation actually takes. An
* update is a face recalculation, a sprite lookup, a clock read, and the frame
* arithmetic.
*/
static akerr_ErrorContext *bench_actor_update(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_Actor *actor = NULL;
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, bench_make_character(&basechar, "benchupdatechar", 0));
PASS(errctx, bench_fill_actor_pool(basechar, 1));
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
actor = &akgl_heap_actors[0];
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
AKGL_BITMASK_CLEAR(actor->state);
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("actor_update, animation advancing", "actor", 700.0);
BENCH_LOOP(inner, i, count, actor->updatefunc(actor));
bench_stop(count);
PASS(errctx, inner);
// An actor in a state its character has no sprite for is not an error
// the library refuses -- akgl_actor_update handles AKERR_KEY and carries
// on, and akgl_actor_render logs it and draws nothing. It is a normal
// condition on a partly authored character, and it is worth knowing what
// one costs, because raising, formatting, and handling a context is far
// more work than the update it replaces.
AKGL_BITMASK_ADD(actor->state, AKGL_ACTOR_STATE_MOVING_UP);
bench_start("actor_update, no sprite for state", "actor", 6200.0);
BENCH_LOOP(inner, i, count, actor->updatefunc(actor));
bench_stop(count);
PASS(errctx, inner);
AKGL_BITMASK_CLEAR(actor->state);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the physics sweep over a full actor pool, and over an empty one.
*
* akgl_physics_simulate walks all #AKGL_MAX_HEAP_ACTOR slots whether or not
* anything is in them, so the empty-pool number is the floor every frame pays.
* The full-pool number is one frame of movement for 64 actors: movement logic,
* gravity, drag, clamp, and integrate, per actor.
*/
static akerr_ErrorContext *bench_physics_simulate(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_PhysicsBackend backend;
int count = bench_iterations(20000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, akgl_registry_init_properties());
PASS(errctx, bench_make_character(&basechar, "benchphysicschar", 0));
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
PASS(errctx, akgl_physics_init_arcade(&backend));
backend.gravity_y = 9.8;
backend.drag_x = 0.1;
backend.drag_y = 0.1;
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
Measure collision, and say which numbers moved for what reason Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
bench_start("physics_simulate, 64 live actors", "frame", 16000.0);
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
BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL));
bench_stop(count);
PASS(errctx, inner);
}
PASS(errctx, akgl_heap_init_actor());
PASS(errctx, akgl_registry_init_actor());
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("physics_simulate, empty pool", "frame", 650.0);
BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL));
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the logic half of a frame: update every actor, then simulate.
*
* This is what a host's main loop does before it draws anything, and it is the
* number to subtract from a 16.6 ms frame to find out what is left for drawing.
* Note that it is *not* what akgl_game_update does: that one runs the update
* loop once per tilemap layer, which is measured in the render suite.
*/
static akerr_ErrorContext *bench_logic_frame(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_Actor *actor = NULL;
akgl_PhysicsBackend backend;
int count = bench_iterations(5000);
int i = 0;
int j = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_registry_init());
PASS(errctx, akgl_registry_init_properties());
PASS(errctx, bench_make_character(&basechar, "benchframechar", 0));
memset(&backend, 0x00, sizeof(akgl_PhysicsBackend));
PASS(errctx, akgl_physics_init_arcade(&backend));
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("logic frame, 64 actors updated + simulated", "frame", 60000.0);
for ( i = 0; i < count; i++ ) {
for ( j = 0; j < AKGL_MAX_HEAP_ACTOR; j++ ) {
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
actor = &akgl_heap_actors[j];
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
if ( actor->refcount == 0 ) {
continue;
}
inner = actor->updatefunc(actor);
if ( inner != NULL ) {
break;
}
}
if ( inner != NULL ) {
break;
}
inner = backend.simulate(&backend, NULL);
if ( inner != NULL ) {
break;
}
}
bench_stop(count);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the geometry helpers, including the all-pairs sweep a caller has to write.
*
* akgl_collide_rectangles tests eight corners and returns at the first hit, so
* an overlap is cheap and a miss is the full eight. The third benchmark is the
* broad phase the library does not provide: 64 actors is 2016 pairs, and a
* caller doing collision at all does that every frame.
*/
Measure collision, and say which numbers moved for what reason Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
/** @brief Counts nothing; the query cost is what is being measured. */
static akerr_ErrorContext *bench_collision_visit(akgl_CollisionProxy *proxy, void *data)
{
(void)proxy;
(void)data;
return NULL;
}
/** @brief A map with a floor, for the tile-collision row. */
static akgl_Tilemap bench_collision_map;
/**
* @brief Collision: the narrowphase, the broad phase, and a whole sweep.
*
* The all-pairs row in bench_geometry stays where it is and is the control for
* these: it is the cost a caller paid before the library had a broad phase, run
* on the same machine in the same run, which is the only way to read a
* reduction honestly.
*/
static akerr_ErrorContext *bench_collision(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_CollisionWorld world;
akgl_CollisionShape boxshape;
akgl_CollisionShape circleshape;
akgl_CollisionProxy a;
akgl_CollisionProxy b;
akgl_CollisionProxy *proxies[BENCH_ACTOR_COUNT];
akgl_Contact contact;
SDL_FRect body = { .x = -8.0, .y = -8.0, .w = 16.0, .h = 16.0 };
SDL_FRect area = { .x = 0.0, .y = 0.0, .w = 48.0, .h = 48.0 };
bool hit = false;
int count = bench_iterations(500000);
int sweeps = bench_iterations(20000);
int i = 0;
int x = 0;
int rep = 0;
PASS(errctx, akgl_collision_shape_box(&boxshape, &body, 0.0));
PASS(errctx, akgl_collision_shape_circle(&circleshape, 0.0, 0.0, 8.0, 0.0));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0));
PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &boxshape, 0.0, 0.0, 0.0));
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 4.0, 0.0, 0.0));
bench_start("narrowphase box/box, overlapping", "call", 210.0);
BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 900.0, 900.0, 0.0));
bench_start("narrowphase box/box, disjoint", "call", 90.0);
BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &circleshape, 0.0, 0.0, 0.0));
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &circleshape, 4.0, 0.0, 0.0));
bench_start("narrowphase circle/circle, overlapping", "call", 680.0);
BENCH_LOOP(inner, i, bench_iterations(100000),
akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(bench_iterations(100000));
PASS(errctx, inner);
// The broad phase, with a realistic population spread over the map.
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
PASS(errctx, akgl_heap_next_collision_proxy(&proxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(proxies[i], NULL, &boxshape,
(float32_t)((i % 20) * 24),
(float32_t)((i / 20) * 24), 0.0));
PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i]));
}
bench_start("grid move, unchanged cells", "actor", 120.0);
BENCH_LOOP(inner, i, count, world.partitioner.move(&world.partitioner, proxies[0]));
bench_stop(count);
PASS(errctx, inner);
bench_start("grid query, 64 actors", "query", 440.0);
BENCH_LOOP(inner, i, sweeps,
world.partitioner.query(&world.partitioner, &area,
AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL));
bench_stop(sweeps);
PASS(errctx, inner);
// The same population through the tree, so the two are comparable.
PASS(errctx, akgl_collision_world_init(&world, "bsp", 16.0, 16.0));
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i]));
}
bench_start("bsp query, 64 actors", "query", 1140.0);
BENCH_LOOP(inner, i, sweeps,
world.partitioner.query(&world.partitioner, &area,
AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL));
bench_stop(sweeps);
PASS(errctx, inner);
// One actor against a floor of tiles, which is the case a platformer
// runs every frame for every actor.
memset(&bench_collision_map, 0x00, sizeof(akgl_Tilemap));
bench_collision_map.tilewidth = 16;
bench_collision_map.tileheight = 16;
bench_collision_map.width = 128;
bench_collision_map.height = 32;
bench_collision_map.numlayers = 1;
bench_collision_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
for ( x = 0; x < bench_collision_map.width; x++ ) {
bench_collision_map.layers[0].data[(20 * bench_collision_map.width) + x] = 1;
}
bench_collision_map.collidablelayers = 1u;
PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0));
PASS(errctx, akgl_collision_bind_tilemap(&world, &bench_collision_map));
area.x = 100.0;
area.y = 310.0;
area.w = 16.0;
area.h = 16.0;
bench_start("tile query, actor on a floor", "query", 160.0);
BENCH_LOOP(inner, i, sweeps, akgl_collision_box_blocked(&world, &area,
AKGL_COLLISION_LAYER_ALL, &hit));
bench_stop(sweeps);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
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
static akerr_ErrorContext *bench_geometry(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
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_RectanglePoints points;
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
SDL_FRect rects[BENCH_ACTOR_COUNT];
SDL_FRect overlapping = { .x = 8.0, .y = 8.0, .w = 32.0, .h = 32.0 };
SDL_FRect disjoint = { .x = 900.0, .y = 900.0, .w = 32.0, .h = 32.0 };
SDL_FRect subject = { .x = 0.0, .y = 0.0, .w = 32.0, .h = 32.0 };
bool collide = false;
int count = bench_iterations(500000);
int sweeps = bench_iterations(2000);
int pairs = 0;
int i = 0;
int j = 0;
int k = 0;
int rep = 0;
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
rects[i].x = (float32_t)(i * 24);
rects[i].y = (float32_t)(i * 18);
rects[i].w = 32.0;
rects[i].h = 32.0;
}
pairs = (BENCH_ACTOR_COUNT * (BENCH_ACTOR_COUNT - 1)) / 2;
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("rectangle_points", "call", 100.0);
BENCH_LOOP(inner, i, count, akgl_rectangle_points(&points, &subject));
bench_stop(count);
PASS(errctx, inner);
bench_start("collide_rectangles, overlapping", "call", 300.0);
BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &overlapping, &collide));
bench_stop(count);
PASS(errctx, inner);
bench_start("collide_rectangles, disjoint", "call", 600.0);
BENCH_LOOP(inner, i, count, akgl_collide_rectangles(&subject, &disjoint, &collide));
bench_stop(count);
PASS(errctx, inner);
Measure collision, and say which numbers moved for what reason Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
bench_start("control: all-pairs collision sweep, 64 actors", "sweep", 1200000.0);
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
for ( k = 0; k < sweeps; k++ ) {
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
for ( j = i + 1; j < BENCH_ACTOR_COUNT; j++ ) {
inner = akgl_collide_rectangles(&rects[i], &rects[j], &collide);
if ( inner != NULL ) {
break;
}
}
}
if ( inner != NULL ) {
break;
}
}
bench_stop(sweeps);
PASS(errctx, inner);
}
printf("all-pairs collision sweep covers %d pairs per sweep\n", pairs);
SUCCEED_RETURN(errctx);
}
/**
* @brief Time the pooled-string operations.
*
* Both of these move #AKGL_MAX_STRING_LENGTH bytes by default -- a copy with a
* count of 0 means "all of it" -- so the cost is the same for a filename and for
* a single character.
*/
static akerr_ErrorContext *bench_strings(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_String *src = NULL;
akgl_String *dst = NULL;
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_heap_next_string(&src));
PASS(errctx, akgl_heap_next_string(&dst));
PASS(errctx, akgl_string_initialize(src, "assets/testcharacter.json"));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("string_initialize", "call", 350.0);
BENCH_LOOP(inner, i, count, akgl_string_initialize(dst, "assets/testcharacter.json"));
bench_stop(count);
PASS(errctx, inner);
bench_start("string_copy, full length", "call", 350.0);
BENCH_LOOP(inner, i, count, akgl_string_copy(src, dst, 0));
bench_stop(count);
PASS(errctx, inner);
}
PASS(errctx, akgl_heap_release_string(src));
PASS(errctx, akgl_heap_release_string(dst));
SUCCEED_RETURN(errctx);
}
/**
* @brief Time parsing a document and reading values back out of it.
*
* The parse is what an asset load pays per file. The accessors are what the
* tilemap and character loaders pay per field, and every one of them that
* returns a string claims and fills a 4 KiB pooled string.
*/
static akerr_ErrorContext *bench_json(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_String *path = NULL;
akgl_String *value = NULL;
json_t *doc = NULL;
json_error_t jsonerr;
int number = 0;
int loads = bench_iterations(2000);
int count = bench_iterations(200000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_heap_next_string(&path));
PASS(errctx, akgl_heap_next_string(&value));
snprintf((char *)&path->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), BENCH_JSON_FIXTURE);
doc = json_load_file((char *)&path->data, 0, &jsonerr);
FAIL_ZERO_RETURN(errctx, doc, AKERR_IO, "Unable to load %s: %s", (char *)&path->data, jsonerr.text);
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("json_load_file, small document", "load", 120000.0);
for ( i = 0; i < loads; i++ ) {
json_t *scratch = json_load_file((char *)&path->data, 0, &jsonerr);
if ( scratch == NULL ) {
break;
}
json_decref(scratch);
}
bench_stop(loads);
bench_start("get_json_string_value", "call", 450.0);
BENCH_LOOP(inner, i, count, akgl_get_json_string_value(doc, "name", &value));
bench_stop(count);
PASS(errctx, inner);
bench_start("get_json_integer_value", "call", 150.0);
BENCH_LOOP(inner, i, count, akgl_get_json_integer_value(doc, "count", &number));
bench_stop(count);
PASS(errctx, inner);
}
json_decref(doc);
PASS(errctx, akgl_heap_release_string(path));
PASS(errctx, akgl_heap_release_string(value));
SUCCEED_RETURN(errctx);
}
/**
* @brief Time path resolution, which every asset reference goes through.
*
* akgl_path_relative calls `realpath(3)`, so this is a syscall and a walk of the
* filesystem, per reference, at load time. A tilemap naming twenty tilesets and
* sprites pays it twenty times.
*/
static akerr_ErrorContext *bench_path_relative(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_String *dst = NULL;
int count = bench_iterations(20000);
int i = 0;
int rep = 0;
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_heap_next_string(&dst));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("path_relative, realpath on an existing file", "call", 36000.0);
BENCH_LOOP(inner, i, count, akgl_path_relative(".", BENCH_PATH_FIXTURE, dst));
bench_stop(count);
PASS(errctx, inner);
}
PASS(errctx, akgl_heap_release_string(dst));
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetLogOutputFunction(bench_discard_log, NULL);
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, akgl_registry_init_properties());
CATCH(errctx, bench_heap_actor_claim());
CATCH(errctx, bench_heap_string());
CATCH(errctx, bench_heap_init());
CATCH(errctx, bench_actor_spawn());
CATCH(errctx, bench_registry_lookup());
CATCH(errctx, bench_properties());
CATCH(errctx, bench_character_sprite_get());
CATCH(errctx, bench_actor_update());
CATCH(errctx, bench_physics_simulate());
CATCH(errctx, bench_logic_frame());
CATCH(errctx, bench_geometry());
Measure collision, and say which numbers moved for what reason Seven new benchmark rows, budgets set from a measured full-scale run rather than guessed, and the three existing rows the work moved re-recorded from that same run. The all-pairs sweep stays and is relabelled `control:` -- it is the cost a caller paid before the library had a broad phase, measured on the same machine in the same run, which is the only honest way to read a reduction. What the numbers say: - The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays. - The grid beats the tree by 2.6x on the same population, which is the argument for the default measured here rather than cited from another engine. The tree also rebuilds on every move and that is not in its row, so a moving scene is worse than 2.6x. - A grid `move` that changes nothing is 11.5 ns. That is the incremental claim in one number. Two rows moved on a backend with **no collision world attached**, so no collision code runs in either, and the commit says so rather than letting the feature take credit: - akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set per frame. The empty-pool row is unchanged at 58.7 ns, which is what identifies the cost as per live actor rather than per slot. - Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an early-returning function is not free when it is reached through one. The no-collision path now routes around the machinery entirely, which took the 64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson for writing benchmarks; this is the same thing in production code. The remainder is the struct, and it is paid whether or not a game uses collision. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
CATCH(errctx, bench_collision());
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
CATCH(errctx, bench_strings());
CATCH(errctx, bench_json());
CATCH(errctx, bench_path_relative());
bench_report_footprint();
BENCH_REPORT_BREAK(errctx);
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}