It asked whether either rectangle enclosed one of the other's four corners -- eight akgl_collide_point_rectangle calls, stopping at the first hit. That is a different question from "do these overlap", and it has the wrong answer for one arrangement: a tall thin rectangle crossing a short wide one overlaps in a plus sign with no corner of either inside the other, and all eight tests said no. A long thin platform crossing a tall thin character is exactly that shape, so this is a shape a 2D game produces, not a curiosity. util.h carried an @note describing it and docs/18-utilities.md had a diagram of it, both under the heading of a limitation rather than a defect, and there was no test for it at all -- nor for full containment, nor for a shared edge. It is four comparisons now, on both axes. `<=` rather than `<` because akgl_collide_point_rectangle is inclusive on all four edges and these two have always agreed that touching counts; a span test written with `<` would have silently changed a contract both the header and the manual state. The test was written first and failed on the cross before the fix went in. Two answers change for a caller upgrading, and both are in the header note and the chapter: - The cross reports `true`, which is the point. - The comparison is in float rather than through akgl_Point's int members, so a sub-pixel overlap is no longer truncated away. A pickup test that was accidentally forgiving by up to a pixel is no longer forgiving. Both tutorials use this for coins and hazards; both still pass. Faster as a side effect rather than a goal, and worth recording because the numbers move a documented budget: 24.9 ns -> 6.1 overlapping, 57.9 -> 6.1 disjoint, and the all-pairs sweep over 64 actors 115 us -> 12.2. The disjoint case gained most because it was the one that ran all eight tests before answering. The three moved rows are re-recorded in PERFORMANCE.md and nothing else is. akgl_rectangle_points is untouched by this change and reads 6.1 in the same run against the 4.0 recorded, so 6 ns is this run's floor and the new figure means "too cheap to measure" rather than "exactly 6.1" -- said in the prose so the next reader does not re-baseline the table around it. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
525 lines
30 KiB
Markdown
525 lines
30 KiB
Markdown
# 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 |
|
|
| all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 12,241.6 | 81,688 |
|
|
| `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% |
|
|
| All-pairs collision over 64 actors, if you do it | 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.
|
|
|
|
### 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.
|