← All skills

Skill

Performance & SIMD

Making code fast by design: data layouts, vectorization and measuring where the time goes. Achilles steps eight robots per SIMD instruction and outruns MuJoCo single-threaded.

3 log entries1 project

AchillesBest example

SIMD: eight robots per instruction

Pull request

Also shows: C++

V3 replaces V2's Eigen with my own spatial algebra on xsimd, and stores every field as a structure of arrays: each component of each field, say the x angular velocity of every joint, is its own contiguous, aligned array. Joints are sorted by their depth in the tree, and each group of SIMD lanes holds joints at the same depth. With many copies of one robot, that puts the same joint of different robots side by side, so one instruction can load, update or store a quantity for eight robots at once (eight lanes with AVX2, four in the default SSE2 build). A Valgrind trace of the compiled binary confirms it: 88.9% of all accesses to simulation data are whole, aligned 8-lane vector loads or stores. The payoff is that filling the lanes is nearly free. On a seven-link arm, one robot takes 3.1 µs per step, while eight robots in the 8-lane build take 4.3 µs, 5.7× the robot-steps per second.

Three bars of SIMD lanes showing the time for one step of a seven-link arm: one robot in the 4-lane build takes 3.1 microseconds with three lanes idle; four robots fill the lanes in 4.3 microseconds, 2.9 times the robot-steps per second; eight robots in the 8-lane AVX2 build take 4.3 microseconds, 5.7 times
One step of a seven-link arm on one CPU core, best of 18 runs. Batched runs also step one shared fixed root.
Line chart of robot-steps per second against robots stepped together, seven-link arm. With one robot, 8-lane AVX2 does 0.75 million and 4-lane SSE 0.94 million. AVX2 climbs to 5.45 million at 64 robots, 7.2 times its one-robot rate; SSE levels off near 3.6 million.
The same effect across batch sizes: throughput climbs until the lanes are full.

Achilles

Beating MuJoCo on throughput

Benchmark code

Also shows: Simulation (MuJoCo)C++

To see where V3 stood, I benchmarked it against MuJoCo 3.15 on the same arm scenes, converted from the same .arow files, at the same 2 ms step, single-threaded on my laptop (Intel Core Ultra 9 285H). Batched across SIMD lanes, Achilles steps a seven-link arm 3.3× faster than MuJoCo's best single-threaded mode, reaching 5.45 million robot-steps per second at 64 robots. In double precision, MuJoCo's own, Achilles is still 1.5–1.6× faster at most batch sizes, and its results match MuJoCo's to about one part in 10¹¹. The one loss is a single seven-link arm, where most SIMD lanes sit empty (0.97×).

Line chart of speed relative to MuJoCo by number of links. V2 with one robot falls from 1.1× at one link to 0.48× at eight. V3 with one robot is 2.8×, 2.1×, 1.6× and 0.97× at one, two, three and seven links. V3 with 64 robots batched is 2.7×, 2.6×, 3.0× and 3.3×.
Each version timed against MuJoCo on the same machine, in its own benchmark: V2 against MuJoCo 3.3 (no gravity, 1 ms step), V3 against MuJoCo 3.15 (2 ms step).
The same double pendulum in Achilles and MuJoCo, matching out to 8 seconds. Same scene and start, both RK4 at a 2 ms step.

Achilles

V3: designing for performance

Commit

Also shows: C++

For V3, I wrote a full design document before writing any code. The target was concrete: simulate my club's six-robot arena of holonomic turret robots fast enough to train RL policies on a laptop CPU, since we don't have much GPU compute. Training takes millions of steps, so throughput was the goal, and V3 doubled as a performance learning exercise. One rule drove most decisions: only costs paid every frame matter. Only joint state, plus the contact solver's last solution, persists between frames. Everything else is either static data built at load time or scratch memory rebuilt each step and reused between passes. That rule became three pieces: fields; small operations that declare exactly which fields they read and write; and traversals that apply those operations across the joint tree, all checked at compile time. What the compiler can't check, the engine checks as it starts, and CI runs death tests confirming that a bad setup, like a model that doesn't fit its layout or an overflowing arena, stops it with a clear message instead of quietly corrupting a run. I kept V2's physically meaningful types but moved them into the math rather than the structure, so adding collisions or rendering meant adding passes instead of rewiring classes.

The V3 design: each frame runs eight passes in one direction (actuator torques, ABA forward dynamics, integration, kinematics, then broad phase, narrow phase, contact resolution and a debug render), and below it, which data is static, which persists between frames, and which is rebuilt each frame in a reused arena
V3 as designed. Collisions changed once I built them (see above).