Files
libakgl/tests/perf.c
Andrew Kesterson fcad2822a9 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

1060 lines
36 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/collision.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", 16000.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.
*/
/** @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);
}
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("control: 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_collision());
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);
}