Files
libakgl/tests/perf.c
Andrew Kesterson 4e32328681
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 21s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
Remove the corner helpers akgl_collide_rectangles no longer uses
akgl_rectangle_points, akgl_collide_point_rectangle, akgl_Point and
akgl_RectanglePoints go. They were the intermediate form of an implementation
that changed: akgl_collide_rectangles was eight corner-containment tests built on
them, and it has been four span comparisons since the cross-case fix. Nothing
outside tests/ called either function, and a point-in-rectangle test is four
comparisons a caller can write without a struct conversion in front of them.

akgl_collide_rectangles stays. It has two correct callers in the sidescroller
asking a game-level overlap question -- a coin, a hazard, from an updatefunc --
where a bool is the whole answer and a proxy plus a narrowphase call would be
computing a normal nothing reads. TODO.md records the split rather than leaving
it to be rediscovered.

Public API removal, so 194 exported akgl_ symbols against 196, and the manual's
counts move with them. The perf suite loses its rectangle_points row; the
all-pairs sweep stays as the control it is now labelled, and PERFORMANCE.md says
what 0.8.0 measured against it -- 188.5 us for 32,640 pairs at 256 actors, where
a whole step with collision attached is 54.1 us doing strictly more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:09:42 -04:00

1119 lines
39 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 a whole physics step with collision attached, over a full pool.
*
* **This is the number Target 12 is about**: collision for a full actor pool
* without the caller writing a broad phase. It is the whole step -- the proxy
* sync pass, the movement logic, gravity, drag, the sub-stepped move, and
* akgl_collision_resolve per actor per sub-step -- against the same pool the
* row above measures without a world, so the two subtract.
*
* The actors are spread on a 24-pixel lattice with 16-pixel boxes, so neighbours
* are close enough to share grid cells and be tested against each other and far
* enough apart that most of those tests answer "no". A population that all
* overlaps measures the resolver; this measures a frame.
*/
static akerr_ErrorContext *bench_physics_collision(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_Character *basechar = NULL;
akgl_CollisionWorld world;
akgl_PhysicsBackend backend;
SDL_FRect body = { .x = -8.0, .y = -8.0, .w = 16.0, .h = 16.0 };
char label[64];
int count = bench_iterations(2000);
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, "benchcollisionchar", 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 ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
PASS(errctx, akgl_collision_shape_box(&akgl_heap_actors[i].shape, &body, 0.0));
akgl_heap_actors[i].shape_override = true;
akgl_heap_actors[i].shape.collidemask |= AKGL_COLLISION_LAYER_ACTOR;
akgl_heap_actors[i].x = (float32_t)((i % 20) * 24);
akgl_heap_actors[i].y = (float32_t)((i / 20) * 24);
}
PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0));
backend.collision = &world;
snprintf((char *)&label, sizeof(label),
"physics_simulate + collision, %d live actors", BENCH_ACTOR_COUNT);
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start((char *)&label, "frame", 2000000.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 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);
}
/**
* @brief Time akgl_collide_rectangles, and the all-pairs sweep it used to imply.
*
* The function is four span comparisons in `float32_t`, so an overlap and a miss
* cost the same -- it has no early exit and does not want one.
*
* The last benchmark is the **control**, not a recommendation. It is what a game
* had to write before 0.8.0: 64 actors is 2016 pairs, every frame. The
* partitioner rows in bench_collision are what to compare it against, and the
* comparison is generous to the control -- it tests pairs and does not sync,
* sub-step or resolve anything.
*/
static akerr_ErrorContext *bench_geometry(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
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("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_physics_collision());
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);
}