Skip to content

Latest commit

 

History

History
1335 lines (1130 loc) · 65.8 KB

RESULTS.md

File metadata and controls

1335 lines (1130 loc) · 65.8 KB

The bake-off

The reference engines on identical scenes, on one machine, one thread, each at its own recommended settings for a game's 60 Hz step. What decided which design this engine follows.

Machine: Intel Core i7-13700K, Windows 11, GCC 14 (msys2 ucrt64), 2026-09-19. The GPU was busy with a game at the time; these are CPU numbers.

Builds: Box3D f555ee4 (Release, SSE2, validation off); Jolt v5.3.0 (Distribution: LTO, AVX2/FMA, no profiler, no debug renderer -- its shipping configuration). Box3D's build here has neither AVX nor LTO, so if anything the table favours Jolt.

Settings: a 1/60 s step. Each engine at its own recommended setting first -- Box3D: 4 sub-steps (its own benchmark setting); Jolt: 1 collision step, 10 velocity + 2 position iterations (its defaults) -- and then each at the other's budget: Jolt at 4 collision steps, Box3D at 16 sub-steps. Sleeping off on both, so every body is simulated every step. Timing excludes the first step (the structures are built there). The machine was also running a game, so the times wander by about 20% between runs; the ratios do not.

Each at its recommended setting

scene bodies Box3D 4 sub-steps Jolt 1 step how it held
pyramid, 100 rows of unit boxes 5,050 9.6 ms/step 44.5 Box3D: worst drift 0.79 m after 300 steps, 0.72 m after 1,000 (the top wobbles, the stack stands). Jolt: 17.5 m after 300, 102 m after 1,000 -- the pyramid collapses.
pile, 20 x 25 x 20 unit boxes dropped 2 m 10,000 10.2 42.5 lowest box after 300 steps: Box3D 0.498 m (2 mm into the ground), Jolt 0.473 m (27 mm)
chain, a 100 x 100 grid of spheres on point joints, hung from its top row 10,000 bodies, 19,800 joints 11.1 11.3 sag of the bottom corner below a taut 99-link chain: Box3D 0.94 m, Jolt 1.97 m

Each given the other's budget

scene Jolt 4 steps Box3D 16 sub-steps how it held
pyramid 157 ms/step 28.2 Jolt now stands: drift 0.05 m. Box3D 0.68 m (its contacts are soft, 30 Hz by default; see below).
pile 60.8 24.4 Jolt 0.4995 m (0.5 mm in), Box3D 0.498 m (2 mm)
chain 42.8 39.1 Jolt 0.094 m. Box3D 0.79 m at its default 60 Hz joints; 0.047 m with the joints set to 240 Hz (BAKE_JOINT_HERTZ=240), same 39 ms.

scripts/bake.sh 300 reproduces both tables; bench/bake_box3d.c and bench/bake_jolt.cpp are the scenes.

Reading it

  • At its recommended setting Box3D's Soft Step solver is four times faster than Jolt's sequential impulse solver on the contact-heavy scenes, and it is the one that holds a tall stack at that speed: sub-stepping with soft constraints converges where iterating a single step does not.
  • Given four times the collision steps Jolt holds everything too, at four to sixteen times Box3D's cost. Box3D at sixteen sub-steps -- about a third to two-thirds of Jolt's 4-step time -- matches or beats it on every scene.
  • The residual drift and sag in Box3D are its soft constraints, not a convergence limit: the joint stiffness is a per-joint hertz (default 60 Hz, capped at a quarter of the sub-step rate), and raising it with the sub-step count took the chain's sag from 0.79 m to 0.047 m at no extra cost. The same knob exists for contacts (contactHertz, 30 Hz). This is the design working as intended: stiffness is a choice made per scene, bounded by the sub-step rate, rather than an iteration count.
  • Both scale across threads (Box3D's own records show 7x at 8 threads on a 7950X; Jolt is known to scale); the port is single-threaded first, so the single-thread numbers are the ones that matter for it.

Not measured: PhysX 5 (too heavy to build for a bake-off tick; GPU-first) and Bullet (an older sequential-impulse design without sub-stepping, the collision library under GTA IV/V's RAGE -- Euphoria sat on it, but the solver is not where the field is today). Rapier is Rust and its idioms would port worst.

The decision

The engine follows Box3D's design: Soft Step (sub-stepped, soft constraints, relax iterations, restitution pass), graph-coloured constraint islands, speculative contacts with continuous collision, island-based sleep, convex hulls / capsules / spheres / triangle meshes / height fields, a dynamic-tree broad phase, and cross-platform determinism. Jolt stays as the second baseline in this table.

Layer by layer

Each layer of aephysics against the same code in the reference, on this machine, one thread. scripts/bench.sh builds and runs every pair.

dynamic_tree

bench/tree.ae and bench/tree_box3d.c: 10,000 boxes (1 m, in a 200 m cube) inserted one by one, 100 full rebuilds, 100,000 box queries (2 m), 10,000 ray casts (up to 200 m), then ten rounds of enlarging every proxy by a random metre and a partial rebuild. The same random sequence on both.

phase aephysics Box3D
insert 10,000 5.0 ms 9.4
100 full rebuilds 46 42
100,000 box queries 12.5 11.9
10,000 ray casts 6.9 (13.4 before the slab test) 7.2
10 x enlarge all 2.5 2.9
10 x partial rebuild 5.9 5.0

Both report 3,965 query hits, 1,950 ray hits, height 17 and area ratio 63.45: the trees are the same tree. The ray cast was 1.9x here while it tested each node as the reference does -- the segment's box, then the three edge axes -- in scalar doubles against the reference's SSE2. It now takes the slab test (math.slab_overlap) against the ray's inverse direction, worked out once per ray: one pass, exact as the pair it replaces, with the near and far faces picked by the ray's signs so a tree's empty sibling box is still missed. 13.6 ms to 6.9 on the same run (aephysics#11), at the reference's figure; the box cast takes the same test on the node boxes grown by the cast box.

hull

bench/hull.ae and bench/hull_box3d.c: 200 hulls of 64 points on a sphere capped at 32 vertices, 20 hulls of 4,096 points inside a cube with the corners stamped last (every interior point through the conflict lists, most cone faces merged out), and 2,000 box hulls. The same random sequence on both.

phase aephysics Box3D
200 sphere hulls, 64 points to 32 4.7 ms 2.9
20 cube hulls, 4,096 points to 8 5.2 2.5
2,000 box hulls 0.62 (2.0 before #108) 0.17

Both produce 6,554 vertices and 12,106 faces in total: the same hulls. The builder runs at 1.6-2x the reference's time (doubles, indices in place of pointers, structs passed by value). Hull construction is a load-time cost, not a per-step one.

The box hulls were not the allocation (#108, 2026-10-05). The box's topology came from helpers holding their table as a local array, so each call copied the whole table onto the stack, 96 times a box. And the block hash ran every word through one chain of multiplies. Now the topology is written out and the hash runs four lanes: 2.9 ms to 0.62 ms for the 2,000 boxes. The faster hash also took about 10% off the mesh and height field builds (median mesh 234-260 to 207-224 ms, height field 172-181 to 152-161). The hash now reads its words through memcpy (native.load_word). Read through an integer pointer, as before, GCC moved the reads ahead of the writes once the hash was inlined, and the compound benchmark's material table found 253 materials instead of 2.

distance

bench/distance.ae and bench/distance_box3d.c: two boxes (1 x 0.5 x 0.75 and 0.6 x 0.8 x 0.4) over 100,000 poses along a sweep that passes through overlap, each pose a GJK query from a cold cache and then from a cache warmed by the pose before; 10,000 shape casts of the second box at the first; 10,000 times of impact of the second box falling and turning onto a slab.

phase aephysics Box3D
100,000 GJK queries, cold cache 22.9 ms 17.3
100,000 GJK queries, warm cache 18.2 11.9
10,000 shape casts 5.0 3.5
10,000 times of impact 13.8 9.4

The sums of the results agree to the precision of the reference's floats (distances 26,291.3 on both, cast fractions 2,780.06, impact fractions 5,523.1) and the GJK iteration counts within 0.01% (335,952 against 335,990): the same algorithm taking the same paths. The port runs at 1.3-1.5x the reference's time; the simplex is passed by value here where the reference works on it in place.

lanes

bench/lanes.ae and bench/lanes_native.c: the contact solve's per-point body, four contacts to a pass -- rotate both anchors by the bodies' delta rotations, the separation and its bias, the relative normal velocity, the clamped impulse and the two bodies' velocity updates -- over the same flat block of floats, once in Aether's std.lanes and once in C with GCC's vector extensions, the way aephysics/native/aephysics_native.c writes its own. Same layout, same operations, same order, same compiler flags. 4,096 bundles x 4 points x 200 passes.

time
C, GCC vector extensions 54-55 ms
Aether, std.lanes 55-56 ms

Within two percent, and the same sum of impulses to the last digit (37,201,532). A lane operation in Aether costs what the same lane operation costs in C, which is what the native file's vector code was waiting on: the wide contact solver -- prepare, warm start, solve, restitution, and the pack and unpack that exist only because the module's constraints are doubles and the solve's are floats -- can come back into the module in Aether without paying for it (#46).

manifold

bench/manifold.ae and bench/manifold_box3d.c: 20,000 hull-hull collisions of two boxes (1 x 0.5 x 0.75 and 0.6 x 0.8 x 0.4) along a sweep through overlap with a cold SAT cache each time; the same 20,000 with the cache carried from pose to pose (a stack's steady state); 20,000 hull-capsule and 20,000 hull-sphere collisions along a sweep.

phase aephysics Box3D
20,000 hull-hull, cold cache 11.2 ms 9.2
20,000 hull-hull, warm cache 3.2 3.6
20,000 hull-capsule 4.8 4.9
20,000 hull-sphere 2.4 2.5

The same manifolds come out: 52,386 / 52,074 / 23,482 / 11,500 contact points, 19,706 cache hits, and equal separation sums on every phase. With the cache warm -- the state a resting stack is in -- and on capsules and spheres, the port is at or past the reference; the cold separating axis test is 1.4x.

The cold test was 13.2 ms until the edge-pair loop stopped reading A's edges through the half-edge structure. Every edge of A is tested against every edge of B -- a hundred and forty-four pairs for two boxes -- and each test read a face index into the planes and an origin index into the points, a pointer chase per pair; they are gathered once into a flat array of directions, normals and tolerances before the loop, in single precision, since a separating axis is a direction and the reference finds it in floats (#42). That took it to 12.7.

It is 11.2 since the gather became thirteen flat arrays and the loop reads four of A's edges at a time (std.lanes, Aether 0.706): the whole first test -- four dot products, a maximum and four comparisons -- is one pass of lane arithmetic, and only a lane that survives it takes the double path. The reference does the same thing with intrinsics; this is the first place the port answers its SIMD in Aether rather than in the native file. Every manifold is unchanged (52,386 cold points, the same separation sums): each lane computes what the edge computed on its own, in the same order, and the reduction keeps the lowest index among equal values, so the axis a pair chooses is the axis it chose before.

The same treatment of the face tests does not pay and is not in: the support search over the other hull's vertices, gathered into lanes the same way, took the cold test from 11.2 to 11.8 ms. A box has eight vertices, so a search is two lane steps and the reduction across four lanes costs more than the eight dot products it replaces. It is worth measuring again on hulls with vertices in the dozens.

triangle_manifold

bench/triangle.ae and bench/triangle_box3d.c: 20,000 triangle-hull collisions of a box at a hundred attitudes sinking onto a big triangle with a cold SAT cache each time; 20,000 of a box resting on a creeping triangle with the cache carried; 20,000 triangle-capsule collisions of a capsule swung across a triangle and its edge; 20,000 triangle-sphere collisions of a sphere swept over the triangle and off it.

phase aephysics Box3D
20,000 triangle-hull, cold cache 2.8 ms 2.6
20,000 triangle-hull, warm cache 2.6 2.4
20,000 triangle-capsule 5.1 3.8
20,000 triangle-sphere 0.32 0.61

The same manifolds come out: 39,536 / 80,000 / 29,778 / 6,422 contact points, 19,999 cache hits, equal separation sums. Triangle against hull is within 10% of the reference both cold and warm; the capsule is 1.35x (its GJK); the sphere, which is the closest point on a triangle and nothing else, is faster here.

mesh

bench/mesh.ae and bench/mesh_box3d.c: a 200 x 200 wave mesh of 80,000 triangles built ten times with the median split and ten with the binned SAH, edges identified each time; 100,000 rays cast down onto it; 100,000 box queries over it; 10,000 sphere shape casts onto it.

phase aephysics Box3D
10 builds, median split (80,000 triangles) 221 ms 129
10 builds, binned SAH 335 256
100,000 ray casts 20.8 10.2
100,000 box queries 70.4 39.5
10,000 shape casts 24.7 14.3

The same trees come out: 43,135 nodes of height 16 from the median split and 48,063 of height 16 from the SAH on both; every ray and cast hits on both with equal sums; the box query reports 2,218,859 triangles here against 2,218,676 there (0.008% more, boundary cases of the reference's SIMD box-triangle test against the scalar one -- false positives the query permits). The build is 1.2-1.5x, with the welding map and the edge map through core's LongMap; the traversals are 1.7-2x, the reference's SIMD box tests against scalar ones on 48-byte double boxes, the same gap the dynamic tree's ray cast showed. With the slab test in the traversal (aephysics#11) the 100,000 rays went from 11.7 ms to 8.9 in one run on the current toolchain (the table's 20.8 is from an older one; the reference was not run again), the same hits and sum. The sphere shape casts take a closed form for a triangle against a point (Real-Time Collision Detection 5.1.5) in place of GJK since then: 22.4 ms to 11.6, under the reference's 14.3, the same hits and sum.

The builds since (2026-10-05): the tree's recursion carried each child's height back in a 4-byte heap block, two a node, and the binned SAH allocated its bins on every call; the heights now come back with the node index and the bins are made once a build. The SAH bins all three axes in one pass over the primitives rather than one pass an axis. And the edge identification pairs edges by bucketing them under their lower vertex (a vertex has a handful) where it hashed 240,000 keys into a LongMap twice each: 8 ms of a 27 ms build to 4.5. The ten median builds went from 212-232 ms to 156-186 (the reference: 129-134), the ten SAH builds from 323-344 to 239-256 (the reference: 256-258), and every mesh's block, hash included, is the same byte for byte.

height_field

bench/height_field.ae and bench/height_field_box3d.c: a 512 x 512 wave field of 522,242 triangles with a hole every sixteenth cell, built ten times; 100,000 rays cast down onto it at slight angles; 100,000 box queries over it; 10,000 sphere shape casts onto it; 10,000 sphere overlaps at its surface.

phase aephysics Box3D
10 builds (522,242 triangles) 200 ms 131
100,000 ray casts 16.0 11.3
100,000 box queries 8.3 9.0
10,000 shape casts 49.5 23.5
10,000 overlaps 3.7 2.0

The same fields come out: every ray hits on both (94,058, the sum of hit heights within 0.1%, the quantised heights held as doubles here and floats there), every shape cast hits on both with equal fraction sums, the overlaps agree (3,160), the box query reports 2,171,942 triangles here against 2,171,914 there (0.001% more, boundary cases of the cell bounds test). The build is 1.5x, the quantisation and the edge flags of half a million triangles; the ray walk 1.4x; the box query at parity (no SIMD in the reference's); the shape cast 2.1x, the GJK cast per straddled cell on top of distance's 1.3-1.5x; the overlap 1.85x.

The field now packs as the reference's does (2026-10-05): a 16-bit quantum per grid point, read through native.load_u16, and a byte per cell for its material and per triangle for its edge flags (Aether's byte[]). 4,182,176 bytes became 1,307,808 (the reference: 1,307,760), with the same hits and sums. The 10 builds went from 148-158 ms to 140-146. Rays and queries are unchanged once the quantum is a single 16-bit load: read as two bytes and a shift, the queries were 6% slower. HEIGHT_FIELD_VERSION moved with the layout. Meshes keep 32-bit material indices, which go past the reference's 255.

The shape casts and overlaps meet each straddled cell's triangles with a sphere's centre, and that pair takes a closed form (Real-Time Collision Detection 5.1.5) in place of GJK since: on the current toolchain the 10,000 casts went from 42.3 ms to 20.1 (the reference's 23.5) and the overlaps from 3.5 to 2.0 (its 2.0), the same hits and sums.

shape

bench/shape.ae and bench/shape_box3d.c: 1,000,000 rays at a unit sphere and 1,000,000 at a capsule from a fan of origins, a third of them missing; 100,000 casts of a sphere at a capsule under a transform; 100,000 overlaps of a sphere with it; 100,000 mover planes against it; 100,000 capsule masses.

phase aephysics Box3D
1,000,000 sphere ray casts 33.2 ms 31.5
1,000,000 capsule ray casts 41.0 49.7
100,000 shape casts 44.6 18.0
100,000 overlaps 12.2 5.9
100,000 mover planes 6.9 4.8
100,000 capsule masses 4.5 4.3

The same answers: the ray hits agree to one boundary case in a million (349,452 sphere hits here against 349,451, the same 595,654 on the capsule) with equal fraction sums, the casts hit the same 89,582 times with equal sums, the overlaps (51,518), the mover's planes (45,071) and the masses agree. The sphere ray and the masses are at parity, the capsule ray is 0.8x (the reference's float division by the axis length against our double), the mover 1.45x. The sphere-against-capsule cast is 2.5x and the overlap 2x: both are one GJK call on proxies with radii, where the box-against-box work of the distance layer sat at 1.3-1.5x; the radius handling in distance.shape_cast and shape_distance is the place to profile (aephysics#9).

Since aephysics#9's fix, proxies of one or two points (spheres' centres, capsules' segments) take a closed form (math.segment_distance) in place of GJK, the cache written as GJK leaves it for the time of impact. On the current toolchain, same run, same hits and sums: the 100,000 shape casts went from 32.5 ms to 18.6 (the reference's 18.0) and the overlaps from 13.7 to 8.6 (1.46x the reference's 5.9). A test holds the closed form to GJK on 2,000 pairs to 1e-6.

compound

bench/compound.ae and bench/compound_box3d.c: a compound of 2,000 children (1,000 spheres, 500 capsules and 500 instances of one box hull on a 20 x 10 x 10 grid, two materials) built ten times; 100,000 rays through it; 100,000 box queries over it; 10,000 sphere shape casts down through it; 10,000 overlaps and 10,000 mover planes under a transform.

phase aephysics Box3D
10 builds (2,000 children) 9.9 ms 14.3
100,000 ray casts 75.4 25.7
100,000 box queries 8.7 6.5
10,000 shape casts 10.7 5.0
10,000 overlaps 1.5 0.9
10,000 mover planes 1.2 0.9

The same answers: 50,450 ray hits with equal sums of fraction and child index, 777,892 query visits, 5,098 cast hits, 3,278 overlaps, 5,090 mover planes with equal offset sums. The build is faster here (0.7x; the content maps are core's LongMap on the hull's and the mesh's own hash, the reference rehashes every block through a generic table); the queries, overlaps and movers 1.3-1.6x; the shape cast 2.1x (issue #9's radius GJK). The ray cast is 2.9x: a probe with a visitor that does nothing shows the time in dynamic_tree.tree_ray_cast itself (77 ms for 520,000 leaf visits), not in the children -- the tree layer showed the same cast at 1.9x on a sparser scene, the reference's SIMD slab test against scalar doubles. The traversal is the place to profile (aephysics#11). On the current toolchain the rays were down to 39.5 ms, and the slab test in the traversal takes them to 27.3 (the shape casts 7.6 to 7.0) with the same hits and sums; the reference was not run again. The compound is 379 KB here against 231 KB there: the tree's nodes and proxies are our wider doubles and longs, and the block carries the traversal stack.

mover

bench/mover.ae and bench/mover_box3d.c: 1,000,000 solves of a target against six planes tilted around it (a floor, four walls leaning in, a soft ceiling), each followed by a velocity clip.

phase aephysics Box3D
1,000,000 plane solves and clips 64.6 ms 73.2

The same answers: equal sums of the deltas and of the clipped velocities, 1,662,623 iterations here against 1,662,624 there (one convergence test on the float's side of the slop). The solver runs at 0.9x: the reference reads its planes through a pointer per pass where the loop here indexes the array.

broad_phase

bench/broad_phase.ae: 10,000 dynamic unit boxes on a jittered 25 x 16 x 25 grid at 1.4 spacing over a static ground, the first update finding every pair, then 20 steps in which every box drifts a little and only the new pairs come out, each update's keys adopted into the pair set. The reference finds its pairs inside its world (broad_phase.c filters through the shapes and creates the contacts itself), so it has no free-standing counterpart; its pair update is measured against ours through the world benchmarks once the world steps.

phase aephysics
10,000 proxies created 3.5 ms
first update (2,900 pairs) 2.2
20 steps of 10,000 moves and an update (757 new pairs) 91

A step is 4.6 ms, most of it the 10,000 proxy moves (a leaf removed and re-inserted each); the update itself walks only the sibling pairs a moved node touched. The test checks every update against a brute force over the boxes.

The hash under the pair set and every map (core's key_hash) was rewritten during this layer: the previous 64-bit mixer multiplied signed longs, which is undefined in the C underneath, and gcc at -O2 made two inlined copies of it disagree, so a key stored by one copy was not found by the other. The new mixer stays in 32-bit products; the mesh builds above moved from 199 to 221 ms and 316 to 335 ms with it.

mesh_contact

bench/mesh_contact.ae: a box, a sphere and a capsule each dragged 100,000 steps across a 100 x 100 wave mesh, riding a hundredth inside the surface with a small move each step, the manifolds computed every step. The reference's mesh contact lives inside its world (it needs a contact, a worker context and the world's material callbacks), so it has no free-standing counterpart; it is measured against ours through the world benchmarks once the world steps.

shape, 100,000 steps aephysics per step
box (0.8 wide): 876,526 clusters, 3.17 M points, 910,432 cache hits 322 ms 3.2 us
sphere: 127,842 clusters, 129,088 points 69 ms 0.7 us
capsule: 797,124 clusters, 1.35 M points 371 ms 3.7 us

The wave's cells are half the box's width, so a box straddles several triangles of different normals and the clusters stay many (the cluster threshold is cos 5 degrees); the far clip points of a hull face are kept as speculative ones, as the reference keeps them. The test checks the seams (a sphere on an interior edge or vertex gives one point), the cache's persistence and the cull.

dynamics (bookkeeping, contacts, joints, sensors)

bench/dynamics.ae and bench/dynamics_box3d.c: ten rounds of a world with 5,000 dynamic bodies (a box hull and an offset sphere each, the mass computed from both) and 500 static ones, the mass summed, every dynamic body's transform set once (its shapes' bounds and proxies updated), then the world destroyed; then 5,000 cubes resting on a slab through eleven collide passes with no solve (the reference's step with a zero time step does exactly that: the pairs, the narrow phase, the state changes): the first begins every contact, the rest recycle them; then those cubes chained by 4,999 revolute joints (each created into the constraint graph and linked into the islands, which merge into one), then every joint destroyed; then 2,500 sensor spheres over 2,500 static boxes through eleven sensor passes (each with the pairs and the narrow phase before it, as the reference's zero-time step runs them).

phase aephysics Box3D
5,500 bodies and 10,500 shapes created, 10 rounds 112 ms 120
5,000 transforms set, 10 rounds 39 55
the world destroyed, 10 rounds 5.6 4.7
first collide: 5,000 contacts begun, islands linked 7.2 4.6
10 recycling collides 4.0 2.5
4,999 revolute joints created, 5,000 islands merged into one 2.9 5.7
the joints destroyed 0.23 0.11
first sensor pass: 2,500 begin events 1.4 1.0
10 more sensor passes 8.7 7.0

The same mass sum (53,272.5), the same 5,000 contacts and 5,000 islands, 4,999 joints and one island, 2,500 begin events. Creation is at parity: the reference's hull database hashes every hull shape's bytes and refcounts them, which this layer did not do then (a hull shape kept the pointer it was given; the database came with #147, below). The transforms are 0.7x: the reference's b3Body_SetTransform also walks the body's joints, which arrive with the joint layer. The collide passes are 1.7-1.8x: each contact is reached through its id into a 300-byte struct here where the reference's narrow phase prefetches the next contact and packs its state; the first pass also computes 5,000 hull-hull manifolds (the distance layer's 1.3-1.5x) and links 5,000 islands. The wide contact solver will read these arrays, so this is the place to come back to with the solver's profile. Joint creation is 0.5x: the reference's b3CreateJoint records the call for its replay and validates the joint definition's cookie; the destruction is 2x on a tenth of a millisecond. The sensor passes are 1.2-1.4x: each pass queries the three trees per sensor and runs GJK on every candidate, the distance layer's gap.

With the hull database (#147, 2026-10-09, Aether 0.795.0, three runs each, the same session): every hull shape now clones its hull into the world on its first use and finds it by hash and bytes after, as the reference does. Creation went from 96.2-97.4 ms to 97.0-99.2, against the reference's 120.5-123.9: still 0.8x of its time, now for the same work. The other phases did not move.

Re-measured on 2026-10-05 (main b47a270, Aether 0.779.0), three runs each, the same session:

phase aephysics Box3D
5,500 bodies and 10,500 shapes created, 10 rounds 106-109 ms 120-122
5,000 transforms set, 10 rounds 26.5-27.3 54.3-54.9
the world destroyed, 10 rounds 7.9-9.0 5.2-5.4
first collide 4.57-5.21 4.61-4.65
10 recycling collides 2.46-2.88 2.15-2.35
4,999 revolute joints created 3.19-3.46 5.57-5.82
the joints destroyed 0.21-0.26 0.10-0.16
first sensor pass 0.99-1.01 1.03-1.27
10 more sensor passes 5.25-5.42 6.99-7.49

The collide passes are at parity on the first pass and within 10-20% on the recycling (#17 closed). The prefetching (#79, #83), the SAT changes (#87, #94, #101) and #111's union of the contact's caches got them there. The sensor passes are now ahead. The world's destruction frees 16 MB in 5,088 blocks (an island's body list each, then the large arrays); timed section by section, it is the freeing of the double-precision records' memory, with no one culprit.

contact_solver

bench/contact_solver.ae (ours alone: the reference has no solve without its step, which integrates too; the step's pair comes with the world layer): 5,000 cubes resting on a slab and 1,000 stacked in pairs, collided once, then ten steps of the passes as the solver runs them: the colours' constraints prepared, warm started, four sub-steps of a biased solve and four relaxes (the relax carries the friction, twist and rolling), the restitution pass and the impulses stored.

pass, 10 steps of 6,000 contacts aephysics
prepare 7.0 ms
warm start 1.9
4 biased solves 20.1
4 relaxes (with friction) 27.6
restitution 0.13
store 2.5

About 85 ns per contact per biased solve and 115 ns with friction, on one core, scalar. The reference solves its convex contacts eight at a time in SIMD lanes (b3ContactConstraintWide); that path is the layer to add and measure once the step exists to compare against.

joint_solver

bench/joint_solver.ae (ours alone, as the contact solver's): 5,000 cubes in a row chained by 4,999 joints of one kind, the row given a sideways velocity gradient, ten steps of the passes: prepared, warm started, four biased sub-step solves and four relaxes.

pass, 10 steps of 4,999 joints revolute (limited) spherical (cone and twist) weld prismatic (limited, sprung)
prepare 4.1 ms 3.9 2.8 2.9
warm start 1.0 1.0 0.9 1.5
4 biased solves 24.7 21.5 22.0 19.2
4 relaxes 25.2 21.7 16.0 13.6

About 0.5 to 0.6 microseconds per joint per solve, on one core. The reference solves joints the same way (scalar, per graph colour); its numbers come with the step's pair.

solver

bench/solver.ae and bench/solver_box3d.c: the step end to end (the pairs, the narrow phase, the Soft Step) against the reference's b3World_Step, one thread, 120 steps of 1/60 with four sub-steps: 5,000 cubes dropped from a metre onto a slab in a 100 by 50 grid (they land, slide and settle), and 100 stacks of 10 cubes (dynamic pairs in every colour).

scene, 120 steps scalar lanes in Aether native lanes Box3D
5,000 cubes falling onto a slab 249 ms 162 162 161
100 stacks of 10 cubes 50 35 36 28

The same height sums (2,499.65 and 4,983.87), every body asleep at the end, the same contact counts (5,000 and 1,000), in every column. The convex contacts three ways (issue #22's measure): solved one at a time through contact_solver (scalar; 308 and 61 ms before the build flags); four at a time through contact_solver_wide's lanes in Aether; and through the same lanes in aephysics_native.c, GCC vector code.

The two lane columns were 230 and 187 until the module's lanes became the language's f32x4 (std.lanes, Aether 0.706, #46): four doubles in a record left to the C compiler to vectorise became four floats in a register, the constraint became one single-precision record instead of the module's doubles and a float copy for the solve, and with it went the pack every sub-step and the unpack before every store. The Aether lanes are now the native ones' equal on the falling grid and their better on the stacks, and they compute the same record bit for bit (tests/test_contact_solver_wide: "native vs lanes 0"), so the wide path is the Aether one and the native vector code goes.

On the falling grid the port is now the reference's equal. The stacks are the solver's remaining 1.25x.

physics_world

bench/physics_world.ae and bench/physics_world_box3d.c: the reference's own benchmark scenes (its shared/benchmarks.c) stepped by our world and by b3World_Step, one thread, 1/60 with four sub-steps, sleeping off as the reference runs them.

scene aephysics per step Box3D per step
large pyramid: 5,050 cubes on a base of 100, 200 steps 11.5 ms 9.5
many pyramids: 196 pyramids of base 10, 10,780 cubes, 100 steps 27.2 20.7
joint grid: 10,000 spheres on 19,800 spherical joints, 100 steps 13.1 10.8

The pyramids were 12.5 and 28.4 against 8.9 and 19.3 before the wide constraint became one single-precision record in the module's own lanes (#46): 1.40x and 1.47x to 1.22x and 1.31x.

Against Box3D main (e77352c, 2026-10-04)

The reference pin moved from f555ee4 to e77352c (#86). Upstream's optimization commits since the old pin (#161, #168, #170) and its AVX2 dispatch (#174, picked at run time when the processor has it) make main much faster. aephysics has ported every behaviour change and the optimizations that measured faster here (#83-#94).

All three engines ran back to back, one thread, all three scenes in one process each, two rounds. That mode inflates the later scenes for every engine compared with running each scene alone, so read across the rows, not against the table above:

scene aephysics Box3D f555ee4 Box3D e77352c
large pyramid 9.84-10.23 ms 9.09-9.34 5.38-5.69
many pyramids 32.2-37.4 27.2-29.6 12.5-13.3
joint grid 27.1-30.6 21.0-21.6 14.4-15.5

Box3D's large pyramid stage by stage, old pin against main. The gain is the eight-wide (AVX2) contact stages:

stage f555ee4 e77352c
collide 1.06 ms 0.80
prepare 1.26 0.60
warm start 1.17 0.55
solve 1.76 0.92
relax 2.24 1.02
store 0.48 0.38

Matching that needs eight float lanes: Aether 0.788 has them (aether#2428), and the solver uses them (below, "Eight lanes").

Eight lanes (2026-10-07, #96)

Aether 0.788 has f32x8 (aether#2428) and the wide contact solver is written in it: eight contacts to a slot, one 256-bit register a lane group when built with AVX2 (AETHER_AEPHYSICS_CFLAGS=-mavx2), two four-lane halves otherwise. Every lane computes what it computed at four, so the checksums are the same bits at either width and either build.

The reference's three scenes, interleaved, one thread, the reference at e77352c (its AVX2 path picked at run time):

scene Box3D e77352c aephysics, four lanes aephysics, eight lanes, AVX2
large pyramid 5.86-6.45 ms 10.76-11.57 9.73-9.93
many pyramids 15.1-17.7 26.5-28.1 23.8-25.3
joint grid 11.1-11.3 12.5-13.3 12.2-12.7

Without AVX2 the eight-lane build steps as the four-lane one did, as the lanes promise. With it the large pyramid's contact stages, against the reference's:

stage four lanes eight lanes, AVX2 Box3D e77352c
prepare 1.28 ms 1.17 0.64
warm start 1.04 1.06 0.60
solve 1.97 1.52 0.98
relax 2.39 1.63 1.06
store 0.66 0.70 0.43

The solve and relax gain a quarter and a third; the warm start, nearly all gather and scatter, nothing. What is left is the bodies' states: ours are doubles, converted lane by lane into the floats the lanes hold and back, where the reference's are floats it loads and transposes (#95).

On Windows, before Aether 0.793, the AVX2 build needed GCC 16: MinGW GCC 15.2 spilled 256-bit values with aligned moves to a stack it could not align to 32 bytes, and the program faulted (aether#2476). Aether 0.793 fixes that; MinGW GCC 15.2 now builds and runs it with the same checksums.

Body states in float (2026-10-07, #95)

The solver's body states (velocities, the step's change of position and rotation) are single precision now, laid out as the reference's b3BodyState: 64 bytes a body, one cache line, where the doubles took 136 over three. The lanes gather them without a conversion and scatter them back the same way; the scalar solvers and the integration widen to double for their arithmetic and narrow on the store, as the reference computes in float throughout. Positions stay double.

Interleaved, one thread, the same machine minutes apart:

scene, ms per step doubles, AVX2 floats, AVX2 doubles floats
large pyramid 8.18-8.69 7.71-8.00 8.78-8.99 8.24-8.76
many pyramids 19.9-20.9 18.8-19.7 22.0-24.3 20.0-22.3
joint grid (pinned, best of 8) 16.59 16.57 17.48 17.13

The large pyramid's stages in AVX2: warm start 0.91-1.00 to 0.76-0.78 ms, solve 1.43-1.46 to 1.20-1.22, relax 1.57-1.62 to 1.30-1.34. The joint grid is level: its joints widen what they read and narrow what they write.

The results change, as rounding a velocity to float each sub-step must: the falling ragdolls sleep at step 298 (307 in doubles), the soak's fastest body and overlaps are unchanged to the printed digits, and every check holds. tests/golden/determinism_trace.txt is the new record (#125).

Manifolds and contacts in float (2026-10-07, #121, #37)

The stored manifold and the contact's cached values are single precision now, the reference's b3ManifoldPoint and b3Manifold byte for byte (56 and 268 bytes, from 104 and 504) and its cached rotations, relative pose, friction, restitution, rolling resistance and tangent velocity (the contact 344 bytes to 256; the reference's is 216). The narrow phase still computes in double and narrows once as it stores; the recycling, the prepare and the store, which read and write these records for every contact every step, move half the bytes, and the lanes load them with no conversion.

Interleaved, AVX2 builds, one thread, ms per step (a noisy machine; read across a row):

scene main (float states) + float manifolds + float contacts
large pyramid 9.83-10.43 8.60-11.40 8.76-10.36
many pyramids 25.1-26.3 22.1-23.7 21.1-22.7
collide, many pyramids 5.71-6.38 4.59-4.94 4.04-4.47
prepare, many pyramids 3.64-3.81 2.75-2.87 2.45-2.84
store, many pyramids 1.68-1.86 1.25-1.38 1.23-1.38
joint grid (pinned, best of 8) 12.25 12.19 --

The collide phase on the many pyramids is a quarter shorter, the prepare a quarter, the store a quarter; the joint grid, which has no contacts, is level. The soak test against the reference stays as close as it was (seed 1's farthest body 6.89911 m against 6.89907), and the determinism golden is regenerated for the new rounding.

Body sims in float (2026-10-07, #121, #37)

BodySim keeps its world positions in double (the origin's transform, the centre of mass and the time of impact's start centre) and the rest in single precision as the reference's b3BodySim does: the inverse inertias, the local centre, the extents, the forces, the dampings and the start rotation. It shrinks from 424 bytes to 272 (the reference's is 216, its positions in float), and what the recycling and the prepares read comes first.

A quiet machine, AVX2 builds, interleaved, ms per step:

scene main float body sims Box3D e77352c
large pyramid 7.29-7.37 7.04-7.13 5.54-5.74
many pyramids 16.5-16.9 16.1-16.3 (15.6 best) 13.0-13.8
joint grid 11.66-11.77 11.68-11.98 10.63-10.76

Collide is 7% shorter and the prepare 10%; the velocity integration a few percent longer (it widens the inertias for the gyroscopic solve, the reference's algorithm in double). After this morning's #124, #126, #127 and this, the reference's three scenes stand at 1.25x, 1.2x and 1.1x its AVX2 build, from 1.8x, 1.8x and 1.2x.

The collide pass as the reference keeps it (2026-10-09, #121)

Two pieces of e77352c's collide pass were not ported until now, and both are about what a contact reaches without going through anything else:

  • A contact keeps its bodies' sims as indices (the reference's encodedBodySimA/B): the awake set's index for an awake body, -(index + 2) for a static one, NULL_INDEX for any other. They are kept current wherever a sim moves: a contact made or put into the graph, a body woken or put to sleep, a sim swapped into the place of a destroyed or transferred body's. The collide pass and the contact prepares read them where they used to go from the shape to the body, its solver set and its sim, and rewrite the indices every step. A sleeping body's sim, which a non-touching contact may still need, is found through the body, as the reference's b3ResolveContactBodySim does. validate_solver_sets checks every contact's indices against its bodies (the reference's b3ValidateSolverSets), and test_encoded_sims in tests/test_edge_cases.ae runs every path that moves a sim with that check after each step: leaving out the refresh on a destroy, or on a wake, fails it.
  • The shapes' fat boxes live in the world's fat_aabbs, by shape id (the reference's fatAABBs), not in the shapes: the first test of every contact, whether its proxies still overlap, reads two entries of a dense array instead of two shapes, and a recycled contact never touches its shapes at all.

Nothing is computed differently: the golden trace and every checksum are the same. A quiet machine, pinned to one core, 5 runs each rotated, ms per step (range and median):

scene main this change Box3D e77352c, AVX2
large pyramid 6.50-7.29 (6.81) 6.27-8.85 (6.47) 5.28-5.80 (5.39)
collide 1.00-1.08 0.81-0.85 0.81-0.94
many pyramids 14.66-17.10 (14.86) 13.43-15.02 (14.34) 11.73-13.83 (12.40)
collide 2.49-2.97 1.81-1.97 1.97-2.36
joint grid 10.62-10.91 10.76-11.00 10.25-10.39

The collide pass is level with the reference's on the large pyramid and ahead of it on the many pyramids. The mixed pile (bench/soak.ae, awake, 6 runs): 2.58-2.64 ms per step to 2.52-2.55 against the reference's 1.99-2.02, its collide 0.59-0.60 to 0.52-0.53 against 0.42-0.43; more of that pile's contacts compute their manifolds afresh every step, and the narrow phase of its mixed pairings is in double. The scenes now stand at 1.2x, 1.15x and 1.05x the reference, the mixed pile at 1.26x. What is left is in the lanes (warm start, solve, relax: the transpose of aether#2550), the prepare, and the pairs (the tree in double, kept for large worlds).

The joint grid's warm start (2026-10-08, #121)

The joint grid's solve and relax were already ahead of the reference's; its gap was the warm start and the prepare. Two changes, neither of which moves a bit of any result:

  • JointSim keeps the bodies' world inertias in float, as the body sims hold them, and the joint solvers widen them where they read them. They were floats widened at the prepare, so nothing rounds differently, and the record shrinks from 912 bytes to 840.
  • The spherical joint's warm start adds its angular impulses (the spring's, the motor's, the cone's and the twist limits') only when one of those parts is on. Each is zeroed when its part is off, so the sum it skips is exactly zero; a plain point joint, the grid's, no longer reads the 120 bytes of impulses and axes behind it.

Pinned to one core, 8 runs each interleaved, ms per step:

min median
main 11.23 11.91
this change 10.78 10.90
Box3D e77352c, AVX2 10.21 10.34

Stage by stage, three rounds:

stage main this change Box3D
prepare joints 1.10-1.23 0.93-0.95 0.51-0.58
integrate velocities 1.21-1.25 1.20-1.22 1.09
warm start 1.97-2.23 1.37-1.40 1.17-1.26
solve 3.23-3.48 3.14-3.19 3.32-3.34
integrate positions 0.30-0.31 0.30-0.31 0.20
relax 3.12-3.36 3.06-3.11 3.22-3.23

The grid stands at 1.05x the reference, from 1.1x. Two more things were tried and dropped. Keeping the spherical joint's frames, impulses, axes and masses in float, as the reference does (a 664-byte record), took the warm start only to 1.5-1.6 ms and cost the solve and the relax 0.3 ms each: the solve is bound by its chain from the impulse to the velocities, and that chain gains a narrowing and a widening every iteration. Computing the limits' axes only for a joint with a limit gained nothing, since GCC already sinks them. What remains (the prepare, the two integrations, a fifth of the warm start) is the arithmetic in double over float records, each read widened and each store narrowed, where the reference computes in float throughout; a line-level sample of the warm start puts a fifth of it in those conversions.

The joint grid, stage by stage (2026-10-05)

The joint grid has no contacts, so the eight-wide lanes don't explain its gap. It is the scalar spherical joint, the same code as the reference's. Each scene run alone (AEPHYSICS_BENCH_SCENE=grid), one thread:

stage aephysics before aephysics after Box3D e77352c
prepare joints 1.21-1.42 ms 1.27-1.49 0.61
integrate velocities 1.25 1.25 1.15
warm start 2.09-2.95 2.04-2.39 1.31
solve 4.36-4.98 3.90-4.14 3.33
relax 4.21-4.88 3.88-4.14 3.22
step 14.4-17.0 13.6-14.7 10.4-10.6

"After" is #107's layout. The solve and relax were reading about eleven cache lines per joint, because what they need was spread through the records. JointSim kept the local frames (read only by the prepare) between its header and the masses, and SphericalJoint kept the enable flags, tested first, 536 bytes in. JointSim now puts the solve's fields first: kind, masses, inertias, softness and the joint event thresholds. The kind's data comes next, and the frames and settings go last. SphericalJoint puts its flags, body indices, impulse, frames and centre delta first, then what the warm start reads. The arithmetic is unchanged and the height sum is the same.

BodySim now starts with what every constraint prepare reads: the transform, centres, inverse mass and world inertia, in three lines instead of five. That took the joint prepare from 1.43-1.49 to 1.26-1.37 ms and the many pyramids' wide prepare from 3.53-3.60 to 3.38-3.49. The large pyramid was about even.

Then #108: the profile put a sixth of the joint grid in point_mass_matrix, a real call (GCC didn't inline it) forming skew(r) i skew(r) through two full 3x3 products per body. It now leaves out the products with skew's zeros. Every surviving term is the one mul_mm forms, added in the same order, and adding an exact zero product changes nothing, so the matrix is the same to the bit (tests/test_joint_solver.ae checks 2,000 random cases). Joint grid, interleaved, 4 rounds:

stage before after Box3D e77352c
solve 3.87-4.23 ms 3.09-3.38 3.33
relax 3.87-4.22 3.01-3.22 3.22
step 13.6-14.5 11.5-12.7 10.4-10.6

The joint solve and relax are now level with the reference's. The warm start (1.9-2.2 ms against 1.31) and the prepare (1.2-1.4 against 0.61) are what's left.

What's left of the gap is the records' size in double precision. Our JointSim is about 900 bytes, against the reference's float union. A BodyState is 112 bytes against 64.

Before #107, AEPHYSICS_STAGES never reset its clocks between scenes. A benchmark's second and third scenes reported averages over every step since the start, so the joint grid showed a wide prepare and a store although it has no contacts. Stage figures for the many pyramids and the joint grid from before then (in this file and on the issues) carry the earlier scenes' stages. The large pyramid, which runs first, was right.

Stage by stage

AEPHYSICS_STAGES=1 times every stage a worker runs and prints the per-step averages; the world's own profile carries the phases around them, and bench/physics_world_box3d.c prints the reference's b3Profile, which names the same stages. So the two columns are the same run, stage for stage, rather than two totals. The large pyramid, one thread:

stage aephysics Box3D difference
pairs (broad phase) 0.32 ms 0.22 +0.10
collide (narrow phase and recycling) 2.17 1.04 +1.13
prepare constraints 2.07 1.26 +0.82
integrate velocities 0.57 0.58 0.00
warm start 1.13 1.15 -0.02
solve (biased) 1.92 1.75 +0.17
integrate positions 0.11 0.11 0.00
relax (no bias) 2.43 2.24 +0.20
restitution 0.01 0.01 0.00
store impulses 0.76 0.44 +0.31
step 11.9 9.1 +2.8

Manifolds from blocks

The line profile of the collide pass (scripts/profile.sh samples mapped to source lines with addr2line) put 72% of the recycling's samples on two lines: the first read of a contact's manifold and the first read of its points. The arithmetic is the reference's, operation for operation; the difference was where the manifolds were. The port allocated each contact's manifolds from the heap one by one, so the manifolds of neighbouring contacts were anywhere, and every pass that walks the contacts -- the collide, the prepare, the store -- missed the cache on each. The reference hands them out of a block allocator per manifold count (b3AllocateManifolds), and so does the port now (basics.BlockAllocator, a port of block_allocator.c, held by the world behind a spinlock since the narrow phase runs on every worker).

The large pyramid, the two builds run in turn, four rounds:

ms per step
manifolds from the heap 11.57, 11.34, 11.36, 11.65
manifolds from blocks 11.17, 11.19, 11.36, 11.23

and stage by stage, collide 1.94-2.02 to 1.55-1.72 ms, prepare 1.87-1.95 to 1.75-1.81, store 0.64-0.68 to 0.60-0.64. The same height sum; the suite passes.

A probe that never reused a freed manifold -- every allocation the next slot of one long run, in the order the contacts were made -- measured 10.7 to 11.3 in the same rounds, so the layout has more in it than the allocator gives: a freed slot is reused last-in first-out, which is the reference's behaviour and scatters the order a little. The reference's own note on the contact (todo embed single manifold) points at the rest: a contact with one manifold carrying it inline, where the pass is already reading.

Two stages are seven tenths of the whole difference: the collide pass (#17) and the prepare. Everything the lanes touch -- warm start, solve, relax, restitution, integrate -- is within two tenths of a millisecond of the reference, and the warm start is ahead of it. That is what #48 bought and where the port stands: the solve is the reference's equal, and what is left is the narrow phase and the work of filling the constraints.

The same height sums (167,556, 37,695 and -496,893), contact counts (14,950 and 28,420) and joint count. One run of the pair, on a machine whose other work moves both columns by 10-20% between runs (the joint grid has measured 11.1 to 14.1 ms, the reference 9.1 to 11.8): the ratios hold, 1.1x on the joint grid (no contacts: single against double precision and what the emitted C still loses) and 1.4-1.5x on the pyramids (14.1 and 31.6 before the native prepare, 1.6x; 24.5 and 53.0 before the wide path and the flags, 2.2-2.4x; 17.1 and 41.5 before the parallel layer's stack allocator stopped zeroing). AEPHYSICS_BENCH_SCENE=pyramid scripts/profile.sh bench/physics_world.ae on the large pyramid alone, and the reference's benchmark under the same sampler (tools/sampler.c linked into its C), ms per step:

stage aephysics Box3D
contact solve (with the gather and scatter) 4.8 4.5
contact prepare 2.1 1.3
narrow phase and recycling 2.0 1.2
warm start 1.0 0.7
unpack 0.3 --
the solver's own stages (integrate, finalize, blocks) 1.7 1.0
store 0.3 0.5
step 12.5 9.7

The solve is at parity: the same lanes, the same precision. The prepare is the native file's now (issue #37): the module used to write a lane at a time into its double block and the file packed the block to floats, a millisecond a step of packing on this scene; the prepare writes the floats itself, reading the module's structures through the field offsets the module measures at start, and computes in doubles as the module did, operation for operation, so the lanes are the same to the bit (test_contact_solver_wide's native-against-lanes figure is unchanged, 3.87e-5, and the determinism traces are). What is left is the prepare's own writes (a lane at a time, as the reference's), the narrow phase in doubles (issue #17) and the solver's stages.

parallel

bench/parallel.ae and bench/parallel_box3d.c: the three scenes above by worker count, ours through WorldDef.worker_count and the reference's through b3WorldDef.workerCount on its built-in scheduler, sleeping off, ms per step; 1, 2, 4 and 8 workers and the machine's 24 processors. The height sums are the same at every count on both sides.

scene workers aephysics Box3D
large pyramid 1 13.3 9.1
2 8.0 4.8
4 4.6 2.9
8 3.3 2.0
24 2.9 1.8
many pyramids 1 32.0 20.6
2 17.6 10.4
4 10.8 5.3
8 6.8 3.5
24 5.6 2.8
joint grid 1 12.2 11.4
2 6.2 5.6
4 3.8 3.3
8 2.4 2.1
24 2.2 1.6

The scaling is the reference's (4.0x, 4.7x and 5.1x at eight workers against its 4.4x, 5.8x and 5.4x) and the ratio between the columns at eight workers is the single-thread one, so what is left is the per-thread work, not the layer. The profile the world keeps (physics_world.get_profile, the reference's b3Profile) says where the step goes at eight workers on the large pyramid: constraints 2.2 ms (the stages), collide 0.5, pairs 0.3 (the broad phase, serial here and a task in the reference), transforms 0.14, setup 0.01.

Two things the layer found on the way. A task on a fresh thread (std.worker's run_detached spawns one per call) costs 180 us a round of seven; the scheduler now makes its threads once and they wait on a semaphore, and a round is 8 us. And the stack allocator zeroed every allocation -- the constraint blocks are tens of megabytes a step -- where the reference's does not: a millisecond a step on the pyramids, gone, with the memory poisoned under the determinism scenes to show nothing reads it before writing.

human

bench/human.ae and bench/human_box3d.c: the reference's "rain" benchmark (shared/benchmarks.c, its shared library linked for the figure and the scene): a ten by ten grid of cells, each a grid mesh with a torus on it; every 48 steps a column of cells gets three ragdolls (twelve capsule bones, eleven joints with limits, springs and motors) dropped from twenty metres, the columns recycled once full. 400 steps of 1/60 with four sub-steps, one thread.

aephysics Box3D
400 steps, 3,700 bodies and 4,200 joints at the end 4.61 ms per step 4.40

1.05x, the closest pair yet: the step is joints and capsule contacts, which the flags of PR #24 and the lanes of PR #25 brought to parity, and the meshes' contacts, the bookkeeping of 300 figures created and destroyed in turn. Both build the same 3,700 bodies and 4,200 joints on the same steps; the resting state differs (7,208 contacts and 2,892 awake against 6,831 and 3,144, the moved bodies' height sum 24,019 against 24,187): eight figures tumbling for six seconds is chaotic, and single against double precision parts them, where the resting pyramids of physics_world agree to six digits.

Checked again on 2026-10-05, when the rain ended at 6,062 contacts against 6,831. Traced every 10 steps, the counts agree within 1% through the fall and part at the first landing (step 120: 1,700 against 1,805), when the figures hit the top of the tori. One figure dropped on one cell follows Box3D to four digits until it lands. From there, both engines split between staying on the torus and sliding off it with the drop point: over eleven offsets, Box3D stays on top three times and aephysics four. Groups of three, dropped at sixteen offsets, end with 34 to 93 contacts in Box3D and 47 to 138 here, and ours has the larger sum (1,373 against 1,074). No figure falls through a mesh in either engine. The rain's total is chaos, not a contact difference.

soak (a mixed pile)

bench/soak.ae against bench/soak_box3d.c (2026-10-07, i7-13700K, one thread, Box3D e77352c with its AVX2 dispatch): 2,048 bodies of every convex kind -- boxes, spheres, capsules, hulls of twelve random points, restitution up to 0.6, a little rolling resistance -- thrown spinning from eight layers into a walled pen, 600 steps, the same dice drawn in the same order in both. The reference's own benchmarks are cubes; this is the pile a game throws.

Box3D e77352c aephysics (eight lanes, AVX2)
sleeping, ms per step 1.25-1.48 2.33-2.39
awake throughout, ms per step 2.26-2.43 3.49-3.93
pairs 0.08 0.12
collide 0.53-0.58 0.89-0.93
solve (constraints) 1.64-1.76 (1.42-1.52) 2.49-2.88 (2.22-2.61)
transforms 0.21-0.23 0.25
height sum, contacts 618.6, 9,061 621.9, 8,927

Awake, the like-for-like cost, we are 1.5-1.7x the reference, the gap in every phase: the solve's share is the body states' gather and scatter in doubles (#95), the pairs, the narrow phase of the mixed pairings and the transforms are their own (#121).

Both piles are asleep by step 500 now. Before #134, a random hull resting on an edge could rock between two faces for good, in the reference as in ours (replayed from the same state, both cycle every 400 steps to five digits): when the floor's manifold flipped to the other incident face, the corner coming down was not among the old manifold's points and was found up to 2.5 cm deep, the soft contact pushed it out, lifting the body, and it fell back the other way, the energy the impact took given back as height. Which body in a pile ended up so was chaos, so the reference's pile slept at step 500 and ours at 1,150, then 800. The face contact now takes the hull's vertices already within the speculative distance over the reference face, the corner lands speculatively, and our pile sleeps at step 500 as the reference's does; the benchmark checksums are unchanged.

tests/test_soak.ae holds the small version to account on four seeds (64 bodies, 900 steps), and the reference's port on the same dice agrees with it nearly to the digit: the same starting energies to the joule, none ever gained, the fastest body 11.3546 m/s on seed 1 in both, the deepest overlap in the fall 0.160931 m against our 0.160922 (dynamic bodies have no continuous collision in either, so a body meeting another at 9 m/s is a step's 15 cm in before its contact exists), the farthest body 6.8991 m against 6.8992, at rest 0.8-4.7 mm against our 0.6-5.0, every body asleep at the end in both.

suite (the reference's benchmark suite)

The rest of the reference's benchmark/main.c (#141): its nine other scenes, built by its own functions (shared/benchmarks.c, e77352c, the release sizes) in bench/suite_box3d.c and by their port in bench/suite.ae, each run as the reference's runner runs it: the scene's own step before every step, the first step untimed. One thread, pinned to a core, the better of two runs, ms per step:

scene aephysics Box3D e77352c, AVX2 height sum, contacts (ours / reference)
convex_pile: 5,120 of PEEL's 32-point convexes 11.89 9.84 1.21x 37,935 / 37,950; 51,767 / 51,706
junkyard: 10,584 rocks swept by a kinematic cylinder 22.69 21.80 1.04x 85,925 / 90,716; 195,896 / 193,126
large_world: a million static boxes, 100 spheres dropped 0.0109 0.009 1.21x 76.189 / 76.189; 380 / 380
sleep: ten pyramids, one woken each step 2.18 1.80 1.21x 14,322.8 / 14,322.8; 5,890 / 5,890
spinner: 1,500 spheres churned in a cylinder mesh 3.46 3.17 1.09x 19,206 / 19,059; 7,496 / 7,564
trees25 1.45 1.25 1.16x 43.99 / 44.48; 1,112 / 1,103
trees50 0.543 0.513 1.06x 45.59 / 45.60; 1,106 / 1,100
trees100 0.310 0.299 1.04x 45.20 / 45.33; 1,100 / 1,100
washer: 8,000 cubes in a kinematic drum 16.87 15.14 1.11x 91,422 / 85,532; 41,649 / 43,736

Where nothing is chaotic the two agree: the large world and the sleep scene to the printed digit, the spinner's motor angle after 800 steps to five (-0.157365 against -0.157361). The piles that tumble (the junkyard, the washer, the spinner's spheres) part at the rounding of the first collisions and end in different heaps of the same size. The reference's trees25 warns that a mesh query filled its 256-triangle buffer; ours has no such cap, hence its nine more contacts.

The SAT runs (shared/sat_benchmark.c): 1,000 pairs of each hull pairing placed half the speculative distance apart, the best of four runs of twenty repeats, microseconds a query, ours / the reference's:

pair GJK cold GJK warm SAT SAT, no inscribed-sphere bound
complex/complex 0.654 / 0.618 0.212 / 0.196 1.78 / 0.954 16.3 / 6.08
complex/rock 0.433 / 0.411 0.139 / 0.129 1.02 / 0.645 5.63 / 2.76
complex/cylinder 1.13 / 1.09 0.380 / 0.361 2.78 / 1.07 14.9 / 6.00
rock/rock 0.240 / 0.225 0.080 / 0.073 0.560 / 0.379 1.99 / 1.18
rock/cylinder 0.944 / 0.842 0.296 / 0.273 1.58 / 0.738 5.18 / 3.02
cylinder/cylinder 1.45 / 1.41 0.485 / 0.474 4.77 / 1.41 13.3 / 7.48

GJK is within 3-12% of the reference's. The separating axis test is 1.5-3.4x: the reference's is the wide one of convex_manifold_wide.inl (the hull's vertices as eight-lane rows, the edge candidates filtered and sorted before any is tested), ours the four-lane edge pass of an earlier reference. That is the convex pile's gap, and #150. Across the scenes the pairs phase is 1.4-1.6x the reference's (2.59 against 1.63 ms on the convex pile): the broad phase's boxes are double, where the reference keeps float boxes rounded outward even in its large-world build (#151). Both are taken up below.

The pairs phase and the wide separating axis test (2026-10-09, #151, #150)

The tree's boxes are single precision now, rounded outward from double as the reference's large-world build rounds them: 32-byte nodes, the world's fat boxes in float, the insertion costs and the rays measured on the widened boxes. Measured alone, that did not move the pairs phase (main 1.64-1.82 ms on the convex pile, the float boxes 1.68-1.77). The profile said why: two thirds of the walk's samples were the pair set's probe, a cache miss on every candidate in a table larger than the caches. The reference batches its candidates and prefetches every slot before it probes any (b3FlushCandidatePairs); so do we now, 32 at a time, and the walk reads a leaf's shape index from the node instead of the proxy array. The pairs phase, ms per step, the better of two pinned runs, Box3D e77352c AVX2 and ours AVX2 alternating, one session:

scene Box3D before after
convex_pile 1.24 1.82 1.44
junkyard 2.00 2.88 2.21
washer 1.83 2.65 2.21
spinner 0.284 0.403 0.353

The separating axis test reads the hulls' new single-precision rows: support searches eight vertices a pass (a hull of 16 vertices or fewer is searched one vertex at a time in double, where the lanes' reduction cost more than it saved), edge candidates culled with the stored cosines and again by the best faces' probe points, edge pairs eight a pass with the axis and separation in lanes. Every winner is measured again in double, so the golden trace did not change. The SAT runs, microseconds a query, before / after / the reference:

pair SAT SAT, no inscribed-sphere bound
complex/complex 1.76 / 1.60 / 0.95 16.2 / 12.9 / 6.01
complex/rock 1.00 / 1.04 / 0.63 5.57 / 5.11 / 2.73
complex/cylinder 2.77 / 2.02 / 1.06 14.8 / 12.8 / 5.98
rock/rock 0.545 / 0.600 / 0.375 1.91 / 1.93 / 1.17
rock/cylinder 1.51 / 1.34 / 0.74 4.88 / 4.80 / 3.01
cylinder/cylinder 4.73 / 2.58 / 1.41 13.0 / 10.2 / 7.42

(The rock rows were measured before the small-hull search went back to double; the rocks have ten vertices and take that path now.) The rest of the gap is the work around the lanes that the reference does in lanes too: the face dots, B taken into A's frame, A's edges gathered, and the reduction of a support search's lanes, which std.lanes cannot do in one step yet (aether#2603, aether#2550). #150 stays open for it.

The whole step with all of it, ms per step, the better of two runs, ours before and after (that session was noisier for both engines, so the ratio to the reference is from the table above): convex_pile 11.84 to 11.26, junkyard 28.3 to 25.0, washer 17.6 to 16.7, spinner 4.31 to 4.08, sleep 2.40 to 2.33, large_world 0.0114 to 0.0111, trees25 1.589 to 1.587, trees50 0.573 to 0.590, trees100 0.334 to 0.334. The trees scenes pay for the outward rounding: their fast shapes leave their fat boxes every step, and each new box is rounded (the inline, branch-free bit step of nextafterf); a tree query no longer rounds at all, the double box compared against the widened nodes.

Build flags (inlining)

The generated C of every Aether function is a plain static function, never static inline, and gcc's -O2 keeps the small maths (a 3x3 product, a rotation, a quaternion product) out of line: a sampling profile of the joint grid at -O2 had math_mul_mm at 15%, math_rotate_vector at 8%, math_solve3, math_mul_quat and math_inv_mul_quat at 4-6% each, all as calls, where the reference's static inline headers vanish into the joint solve. Marking the math module's functions static inline by hand in the emitted C took the joint grid from 18.9 to 11.9 ms per step; the same effect from the outside is gcc's inlining budget, so aether.toml builds this repo with

-O3 --param max-inline-insns-auto=400 --param inline-unit-growth=400

(ae build reads it for the tests and the benchmarks; a program that uses aephysics as a library sets its own). Measured on the joint grid, one run each, ms per step: -O2 18.9; -O3 16.9; -O3 -march=native 16.2; -O2 with the inline parameters at 200: 12.5; -O3 with them at 400: 11.2; the hand-inlined C at -O2: 11.9. The pyramids gain 10% from the same flags (their time is the contact solver's loops, already inlined). The compile is a third slower and the executable 1.7x larger. Turning every double into a float in the emitted C (an experiment, not a change) made the grid slower, 26 ms, so precision is not where the time goes at present. An emitter-side static inline for small leaf functions is asked of Aether in aether-lang-dev/aether#2123.