Files
libakgl/PERFORMANCE.md
Andrew Kesterson fcad2822a9 Measure collision, and say which numbers moved for what reason
Seven new benchmark rows, budgets set from a measured full-scale run rather
than guessed, and the three existing rows the work moved re-recorded from that
same run. The all-pairs sweep stays and is relabelled `control:` -- it is the
cost a caller paid before the library had a broad phase, measured on the same
machine in the same run, which is the only honest way to read a reduction.

What the numbers say:

- The box fast path is two to seven times cheaper than the general solver, and
  the disjoint case is cheaper still because the proxies' bounds reject it before
  any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays.
- The grid beats the tree by 2.6x on the same population, which is the argument
  for the default measured here rather than cited from another engine. The tree
  also rebuilds on every move and that is not in its row, so a moving scene is
  worse than 2.6x.
- A grid `move` that changes nothing is 11.5 ns. That is the incremental claim
  in one number.

Two rows moved on a backend with **no collision world attached**, so no
collision code runs in either, and the commit says so rather than letting the
feature take credit:

- akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and
  a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more
  working set per frame. The empty-pool row is unchanged at 58.7 ns, which is
  what identifies the cost as per live actor rather than per slot.
- Reaching the collision check through CATCH cost more than the check. PASS and
  CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an
  early-returning function is not free when it is reached through one. The
  no-collision path now routes around the machinery entirely, which took the
  64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson
  for writing benchmarks; this is the same thing in production code.

The remainder is the struct, and it is paid whether or not a game uses
collision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00

33 KiB

libakgl performance baseline

This is where the library actually spends its time, measured rather than guessed. The numbers below are the first recorded baseline: libakgl 0.3.0, at commit f35443e plus the perf suites themselves. Everything here is reproducible with two commands, and the suites that produced it are checked in as tests/perf.c and tests/perf_render.c.

Read it in this order if you only want the short version: the frame budget is the part that matters and what the numbers say is the argument. The six defects the suites turned up on the way — two of them process-killing — are in TODO.md under Performance, along with the targets these numbers are measured against.

Reproducing it

cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
ctest --test-dir build -L perf --output-on-failure

Both suites print a table and exit non-zero if any measurement blew its budget. They are ordinary CTest tests, so ctest --test-dir build runs them along with everything else; ctest --test-dir build -LE perf leaves them out when you only want the unit suites. AKGL_BENCH_SCALE scales every iteration count — AKGL_BENCH_SCALE=0.1 for a quick look, 10 for a long one. Below 1.0 the budgets are reported but not enforced, because a short run is a noisy one.

The whole thing takes about 30 seconds: 4 s for perf, 25 s for perf_render.

The machine

CPU AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max
Memory 62 GiB
OS Linux 6.8.0-136-generic
Compiler gcc 13.3.0, -DCMAKE_BUILD_TYPE=RelWithDebInfo (-O2 -g)
SDL vendored SDL 3.4.8, dummy video driver, software renderer
Target 640x480, offscreen

One machine, one build type, one afternoon. Treat the absolute numbers as this laptop's and the ratios as the library's.

How it is measured

  • Best of five. Each benchmark runs five times and the harness keeps the fastest. The mean is the wrong statistic on a shared machine: every source of noise makes a run slower and none makes it faster.
  • 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. The timed loop stashes the context and checks it after the clock stops.
  • The renderer is flushed inside the measurement. SDL batches: a draw_* call queues a command and returns. The first version of this suite measured queueing and reported a tilemap frame at 65 µs; the deferred work then took 114 seconds to come out at teardown, inside SDL_DestroyTexture. Every drawing benchmark now flushes before it stops the clock, and the numbers below are 250x larger and true.
  • SDL's log output goes to a sink that discards it. The library logs on paths this suite calls hundreds of thousands of times; timing a write to a terminal measures the terminal. What the formatting costs is measured deliberately, by the pair of actor-spawn benchmarks.
  • Budgets are set at roughly 10x the measured baseline. Loose enough that a busy machine does not turn CI red, tight enough that a linear scan becoming quadratic cannot hide. They are enforced only in an optimized build at full scale — a coverage build measures gcov, not libakgl.

The renderer caveat, stated once and loudly. These draw benchmarks run against SDL's software renderer. No shipped game does that. What a software renderer buys is that all the work stays in this process where it can be timed, and that the per-blit cost is at least honest about how much pixel traffic was asked for. Read every drawing number as a count of work libakgl asked for, not as a frame rate anyone would ship. That is exactly why the raw-SDL control rows exist: they do the same pixel work with none of the library in the path, so the difference between the two is libakgl's share, and that part does carry over to a GPU backend.

Static footprint

libakgl does not call malloc. Every one of these arrays exists from process start whether the game uses one slot or all of them.

Pool Slots Bytes each Total
akgl_heap_actors 64 400 25,600
HEAP_SPRITE 1024 176 180,224
HEAP_SPRITESHEET 1024 536 548,864
HEAP_CHARACTER 256 184 47,104
akgl_heap_strings 256 4,100 1,049,600
pools, total 1,851,392
akgl_Tilemap (one, as _akgl_gamemap) 1 26,388,008 26,388,008
— of which layers 16 1,120,296 17,924,736
— of which tilesets 16 528,944 8,463,104

28 MB of BSS before main runs, and 94% of it is one tilemap. A layer is 512x512 int cells whether the map is 512x512 or 2x2, and a tileset carries a 65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is not a hypothetical cost: zeroing that struct is 1.37 ms, and akgl_tilemap_load pays it before it has read a byte of the map file.

Results: everything that does not draw

tests/perf.c, full scale. ns/op is the best of five runs; ops/sec is its reciprocal.

Benchmark Unit ns/op ops/sec
heap_next_actor, empty pool call 4.0 248,392,158
heap_next_actor, one slot left call 36.9 27,077,696
heap string claim + release cycle cycle 49.8 20,084,967
heap_next_string, empty pool call 3.9 255,059,748
heap_next_string, one slot left call 250.9 3,986,007
heap_release_string, 4 KiB wipe call 47.2 21,191,918
heap_init, all five pools call 45,141 22,153
actor spawn + release, library logging on actor 202.3 4,942,862
actor spawn + release, logging suppressed actor 162.5 6,153,324
akgl_actor_set_character, registry lookup call 39.3 25,438,092
akgl_set_property call 96.9 10,316,516
akgl_get_property, 4 KiB copy call 85.3 11,722,134
akgl_character_sprite_get, state to sprite call 37.4 26,734,605
akgl_actor_update, animation advancing actor 68.4 14,609,892
akgl_actor_update, no sprite for state actor 616.5 1,621,937
akgl_physics_simulate, 64 live actors frame 1,216.8 821,841
akgl_physics_simulate, empty pool frame 63.9 15,650,101
logic frame: 64 updates + simulate frame 5,763.1 173,517
akgl_rectangle_points call 4.0 248,412,026
akgl_collide_rectangles, overlapping call 6.1 164,345,776
akgl_collide_rectangles, disjoint call 6.1 164,423,708
control: all-pairs collision sweep, 64 actors (2016 pairs) sweep 11,769.1 84,968
akgl_string_initialize call 32.1 31,197,432
akgl_string_copy, full length call 32.2 31,075,004
json_load_file, small document load 11,338.8 88,193
akgl_get_json_string_value call 40.7 24,552,958
akgl_get_json_integer_value call 13.8 72,704,562
akgl_path_relative, realpath on an existing file call 3,481.5 287,232

Results: everything that draws

tests/perf_render.c, full scale, 640x480 software renderer. The two control rows are raw SDL_RenderTexture loops with no libakgl in the path.

Benchmark Unit ns/op ops/sec
frame_start + frame_end (clear + present) frame 23,161.7 43,175
akgl_draw_point call 132.5 7,547,141
akgl_draw_line, screen diagonal call 605.9 1,650,375
akgl_draw_rect, 200x150 outline call 520.8 1,920,055
akgl_draw_filled_rect, 200x150 call 43,695.1 22,886
akgl_draw_circle, radius 64 call 2,516.2 397,423
akgl_draw_copy_region, 64x64 readback call 954.7 1,047,471
akgl_draw_paste_region, 64x64 upload call 5,352.1 186,841
akgl_draw_flood_fill, full 640x480 target call 1,967,295.6 508
akgl_text_measure, 15 characters call 37.3 26,804,657
akgl_text_rendertextat, 15 characters call 12,601.7 79,354
akgl_sprite_load_json, sheet already loaded load 17,011.3 58,784
akgl_character_load_json, two mappings load 14,506.8 68,933
akgl_tilemap_load + release, fixture map load 11,881,190.8 84
zeroing one akgl_Tilemap call 1,372,396.7 729
akgl_tilemap_compute_tileset_offsets, 1728 tiles call 2,240.8 446,264
akgl_tilemap_draw, 40x30 tiles, 1 tileset frame 16,260,566.6 61
akgl_tilemap_draw, 40x30 tiles, 8 tilesets frame 16,395,182.4 61
control: raw SDL blits, one source tile frame 471,676.6 2,120
control: raw SDL blits, map order frame 16,229,501.0 62
akgl_actor_render, on camera actor 2,992.0 334,220
draw_world, 1200 tiles + 64 actors frame 16,484,493.6 61
akgl_game_update, full frame frame 16,576,902.2 60

The akgl_game_update row is from before the sixteen-times-per-actor defect was fixed in 0.5.0; see below. It sat about 92 µs above draw_world in the same run, and now sits level with it. The absolute figures in this table have not been re-taken -- a later run on this machine read every row about 15% high, including rows nothing has touched, so the table is a record of one machine state and the honest measurement of that fix is the gap between two rows of the same run, not a new absolute.

The frame budget

At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a full screen of 16-pixel tiles and 64 actors:

Part of the frame Cost Share of 16.67 ms
Logic: 64 actor updates + one physics sweep 0.006 ms 0.03%
Collision over 64 actors, all-pairs, as a control 0.012 ms 0.07%
Clear + present 0.023 ms 0.1%
64 actor renders (48x48 blits) 0.191 ms 1.1%
1200 tile blits 16.26 ms 97.6%
Six lines of HUD text 0.076 ms 0.5%

Everything libakgl decides is free. The pixels are the whole frame. Every piece of bookkeeping this library does — pool scans, registry lookups, state-to- sprite mapping, physics, the error-context machinery — adds up to well under 1% of a frame that is 97% software rasterization. On a GPU backend those blits get cheap and libakgl's own share rises, which is exactly why the per-operation numbers above matter more than the frame totals.

What the numbers say

The tilemap draw is SDL, not libakgl — and I can prove it

akgl_tilemap_draw takes 16.26 ms for a 1200-tile screen. A raw SDL_RenderTexture loop issuing the same 1200 blits from the same scattered source tiles takes 16.23 ms. The library's own per-tile work — the bounds arithmetic, the tileset scan, the offset-table lookup, the backend indirection, the error macros — is 0.03 ms per frame, under 0.2%.

That took three attempts to measure honestly. A control that walked the sheet sequentially said libakgl cost 67%; a control that blitted one source tile over and over said it cost 3400%. Both were wrong, and both were wrong the same way: they changed the memory access pattern of the source texture rather than isolating the library. A 16x16 tile read from a random place in a 768x576 sheet costs about 13 µs on this software rasterizer; the same tile read from cache costs 0.4 µs. That factor of thirty is the whole story, and none of it is libakgl's.

Related: the FIXME in src/tilemap.c worrying that the per-tile tileset scan "is probably not very efficient" is, at eight tilesets, worth 0.8% of the frame (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it should still be fixed, but it is not where the time is, and nobody should reorganise the loader for it.

The pools are linear scans, and only the string pool cares

Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is 36.9 ns — nine times the cost, and still nothing.

The string pool is the exception, and it is instructive. Claiming from an empty string pool is 3.9 ns; claiming the last free slot is 250.9 ns, 64 times slower. Same algorithm, same 256-ish entries. The difference is that each akgl_String is PATH_MAX + 4 bytes, so the scan touches one reference count every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is a megabyte, and walking it is walking a megabyte.

akgl_heap_release_string costs 47.2 ns because it memsets all 4,100 bytes whether the string held a path or one character. Same for akgl_string_copy (32.2 ns) and akgl_get_property (85.3 ns), which move AKGL_MAX_STRING_LENGTH bytes unconditionally. None of these is expensive in isolation; all of them are the same avoidable habit of paying for PATH_MAX when you used eleven bytes.

Errors cost about ten times what success costs

akgl_actor_update on an actor whose character has a sprite for its state: 68.4 ns. The same call on an actor whose character does not: 616.5 ns.

That path is not an error in any meaningful sense — the library 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. But raising it means claiming a context out of AKERR_ARRAY_ERROR, formatting a message with vsnprintf, appending a stack-trace frame, walking the PROCESS switch, and releasing it again. Nine times the cost of the update it replaced.

The design conclusion is not "make errors cheaper". It is that a routine condition should not be reported as an error. A character that has no sprite for a state should answer that question with a boolean.

akgl_game_update updated every actor sixteen times — fixed in 0.5.0

akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep nested inside it, and never compared an actor's layer to the layer it was on. Every live actor's updatefunc ran 16 times per frame: at 68.4 ns per update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the point, every piece of a game's own per-frame actor logic running sixteen times.

The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer operation; drawing is, and akgl_render_2d_draw_world walks the layers itself. AKGL_ITERATOR_OP_LAYERMASK is honoured now rather than ignored, so a caller that genuinely wants one layer can still ask for it and gets each of those actors once.

How it was measured. tests/game.c counts calls into a stub updatefunc and asserts exactly one per live actor per akgl_game_update; against the old code it reports 16. The timing side is the gap between the akgl_game_update and draw_world rows of the same benchmark run: 92 µs before, and across three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.

It was invisible in the frame total because the tilemap blits are three orders of magnitude larger, which is exactly why it needed a counting test rather than a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the frame doing nothing.

Text has no cache at all

akgl_text_rendertextat is 12.6 µs for fifteen characters: it rasterizes the string, uploads it as a texture, blits it, and destroys the texture — every call, every frame, for a score that changes once a second. Measuring the string first with akgl_text_measure is 37.3 ns, i.e. free, which tells you the whole cost is the rasterize-and-upload.

Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour) would take it to nothing, and it is the single clearest optimisation in the library.

Loading is dominated by things that are not the file

A 2x2 fixture map with one tileset takes 11.9 ms to load and release. Of that, 1.37 ms — 11.5% — is memseting the 26 MB akgl_Tilemap before anything is read. Most of the rest is decoding the tileset PNG. The JSON is noise: parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.

akgl_path_relative is 3.5 µs, because it is a realpath(3) syscall. A map naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall and not a string operation.

Collision, measured

Added in 0.8.0. Every row below is from one run, on the machine described above.

Operation Unit ns per unit per second
narrowphase box/box, overlapping call 20.6 48,485,173
narrowphase box/box, disjoint call 9.0 111,102,445
narrowphase circle/circle, overlapping call 67.8 14,752,779
grid move, proxy has not left its cells actor 11.5 86,944,486
grid query, 64 actors query 43.2 23,155,384
bsp query, 64 actors query 113.6 8,802,193
tile query, actor standing on a floor query 15.6 64,142,653

Three of those are worth reading rather than skimming.

The box fast path is two to seven times cheaper than the general solver, and the disjoint case is cheaper still because the proxies' bounds reject it before any shape arithmetic runs. A tile game is almost entirely box against box, so 9-20 ns describes a real frame and 67.8 is what a circle costs when a game asks for one.

The grid beats the tree by 2.6x on the same population. That is the argument for which one is the default, measured here rather than cited from somebody else's engine. The tree also rebuilds whenever a proxy moves and that cost is not in its row, so the gap in a scene that is actually moving is wider than 2.6x.

A move that changes nothing costs 11.5 ns. That is the incremental claim in one number: an actor walking across a tile pays that on most frames and pays the re-cell only when it crosses a boundary.

Two rows moved, and not because collision is running

akgl_physics_simulate over 64 live actors went from 1,216.8 ns to 1,588.5, and the logic frame moved with it -- on a backend with no collision world attached, so no collision code runs in either. Two things account for it and neither is the feature doing its job:

  • akgl_Actor grew from 415 to 464 bytes, gaining a 40-byte collision shape, an override flag and a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more working set streamed every frame. The empty-pool row is unchanged at 58.7 ns, which is what says this is per live actor rather than per slot.
  • Reaching the collision check through CATCH cost more than the check. PASS and CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so a function that early-returns is not free when it is reached through one. Routing the no-collision path around the machinery entirely took this row from 2,018.7 ns back to 1,588.5 -- the same lesson AGENTS.md records for writing benchmarks, arriving in production code.

What is left is the struct, and it is paid whether or not a game uses collision. That is worth knowing rather than smoothing over.

The collision helpers are fine; the missing broad phase is the problem

akgl_collide_rectangles is 6.1 ns whether the rectangles overlap or not. It was 24.9 ns overlapping and 57.9 ns disjoint until 0.8.0, when it stopped being eight akgl_collide_point_rectangle calls and became four comparisons -- a change made to fix the cross case it could not see, with the speed as a side effect rather than the goal. The disjoint case improved most because it was the one that ran all eight tests before answering.

Read that 6.1 as a floor, not a measurement. akgl_rectangle_points is untouched by that change and reads 6.1 in the same run against the 4.0 recorded above, so this run's resolution is around 6 ns and the collision test is now too cheap to distinguish from a function that does almost nothing. The rows above are not re-baselined for it; only the three rows the change actually moved are.

What the library does not provide is a broad phase, so a caller that wants collision writes the all-pairs loop: 2016 pairs for 64 actors, 12 µs a frame, 0.07% of a 60 fps budget. That is affordable. It is still O(n²): raise AKGL_MAX_HEAP_ACTOR to 256 and the same loop is 32,640 pairs, which at this per-pair cost is around 200 µs — 1.2% of the frame, for a game that has done nothing yet.

Spawning is cheap, and a third of it is a log line

An actor spawn — pool claim, memset, registry insert, release — is 202.3 ns with the library's logging on and 162.5 ns with SDL's log priority raised so the message is never formatted. 20% of a spawn is formatting a log line nobody reads, and that is with output going to a sink that throws it away; write it to a terminal and it is far worse. akgl_actor_initialize and akgl_character_sprite_add both log unconditionally at INFO.

How other engines spend the same frame

The natural question after a baseline like this is how it compares to the engines people actually ship 2D games on. The comparison below is against Construct (2 and 3) and Phaser (3 and 4), because both publish real engineering detail: Ashley Gullen's Construct tech blogs and the Phaser docs and source say what they built, what they rejected, and — most usefully — what they measured. Godot and GameMaker publish similar batching guidance and add nothing the other two don't. Sources are at the end of the section; the construct.net posts are Cloudflare-walled to non-browsers and were verified through Wayback captures.

One caveat governs the whole comparison, so it goes first. Every number these engines publish assumes the pixels are on a GPU. Construct's ~300 CPU cycles per sprite and Phaser's 16,384-quad batch ceiling are budgets for building command lists, not for touching pixels. Two things follow:

  • Ashley's argument that a frame costs max(logic, render) — because the CPU builds commands while the GPU rasterizes the previous batch — inverts under a software renderer. Here the frame really is logic plus render, which is why 97.6% of ours is blits.
  • What transfers from their playbooks is the culling and the caching: camera culls, dirty-flagged text textures, collision cells. What does not transfer is the batching, and the control rows above already proved it: libakgl's per-tile submission overhead is 0.2% of the frame. Batching is a solution to a problem this library has measured itself not to have — and SDL's renderer batches internally anyway.

Tilemaps: they optimize draw calls; ours cost 0.2%

Construct 2 merged runs of identical tiles into rectangles and drew each rectangle in one call — 120 draw calls down to 22 in their worked example — and the Construct 3 runtime renders an entire tilemap layer with zero texture switches, which they measured at 40x over C2 when every visible tile differs. Phaser culls tiles to the camera every frame (one-tile padding by default), and Phaser 4 added an opt-in TilemapGPULayer that draws a whole layer as a single shader quad at a fixed cost per screen pixel, capped at 4096x4096 tiles.

All of that machinery exists to cut draw-call submission cost. libakgl's equivalent cost is the 0.03 ms gap between akgl_tilemap_draw and the raw-SDL control row. The 16.26 ms is rasterization, which none of those techniques touch — a merged rectangle still fills the same pixels in software. The one tilemap idea worth keeping in the back pocket is the one Construct rejected in 2013 on simplicity grounds and Phaser shipped in 2026: push the whole layer to the GPU. That is a backend decision, not a loop optimization, and the per-tile bookkeeping measured above (~25 ns) is what would remain of the frame when it happens.

Sprites: their per-sprite budget and ours are the same order

Construct measured its C3 runtime at roughly 300 CPU cycles to render one sprite — about 65 ns on this machine's clock — and got there by replacing polymorphic dispatch with monomorphic code, which they measured as 70-88% faster on their quad benchmark. Phaser batches 16,384 quads at a time and multi-texture batching turned 210 sprites across 15 textures from 212 draw operations into 2. libakgl's actor render bookkeeping is ~250 ns by subtraction (target 3), through one draw_texture function pointer per actor with an error scope per call. Same order of magnitude, no batch layer, and at 64 actors the whole sweep is 1.1% of the frame. Their lesson that dispatch indirection is where per-sprite CPU goes is worth remembering if the actor ceiling ever rises by an order of magnitude; at 64 it is noise.

Text: everyone else has the cache

Phaser's Text object re-rasterizes its canvas and re-uploads the texture only when the content or style changes; its BitmapText never pays even that — their docs state "you don't incur any speed penalty when updating their content because the underlying texture doesn't change." Construct's Text plugin documents the same failure mode this suite measured: automatic- resolution mode "can cause the text to constantly re-render when being smoothly scaled," and fixed-resolution mode is the documented escape hatch. Both engines treat re-rasterizing an unchanged string as a defect. libakgl does it unconditionally, every call, every frame: 12.6 µs a string. The dirty-flag cache in targets 8 and 9 is not novel engineering; it is the industry floor.

Collision: Construct already ran this experiment

Construct's brute-force collision at 1000-vs-1000 objects was about a million checks per tick and ran at 10 fps on a desktop; their viewport-sized uniform collision cells cut that to ~40,000 checks — a 96% reduction, six times faster — with incremental insert/remove as objects move and zero cost for static objects. They evaluated quadtrees and rejected them as "more complicated and can be more expensive to update as objects move around." Phaser went the other way: Arcade Physics keeps dynamic bodies in an RTree that is cleared and rebuilt every frame, and their own docs say a "conservative estimate of around 5,000 bodies should be considered the max" before turning the tree off beats keeping it.

libakgl has no broad phase at all, and at the current 64-actor ceiling it does not need one: the all-pairs sweep is 115 µs, 0.7% of the frame. The comparison settles which broad phase fits when the ceiling rises — the incremental uniform grid, which is allocation-free at steady state and simple enough that its authors chose it over the fancier structure, not the rebuild-every-frame tree. The design is on record in TODO.md under Performance -> The plan.

Memory: stricter than either, and paying for it wrong

Construct budgets memory layout-by-layout — only the current layout's images are resident, every image is width x height x 4 bytes regardless of source format, and peak memory is the worst single layout. Their create/destroy churn is a first-class engine metric (5x faster in the C3 runtime), and their anti-jank advice — place one instance in the layout so its texture is loaded, then destroy it at start — is a warm-the-pool idiom. Phaser's pooling is Group objects with maxSize as a hard cap, recycling members by toggling active. Both engines converge on what libakgl starts from: fixed budgets and recycled objects.

The difference is what the budget buys. Their costs scale with what a scene uses; libakgl's scale with what the build allows — 28 MB of BSS with 94% in one worst-case-sized tilemap, 4 KiB string wipes for eleven-byte strings, pool scans that walk the ceiling rather than the population. The static-pool discipline is right, and stricter than either engine. Targets 6, 7, 14 and 15 are all the same correction: keep the fixed ceiling, stop paying for it per operation.

The frame split: the same shape as 2012

Ashley wrote in 2012 that "it's not unusual for even a fairly complex game to spend 10% of the time on the logic, and 90% of the time on rendering — even when hardware accelerated!" That is a stated profiling experience from the canvas era, with no published methodology, and it is cited here as exactly that and nothing more; Phaser publishes no frame-composition profile at all, and no official Phaser bunnymark number exists to borrow (third-party figures float around; none are cited here). libakgl's measured 97.6% blits against under 1% bookkeeping is the same shape, more extreme, because software rasterization is slower than a GPU and the logic is C rather than JavaScript. Both lead to the conclusion already in this document: the pixels are the whole frame, and the per-operation bookkeeping numbers are what matter the day a GPU backend makes the pixels cheap.

Sources

Defects these tests found

Six, and they are filed where defects live: TODO.md, under Performance -> Defects the perf suites found, items 28-33, each with its file, line, functional consequence, and what fixing it would touch. They are not repeated here.

Worth saying in this document, because it is the argument for having written the suites at all: stress testing breaks things unit tests do not reach. Two of the six are process-killing — an error-context leak that aborted on the 129th path resolution (fixed, with a regression test), and a pooled-string leak that turns into a segfault rather than an AKGL_ERR_HEAP around the 52nd map load. Neither is reachable by a suite that calls each function a handful of times, and neither had anything to do with speed. They came out of loops that ran the same call ten thousand times in one process, which is what a running game does and what nothing else in this tree did.

What this report does not cover

  • One machine. No ARM, no Raspberry Pi, no Windows, no macOS. The ratios should travel; the absolute numbers will not.
  • No GPU backend. Everything drawing-related is a software rasterizer under the dummy video driver. The share of a frame that belongs to libakgl is a floor, not an estimate.
  • No audio. src/audio.c is not benchmarked at all; the mixer under the dummy driver does not do the work a real device would.
  • No controller input. The event path is driven by SDL and has no gamepad to drive it under the dummy driver.
  • No heap profiling beyond the static footprint. Nothing here measures peak or steady-state heap usage. Correctness of the heap is covered now, by cmake --build build --target memcheck, which runs these same binaries under valgrind — tests/benchutil.h cuts the iteration counts by three orders of magnitude when it finds itself there, so a benchmark becomes a path-coverage program. That run found six more defects, including a leak of the parsed JSON document in every asset loader; they are in TODO.md under Memory checking. What is still missing is a profile: how much a running game actually holds at once, and how that grows over an hour.
  • Single-threaded throughout. The library is not thread-safe by design (akgl_game_state_lock guards one field), and nothing here tests contention.