tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking inside the clock. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than several of the calls being measured. - Flush the renderer before stopping it. SDL batches, so the first version measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside SDL_DestroyTexture. - A drawing benchmark needs a raw-SDL control doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
929 lines
31 KiB
C
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(HEAP_ACTOR) + sizeof(HEAP_SPRITE) + sizeof(HEAP_SPRITESHEET) +
|
|
sizeof(HEAP_CHARACTER) + sizeof(HEAP_STRING);
|
|
|
|
printf("\n");
|
|
printf("static footprint, fixed at compile time:\n");
|
|
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
|
"HEAP_ACTOR", AKGL_MAX_HEAP_ACTOR, sizeof(akgl_Actor), sizeof(HEAP_ACTOR));
|
|
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
|
"HEAP_SPRITE", AKGL_MAX_HEAP_SPRITE, sizeof(akgl_Sprite), sizeof(HEAP_SPRITE));
|
|
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
|
"HEAP_SPRITESHEET", AKGL_MAX_HEAP_SPRITESHEET, sizeof(akgl_SpriteSheet), sizeof(HEAP_SPRITESHEET));
|
|
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
|
"HEAP_CHARACTER", AKGL_MAX_HEAP_CHARACTER, sizeof(akgl_Character), sizeof(HEAP_CHARACTER));
|
|
printf(" %-24s %5d x %7zu = %10zu bytes\n",
|
|
"HEAP_STRING", AKGL_MAX_HEAP_STRING, sizeof(akgl_String), sizeof(HEAP_STRING));
|
|
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++ ) {
|
|
HEAP_ACTOR[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++ ) {
|
|
HEAP_STRING[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 = &HEAP_ACTOR[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 = &HEAP_ACTOR[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 = &HEAP_ACTOR[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;
|
|
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);
|
|
}
|