From b546d5dcab64803ffd5a44092069f777fcfedfa3 Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 5 Oct 2026 12:32:27 -0300 Subject: [PATCH 1/2] Joint records laid out for the solve; stage clocks reset per report JointSim puts what the warm start and the solve read first (kind, fixed rotation, masses, inertias, softness, the joint event thresholds) and the kind's data right after it; the local frames, ids and settings only the prepare and the API read go last. SphericalJoint puts its enable flags, body indices, linear impulse, frames and centre delta first, then the impulses and axes the warm start reads. The arithmetic is unchanged and every height sum and golden is the same. Joint grid, interleaved against main, 6 rounds: solve 4.36-4.98 to 3.90-4.14 ms, relax 4.21-4.88 to 3.88-4.14, step 14.4-17.0 to 13.6-14.7. AEPHYSICS_STAGES never reset its clocks, so a benchmark's later scenes reported averages over every step since the first (the joint grid showed a wide prepare and a store with no contacts). stage_report now starts the clocks over and frees the rows it allocated. --- aephysics/dynamics/module.ae | 45 ++++++++++++++++++++---------------- aephysics/solver/module.ae | 7 +++++- bench/RESULTS.md | 39 ++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/aephysics/dynamics/module.ae b/aephysics/dynamics/module.ae index 7df1328..8546ce9 100644 --- a/aephysics/dynamics/module.ae +++ b/aephysics/dynamics/module.ae @@ -833,13 +833,26 @@ struct RevoluteJoint { enable_limit: bool } +// The point constraint's fields first (every solve reads them), then the +// impulses and axes the warm start reads, the settings after. struct SphericalJoint { + enable_spring: bool + enable_motor: bool + enable_cone_limit: bool + enable_twist_limit: bool + index_a: int + index_b: int linear_impulse: Vec3 + frame_a: Transform + frame_b: Transform + delta_center: Vec3 spring_impulse: Vec3 motor_impulse: Vec3 lower_twist_impulse: float upper_twist_impulse: float swing_impulse: float + swing_axis: Vec3 + twist_jacobian: Vec3 hertz: float damping_ratio: float max_motor_torque: float @@ -849,21 +862,10 @@ struct SphericalJoint { cone_angle: float cone_axis: Vec3 // unit, in frame A target_rotation: Quat - index_a: int - index_b: int - frame_a: Transform - frame_b: Transform - delta_center: Vec3 - swing_axis: Vec3 - twist_jacobian: Vec3 rotation_mass: Matrix3 swing_mass: float twist_mass: float spring_softness: Softness - enable_spring: bool - enable_motor: bool - enable_cone_limit: bool - enable_twist_limit: bool } struct WeldJoint { @@ -927,24 +929,27 @@ struct WheelJoint { // (filled by the solver's prepare), the constraint's softness, the // thresholds, and the kind's data in the block at the end (the // reference's union: read through distance_joint_of and its siblings). +// What the warm start and the solve read comes first and the kind's data +// right after it, so a joint's pass touches as few lines as it can; the +// frames and settings only the prepare and the API read go last. struct JointSim { - joint_id: int kind: int - body_id_a: int - body_id_b: int - local_frame_a: Transform - local_frame_b: Transform + fixed_rotation: bool inv_mass_a: float inv_mass_b: float inv_inertia_a: Matrix3 inv_inertia_b: Matrix3 - constraint_hertz: float - constraint_damping_ratio: float constraint_softness: Softness - force_threshold: float + force_threshold: float // the solve's joint event test torque_threshold: float - fixed_rotation: bool data: long[68] // JOINT_DATA_BYTES + joint_id: int + body_id_a: int + body_id_b: int + local_frame_a: Transform + local_frame_b: Transform + constraint_hertz: float + constraint_damping_ratio: float } struct JointPair { diff --git a/aephysics/solver/module.ae b/aephysics/solver/module.ae index 8b849e7..a98f500 100644 --- a/aephysics/solver/module.ae +++ b/aephysics/solver/module.ae @@ -97,13 +97,18 @@ stage_clock() -> float[] { return totals } -// The averages, in the reference's own order and words. +// The averages since the last report, in the reference's own order and +// words; the clocks then start over, so a benchmark's next scene reports +// its own steps rather than an average over every scene so far. stage_report() { if stages_timed() == false || g_stage_steps == 0 || g_stage_ms == null { return } clock = stage_clock() n = g_stage_steps as float println("aephysics stages ms/step: prepare_joints ${clock[STAGE_PREPARE_JOINTS] / n} prepare_wide ${clock[STAGE_PREPARE_WIDE] / n} prepare_contacts ${clock[STAGE_PREPARE_CONTACTS] / n} integrate_v ${clock[STAGE_INTEGRATE_VELOCITIES] / n} warm ${clock[STAGE_WARM_START] / n} solve ${clock[STAGE_SOLVE] / n} integrate_p ${clock[STAGE_INTEGRATE_POSITIONS] / n} relax ${clock[STAGE_RELAX] / n} restitution ${clock[STAGE_RESTITUTION] / n} store ${clock[STAGE_STORE_WIDE] / n}") basics.free_bytes(clock as ptr, STAGE_COUNT * 8) + basics.free_bytes(g_stage_ms, native.MAX_WORKERS * STAGE_COUNT * 8) + g_stage_ms = null + g_stage_steps = 0 } const NULL_INDEX = 0 - 1 diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 28a1749..8efbdc7 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -626,7 +626,44 @@ the eight-wide (AVX2) contact stages: | store | 0.48 | 0.38 | Matching that needs eight float lanes. Aether's `std.lanes` has `f32x4` -and no `f32x8`. +and no `f32x8` (aether-lang-dev/aether#2428). + +### 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. + +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 From 8b804e0b5ffcd0e71159eabcdbe0caf3320bcc2a Mon Sep 17 00:00:00 2001 From: Nicolas Maman Date: Mon, 5 Oct 2026 12:38:42 -0300 Subject: [PATCH 2/2] BodySim starts with what the constraint prepares read The transform, centre, local centre, inverse mass and world inertia (and the id and flags) come first, three cache lines instead of five; the extents, the integration's loads and the time of impact's start pose follow. Layout only: every height sum and golden is the same. Each scene alone, interleaved, 4 rounds: joint grid prepare 1.43-1.49 to 1.26-1.37 ms; many pyramids wide prepare 3.53-3.60 to 3.38-3.49; the large pyramid about even. --- aephysics/dynamics/module.ae | 19 +++++++++++-------- bench/RESULTS.md | 6 ++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/aephysics/dynamics/module.ae b/aephysics/dynamics/module.ae index 8546ce9..01004b8 100644 --- a/aephysics/dynamics/module.ae +++ b/aephysics/dynamics/module.ae @@ -395,24 +395,27 @@ struct BodyState { } // What integration and collision need of a body. +// What every constraint prepare reads first (the pose, the masses), then +// the collide pass's extents, the integration's loads, and the time of +// impact's start pose. struct BodySim { transform: Transform // of the body's origin center: Vec3 // of mass, in world space - rotation0: Quat // for the time of impact - center0: Vec3 local_center: Vec3 - force: Vec3 - torque: Vec3 inv_mass: float - inv_inertia_local: Matrix3 inv_inertia_world: Matrix3 - min_extent: float + body_id: int + flags: int max_extent: Vec3 + min_extent: float + force: Vec3 + torque: Vec3 linear_damping: float angular_damping: float gravity_scale: float - body_id: int - flags: int + inv_inertia_local: Matrix3 + rotation0: Quat // for the time of impact + center0: Vec3 } // A shape in the world: its geometry (the shape module's Shape, with diff --git a/bench/RESULTS.md b/bench/RESULTS.md index 8efbdc7..af600d8 100644 --- a/bench/RESULTS.md +++ b/bench/RESULTS.md @@ -654,6 +654,12 @@ The kind's data comes next, and the frames and settings go last. 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. + 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.