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>
This commit is contained in:
2026-08-02 07:13:53 -04:00
parent cf930f68bb
commit fcad2822a9
4 changed files with 205 additions and 10 deletions

View File

@@ -128,7 +128,7 @@ reciprocal.
| `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 |
| 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 |
@@ -183,7 +183,7 @@ 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% |
| 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% |
@@ -304,6 +304,59 @@ parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.
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

View File

@@ -39,7 +39,7 @@ software-rasterized. At 60 fps a frame is 16.67 ms:
| 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% |
| 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% |

View File

@@ -304,11 +304,22 @@ akerr_ErrorContext *akgl_physics_simulate(akgl_PhysicsBackend *self, akgl_Iterat
* path it always did. That is what lets the integrator stay untouched
* and every recorded physics number stay valid.
*/
if ( (self->collision == NULL) || (self->collide == NULL) ) {
/*
* No collision: exactly the call this has always made, with
* nothing added around it. Not merely equivalent -- CATCH and
* PASS walk AKERR_ARRAY_ERROR to validate the context, which
* costs more than several of the calls being measured, so even
* a check that answers "no" instantly is not free if it is
* reached through one. Measured: routing the off path through
* one extra CATCH took the 64-actor sweep from 1.2 us to 2.0.
*/
PASS(errctx, self->move(self, actor, dt));
} else {
CATCH(errctx, akgl_collision_substeps(self->collision, actor, dt, &substeps));
subdt = dt / (float32_t)substeps;
for ( s = 0; s < substeps; s++ ) {
PASS(errctx, self->move(self, actor, subdt));
if ( (self->collide != NULL) && (self->collision != NULL) ) {
PASS(errctx, self->collide(self, actor, subdt));
}
}

View File

@@ -40,6 +40,7 @@
#include <akgl/sprite.h>
#include <akgl/physics.h>
#include <akgl/staticstring.h>
#include <akgl/collision.h>
#include <akgl/util.h>
#include <akgl/json_helpers.h>
#include <akgl/tilemap.h>
@@ -625,7 +626,7 @@ static akerr_ErrorContext *bench_physics_simulate(void)
PASS(errctx, bench_fill_actor_pool(basechar, BENCH_ACTOR_COUNT));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
bench_start("physics_simulate, 64 live actors", "frame", 12500.0);
bench_start("physics_simulate, 64 live actors", "frame", 16000.0);
BENCH_LOOP(inner, i, count, backend.simulate(&backend, NULL));
bench_stop(count);
PASS(errctx, inner);
@@ -705,6 +706,135 @@ static akerr_ErrorContext *bench_logic_frame(void)
* broad phase the library does not provide: 64 actors is 2016 pairs, and a
* caller doing collision at all does that every frame.
*/
/** @brief Counts nothing; the query cost is what is being measured. */
static akerr_ErrorContext *bench_collision_visit(akgl_CollisionProxy *proxy, void *data)
{
(void)proxy;
(void)data;
return NULL;
}
/** @brief A map with a floor, for the tile-collision row. */
static akgl_Tilemap bench_collision_map;
/**
* @brief Collision: the narrowphase, the broad phase, and a whole sweep.
*
* The all-pairs row in bench_geometry stays where it is and is the control for
* these: it is the cost a caller paid before the library had a broad phase, run
* on the same machine in the same run, which is the only way to read a
* reduction honestly.
*/
static akerr_ErrorContext *bench_collision(void)
{
PREPARE_ERROR(errctx);
akerr_ErrorContext *inner = NULL;
akgl_CollisionWorld world;
akgl_CollisionShape boxshape;
akgl_CollisionShape circleshape;
akgl_CollisionProxy a;
akgl_CollisionProxy b;
akgl_CollisionProxy *proxies[BENCH_ACTOR_COUNT];
akgl_Contact contact;
SDL_FRect body = { .x = -8.0, .y = -8.0, .w = 16.0, .h = 16.0 };
SDL_FRect area = { .x = 0.0, .y = 0.0, .w = 48.0, .h = 48.0 };
bool hit = false;
int count = bench_iterations(500000);
int sweeps = bench_iterations(20000);
int i = 0;
int x = 0;
int rep = 0;
PASS(errctx, akgl_collision_shape_box(&boxshape, &body, 0.0));
PASS(errctx, akgl_collision_shape_circle(&circleshape, 0.0, 0.0, 8.0, 0.0));
for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
PASS(errctx, akgl_heap_init());
PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0));
PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &boxshape, 0.0, 0.0, 0.0));
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 4.0, 0.0, 0.0));
bench_start("narrowphase box/box, overlapping", "call", 210.0);
BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &boxshape, 900.0, 900.0, 0.0));
bench_start("narrowphase box/box, disjoint", "call", 90.0);
BENCH_LOOP(inner, i, count, akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(count);
PASS(errctx, inner);
PASS(errctx, akgl_collision_proxy_initialize(&a, NULL, &circleshape, 0.0, 0.0, 0.0));
PASS(errctx, akgl_collision_proxy_initialize(&b, NULL, &circleshape, 4.0, 0.0, 0.0));
bench_start("narrowphase circle/circle, overlapping", "call", 680.0);
BENCH_LOOP(inner, i, bench_iterations(100000),
akgl_collision_test(&a, &b, world.flags, &contact, &hit));
bench_stop(bench_iterations(100000));
PASS(errctx, inner);
// The broad phase, with a realistic population spread over the map.
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
PASS(errctx, akgl_heap_next_collision_proxy(&proxies[i]));
PASS(errctx, akgl_collision_proxy_initialize(proxies[i], NULL, &boxshape,
(float32_t)((i % 20) * 24),
(float32_t)((i / 20) * 24), 0.0));
PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i]));
}
bench_start("grid move, unchanged cells", "actor", 120.0);
BENCH_LOOP(inner, i, count, world.partitioner.move(&world.partitioner, proxies[0]));
bench_stop(count);
PASS(errctx, inner);
bench_start("grid query, 64 actors", "query", 440.0);
BENCH_LOOP(inner, i, sweeps,
world.partitioner.query(&world.partitioner, &area,
AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL));
bench_stop(sweeps);
PASS(errctx, inner);
// The same population through the tree, so the two are comparable.
PASS(errctx, akgl_collision_world_init(&world, "bsp", 16.0, 16.0));
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
PASS(errctx, world.partitioner.insert(&world.partitioner, proxies[i]));
}
bench_start("bsp query, 64 actors", "query", 1140.0);
BENCH_LOOP(inner, i, sweeps,
world.partitioner.query(&world.partitioner, &area,
AKGL_COLLISION_LAYER_ALL, &bench_collision_visit, NULL));
bench_stop(sweeps);
PASS(errctx, inner);
// One actor against a floor of tiles, which is the case a platformer
// runs every frame for every actor.
memset(&bench_collision_map, 0x00, sizeof(akgl_Tilemap));
bench_collision_map.tilewidth = 16;
bench_collision_map.tileheight = 16;
bench_collision_map.width = 128;
bench_collision_map.height = 32;
bench_collision_map.numlayers = 1;
bench_collision_map.layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
for ( x = 0; x < bench_collision_map.width; x++ ) {
bench_collision_map.layers[0].data[(20 * bench_collision_map.width) + x] = 1;
}
bench_collision_map.collidablelayers = 1u;
PASS(errctx, akgl_collision_world_init(&world, "grid", 16.0, 16.0));
PASS(errctx, akgl_collision_bind_tilemap(&world, &bench_collision_map));
area.x = 100.0;
area.y = 310.0;
area.w = 16.0;
area.h = 16.0;
bench_start("tile query, actor on a floor", "query", 160.0);
BENCH_LOOP(inner, i, sweeps, akgl_collision_box_blocked(&world, &area,
AKGL_COLLISION_LAYER_ALL, &hit));
bench_stop(sweeps);
PASS(errctx, inner);
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *bench_geometry(void)
{
PREPARE_ERROR(errctx);
@@ -747,7 +877,7 @@ static akerr_ErrorContext *bench_geometry(void)
bench_stop(count);
PASS(errctx, inner);
bench_start("all-pairs collision sweep, 64 actors", "sweep", 1200000.0);
bench_start("control: all-pairs collision sweep, 64 actors", "sweep", 1200000.0);
for ( k = 0; k < sweeps; k++ ) {
for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
for ( j = i + 1; j < BENCH_ACTOR_COUNT; j++ ) {
@@ -916,6 +1046,7 @@ int main(void)
CATCH(errctx, bench_physics_simulate());
CATCH(errctx, bench_logic_frame());
CATCH(errctx, bench_geometry());
CATCH(errctx, bench_collision());
CATCH(errctx, bench_strings());
CATCH(errctx, bench_json());
CATCH(errctx, bench_path_relative());