Files
libakgl/tests/perf.c
Andrew Kesterson e4aa6a5084
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s
Take libakerror 2.0.1 and drop the workaround it makes obsolete
Every libakgl test suite could report success while failing. libakerror's
default unhandled-error handler ended in exit(errctx->status), an exit
status is one byte wide, and libakgl's band starts at 256 -- so
AKGL_ERR_SDL, the most common failure a library built on SDL can have,
exited 0 and CTest recorded a pass. tests/character.c aborted at its
second of four tests on a bad renderer and was green for months.

0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in
tests/testutil.h, and TODO.md ended the entry saying any consumer's
suites have the same problem and it was worth raising upstream. It was.
2.0.1 fixes it at the source: akerr_exit() owns the mapping and the
default handler calls it, so 0 exits 0, 1 through 255 exit themselves,
and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The
trap and its 21 call sites are gone.

Verified by putting the original failure back rather than by reading the
release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main
exits 125 and CTest reports a failure. A standalone consumer raising the
same status unhandled exits 125 where it exited 0 before.

tests/actor.c installs its own handler and called exit(errctx->status)
from it, which is the same defect one layer up. It calls akerr_exit()
now.

2.0.0 also makes the error pool and the status registry thread safe,
which libakgl needs more than it knew: audio_stream_callback raises
error contexts on SDL's audio thread. With an unlocked pool that
callback and the main thread could scan AKERR_ARRAY_ERROR at the same
time and be handed the same slot. The comment there says so.

This is a hard dependency floor, not a preference. 2.0.0 moved
__akerr_last_ignored to thread-local storage and made akerr_next_error()
return a context that already holds its reference, and both expand at
libakgl's call sites -- and at a consumer's, because akerror.h is part
of libakgl's public interface. Mixing headers and libraries across that
line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2; include/akgl/error.h now also feature-
tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe
for 2.0.1 since libakerror publishes no version macro.

0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it
re-exports through its headers is not.

TODO.md records the pkg-config gap this makes sharper -- akgl.pc names
no dependencies at all, so nothing tells a pkg-config consumer which
libakerror it needs.

Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall
-Werror. libakgl.so.0.7 links libakerror.so.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00

929 lines
31 KiB
C

/**
* @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>
#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;
total = sizeof(akgl_heap_actors) + sizeof(akgl_heap_sprites) + sizeof(akgl_heap_spritesheets) +
sizeof(akgl_heap_characters) + sizeof(akgl_heap_strings);
printf("\n");
printf("static footprint, fixed at compile time:\n");
printf(" %-24s %5d x %7zu = %10zu bytes\n",
"akgl_heap_actors", AKGL_MAX_HEAP_ACTOR, sizeof(akgl_Actor), sizeof(akgl_heap_actors));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
"akgl_heap_sprites", AKGL_MAX_HEAP_SPRITE, sizeof(akgl_Sprite), sizeof(akgl_heap_sprites));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
"akgl_heap_spritesheets", AKGL_MAX_HEAP_SPRITESHEET, sizeof(akgl_SpriteSheet), sizeof(akgl_heap_spritesheets));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
"akgl_heap_characters", AKGL_MAX_HEAP_CHARACTER, sizeof(akgl_Character), sizeof(akgl_heap_characters));
printf(" %-24s %5d x %7zu = %10zu bytes\n",
"akgl_heap_strings", AKGL_MAX_HEAP_STRING, sizeof(akgl_String), sizeof(akgl_heap_strings));
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++ ) {
akgl_heap_actors[i].refcount = 1;
}
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++ ) {
akgl_heap_strings[i].refcount = 1;
}
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));
actor = &akgl_heap_actors[0];
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));
actor = &akgl_heap_actors[0];
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++ ) {
bench_start("physics_simulate, 64 live actors", "frame", 12500.0);
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++ ) {
actor = &akgl_heap_actors[j];
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.
*/
static akerr_ErrorContext *bench_geometry(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_RectanglePoints points;
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);
bench_start("all-pairs collision sweep, 64 actors", "sweep", 1200000.0);
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());
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);
}