Skip to content

All open issues: wide SAT and lane transposes, run-time SIMD width, joint accessors, integration hoists - #183

Merged
nicolas-maman merged 7 commits into
mainfrom
feat/lanes-dispatch
Oct 20, 2026
Merged

nicolas-maman merged 7 commits into
mainfrom
feat/lanes-dispatch

Conversation

@nicolas-maman

@nicolas-maman nicolas-maman commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Every open aephysics issue in one PR, on Aether 0.835.0 (which brought the lane features they waited on: aether#2550, #2603, #2607).

#150: the separating axis test as Box3D's wide one, at both widths. The new aephysics.sat module is b3ComputeSeparatingAxis (convex_manifold_wide.inl) line for line, in single precision from the hulls' rows:

  • the support search keeps the vertex index in the low mantissa bits, so one horizontal minimum finds it;
  • the face dots and B's frame are taken in lanes;
  • edge candidates are culled by the inscribed spheres and a probe point;
  • the edge pairs run eight a pass, and the improving lanes are reduced in order.

SAT runs against Box3D in the same run: 1.11-1.47x a query, from up to 2x. Convex pile collide: 1.31x to 1.20x Box3D's.

#121: the wide contact stages. Bodies are gathered and scattered through 8x8 register transposes (b3GatherBodies / b3ScatterBodies). The second tangent is computed where it is used instead of stored (Box3D's own todo). Against Box3D e77352c, same session, best of four:

scene before (#121) now
large pyramid 1.2x 1.19x
many pyramids 1.15x 1.03x
mixed pile, awake 1.14-1.22x 1.11x

Collide and store are now level or ahead on the large pyramid. What is left is about 1.15-1.2x in prepare, warm start, solve and relax, and integrate_p, which is still in double.

#156: run-time SIMD width. The solver and the SAT import their lane modules twice, plain and at @target("avx2"). A world runs the copy its width names:

  • basics.set_simd_width(0|4|8), Box3D's b3SetSIMDWidth;
  • physics_world.enable_sse2_fallback, b3World_EnableSSE2Fallback, which the API audit had missed.

A default build runs at the AVX2 build's speed where the CPU has AVX2 (large pyramid 8.18 to 6.77 ms) and runs anywhere else. test_determinism steps every scene at both widths in one process, bit for bit (Box3D's SIMDWidthTest).

#181: joint accessors of another kind or a stale id. The 79 one-line getters (and spherical_joint_set_cone_ellipse) dereferenced a null sim and crashed. They now give their type's zero value, as the setters already did nothing. 96 new checks segfault on the old code.

#182: velocity integration. The damping divide is skipped for undamped bodies, and the local inertia is inverted once a step instead of every sub-step (Box3D's own "todo wasteful computation"). integrate_v -8.5%, bit for bit.

Aether 0.835.0 and aeb v0.328. string.bytes replaces the now private raw pointer, and 27 calls take their short qualified form (W1006).

The golden trace changed once, on purpose, in the SAT commit. The old SAT measured every winning edge pair again in double; the reference's does not. Only the wave pile moved: it sleeps at step 320, was 326, and Box3D's sleeps at 314. The new trace holds bit for bit in the default, -mavx2 and -mavx2 -mfma builds. The local gate passes (every module ae check clean on 0.835.0). Numbers are in bench/RESULTS.md, "The reference's wide SAT, the lanes transposed, the width at run time".

Closes #181
Closes #182
Closes #156
Closes #121
Closes #150

🤖 Generated with Claude Code

The 79 one-line getters in joint_solver dereferenced sim_of's result
unchecked, so asking a parallel joint's maximum torque of a motor joint
(ae3d's balance assist while a ragdoll got up) read a null sim and took
the process down, where the setters did nothing. Each getter now checks
the sim as the setters do and gives its type's zero value (false, 0,
the zero vector, the identity rotation), as joint_get_constraint_force
answers a stale id. spherical_joint_set_cone_ellipse had the same
unchecked read and does nothing now too.

test_joint_solver asks every getter of every kind of a motor joint and
of a destroyed joint's id (96 checks); on the old code it segfaults.

Closes #181
…step

integrate_velocities_range ran two pieces of per-step work on every
sub-step for every awake body. The damping factor 1 / (1 + h c) is 1.0
exactly when c is 0, as for most bodies, so the divide is skipped then.
The local inertia, inverted for the gyroscopic step (Box3D's own "todo
wasteful computation"), is inverted on sub-step 0 and its six doubles
kept in a per-step scratch array on the world's stack
(StepContext.body_inertias), read back on the later sub-steps; a caller
without the array computes it each time as before.

Same doubles, same bits: the golden determinism trace matches bit for
bit, 4 workers included. Large pyramid (-mavx2, best of 5 alternated
runs): integrate_v 0.635 to 0.581 ms per step, -8.5%, the height sum
unchanged.

Closes #182
WideConstraint kept tangent2, cross(tangent1, normal), three f32x8
read by every warm start, solve and relax pass and by the store. It is
now computed again where it is used: once a constraint in the warm
start and the friction solve, once a slot of eight in the store. That
is Box3D's own note (contact_solver_wide.inl: "todo test computing the
tangents on the fly, at least tangent2"). The constraint is 96 bytes
smaller, and the cross product is the one prepare computed, on the same
single-precision lanes, so the bits are the same: the golden
determinism trace matches bit for bit.

Large pyramid (-mavx2, 8 alternated pairs on a loaded machine): step
best 7.47 to 7.36 ms, median 7.69 to 7.56 (-1.7%); warm start, solve
and store each 3-4% lower. For #121.
Each wide constraint gathered its eight bodies' states one field at a
time, 104 scalar loads and inserts per side, and scattered them back
the same way, on every warm start, solve, relax and restitution pass.
Box3D loads a body as two rows of eight floats and transposes them in
registers (b3GatherBodies, b3ScatterBodies, b3TransposeW8); with
std.lanes' moves (Aether 0.835, aether#2550) it does the same here: a
body state is the rows [v, pad, w, pad] and [dp, flags, dq], two
transposes give the lanes, one transposes the velocities back and each
dynamic body takes its row with one store (the pads written 0, which
nothing reads). Pure data movement, so the bits are the same: the
golden determinism trace matches bit for bit in the default and
-mavx2 builds.

Large pyramid (-mavx2, 6 alternated pairs): step best 6.97 to 6.57 ms,
median 7.62 to 6.80; warm start median 0.91 to 0.72, solve 1.36 to
1.15, relax 1.49 to 1.25. For #121.
0.835.0 carries the lane moves, bit casts and reductions and the
@target("avx2") import (aether#2550, #2603, #2607) that the wide gather
already uses and the run-time width and the wide separating axis test
need. Two things it changed are taken up here, no behaviour changed:

- string.aether_string_raw_ptr is private now; the public view of a
  string's bytes is string.bytes(s), whose `as ptr` is the same pointer
  (null for an empty string, where every caller passes a length of 0).
- ae check warns (W1006) on a call that names its module twice,
  hull.hull_vertices where hull.vertices reaches the same function
  (aether#2976): the 27 such calls across the modules, tests and
  benches take the short form.

Every module passes ae check on 0.835.0 with no warning.
The width of the wide contact stages was the build's: -mavx2 made them
eight lanes to a register and the binary faulted on a CPU without AVX2,
so one executable for every player had to take the narrow build. The
solver now imports contact_solver_wide twice, plain and at
@target("avx2") (Aether 0.835.0, aether#2607), and runs each wide stage
through the copy the world's width names, as Box3D's wide4.c / wide8.c
and b3SetSIMDWidth do:

- basics.set_simd_width(0 | 4 | 8) and get_simd_width: 0 asks the CPU
  (lanes.cpu_has_avx2), 8 without AVX2 is 4, another number is ignored.
- A world takes the width when it is created (World.simd_width), and
  physics_world.enable_sse2_fallback (b3World_EnableSSE2Fallback, which
  the API audit had missed) forces it to 4.
- physics_world.is_avx2_available reads lanes.cpu_has_avx2, and the C
  helper that did the same check is gone.
- Each copy keeps its own zero record, made on one thread by the copy
  that runs the step.

Both widths give the same bits. test_determinism runs every scene at 4
and at the CPU's width in one process (Box3D's SIMDWidthTest) and
checks the AVX2 copy holds eight lanes a register; the golden trace
matches bit for bit at the default width. test_host_api checks the
width rules and the fallback. The bench takes AEPHYSICS_SIMD_WIDTH.

In a default (non -mavx2) build, large pyramid, best of 4: width 4
8.18 ms a step, width 8 6.77 ms (solve 1.57 to 1.15, relax 1.79 to
1.23): the one binary runs at the AVX2 build's speed where it can.

Closes #156
The hull SAT is now aephysics.sat, Box3D's b3ComputeSeparatingAxis
(convex_manifold_wide.inl, e77352c) line for line, in single precision
from the hulls' rows:

- The support search keeps the vertex index in the low seven mantissa
  bits of its value (b3EmbedIndexW / b3MinIndexW), so one horizontal
  minimum gives the vertex, for every hull size (Aether 0.835.0's bit
  casts and reduce_min8, aether#2603).
- The face dots and B's normals and vertices in A's frame are taken in
  lanes, the edge pairs eight of A's a pass, and the expensive part of a
  pass runs only when a lane passes all four arc tests. The improving
  lanes are reduced in order.
- The Gauss-map tolerance is the reference's -LINEAR_SLOP^2.

manifold.compute_separating_axis imports the module plain and at
@target("avx2") and calls the copy basics.get_simd_width() names, as
Box3D calls b3ComputeSeparatingAxisW4 or W8. Each copy keeps its own
scratch, reserved with the manifold's; the edge contact has a small
block of its own now that the old edge rows are gone.

Results change on purpose: the double recompute of every winning edge
pair is gone, so the golden trace is regenerated. Only the wave pile
moved (it sleeps at step 320 where it slept at 326; Box3D's own sleeps
at 314). The capsule, query, mesh and rolling scenes are unchanged. The
new trace holds bit for bit in the default, -mavx2 and -mavx2 -mfma
builds, and test_determinism steps every scene at both widths in one
process. The brute-force edge test in test_manifold takes the
reference's tolerance too; the 7,000-pair oracle holds (worst shortfall
4.0e-4, worst excess 3.7e-7).

Measured on the reference's SAT runs, microseconds a query, ours before
/ after / Box3D in the same run (AVX2): complex/complex 1.83 / 1.32 /
0.95, cylinder/cylinder 2.98 / 2.02 / 1.41; without early returns
complex/complex 15.6 / 7.43 / 6.15. Convex pile, best of three
alternated pairs: collide 7.65 to 7.07 ms, step 11.32 to 10.74, and
1.31x to 1.20x Box3D's collide pass in the same period.

Closes #150
@nicolas-maman nicolas-maman changed the title Lane transposes, run-time SIMD width, wide SAT; joint accessors; integration hoists All open issues: wide SAT and lane transposes, run-time SIMD width, joint accessors, integration hoists Oct 20, 2026
@nicolas-maman
nicolas-maman marked this pull request as ready for review October 20, 2026 15:00
@nicolas-maman
nicolas-maman merged commit b37fae2 into main Oct 20, 2026
2 checks passed
@nicolas-maman
nicolas-maman deleted the feat/lanes-dispatch branch October 20, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment