/** * @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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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); }