# 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 ```sh 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 `memset`s 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 `memset`ing 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 | | **a whole physics step with collision, 64 actors** | frame | 15,420.1 | 64,851 | 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. #### The whole step, which is the number that matters **15.4 µs for 64 actors, of which 1.6 µs is the step without collision.** So collision costs about 13.8 µs a frame at the default pool size — the proxy sync pass, the broad phase, the narrowphase over whatever it returns, and the response — which is **0.08% of a 16.67 ms frame**. The all-pairs control over the same population is 12.1 µs, and it answers a strictly smaller question: it tests pairs and does not sync, sub-step or resolve anything. At `-DAKGL_MAX_HEAP_ACTOR=256`, a separate build measured on the same machine: | Operation | Unit | ns per unit | |---|---|---:| | a whole physics step with collision, 256 actors | frame | 54,073.3 | | all-pairs control over the same 256 actors (32,640 pairs) | sweep | 188,530.6 | **54.1 µs against Target 12's 2 ms budget**, with the all-pairs control 3.5x more expensive while still doing less. Population went up 4x and the step went up 3.5x, which is the grid doing its job: the all-pairs sweep went up 15.6x over the same range, as `n²` says it must. That measurement is from a `AKGL_BENCH_SCALE=0.3` run of a purpose-built configuration and is not part of the CI baseline; the 64 row above is. ### 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 - Ashley Gullen, [How the Construct 2 WebGL renderer works](https://www.construct.net/en/blogs/ashleys-blog-2/construct-webgl-renderer-works-917) (2014) - Ashley Gullen, [Tech blog: Tilemap tidbits](https://www.construct.net/en/blogs/ashleys-blog-2/tech-blog-tilemap-tidbits-913) (2013) - Ashley Gullen, [Collision cell optimisation in r155](https://www.construct.net/en/blogs/ashleys-blog-2/collision-cell-optimisation-914) (2013) - Ashley Gullen, [How render cells work](https://www.construct.net/en/blogs/ashleys-blog-2/render-cells-work-921) (2014) - Ashley Gullen, [Optimisation: don't waste your time](https://www.construct.net/en/blogs/construct-official-blog-1/optimisation-dont-waste-time-768) (2012) - Scirra, [Announcing the Construct 3 runtime](https://www.construct.net/en/blogs/construct-official-blog-1/announcing-construct-runtime-904) (2018) - Construct 3 manual: [Performance tips](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/performance-tips), [Memory usage](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/memory-usage), [Text](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/text), [Sprite Font](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/sprite-font) - Phaser, [Phaser 4 rendering concepts](https://phaser.io/tutorials/phaser-4-rendering-concepts) - Phaser, [How to render thousands of sprites in Phaser 4](https://phaser.io/news/2026/05/phaser4-spritegpulayer-performance) - Phaser, [Advanced rendering tutorial, part 2: multi-texture batching](https://phaser.io/tutorials/advanced-rendering-tutorial/part2) - Phaser docs: [Text](https://docs.phaser.io/phaser/concepts/gameobjects/text), [BitmapText](https://docs.phaser.io/phaser/concepts/gameobjects/bitmap-text), [Group](https://docs.phaser.io/phaser/concepts/gameobjects/group) - Phaser source: [physics/arcade/World.js](https://github.com/phaserjs/phaser/blob/master/src/physics/arcade/World.js), [tilemaps/components/CullTiles.js](https://github.com/phaserjs/phaser/blob/master/src/tilemaps/components/CullTiles.js), [core/Config.js](https://github.com/phaserjs/phaser/blob/master/src/core/Config.js) ## 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.