I made my code 2.4x faster with this simple trick

A copper bar hitting a wall took 13.85 s to simulate on one core. Now it takes 5.74 s, so 2.41× faster. The final length is 19.419 mm before and after.

Two changes did it. A closed-form solver for 3×3 eigenvalue problems, and a plasticity kernel that works on vertices in batches. Both live in the same build and I can switch each one on or off at runtime. So every comparison below is the same binary on the same inputs.

What BALEK does

BALEK is my explicit solid-dynamics code. Bars hitting walls, bodies colliding, that kind of thing. It’s written in C on top of PETSc and MPI. Explicit means there is no big system of equations to solve. The solver takes tiny time steps, thousands per run, and updates every point of the mesh at each one.

The hot loops are compute-bound. So any gain has to come from the maths done at each point, and the heaviest maths in a metal impact is plasticity.

Why a crash simulation needs eigenvalues

Metal behaves elastically (it springs back) until it’s stressed past its yield limit. Past that it flows, and the deformation stays. That’s plasticity. So at every time step, for every point, the solver roughly does this:

  1. Assume the material just stretched elastically. That gives a trial state.
  2. Check if that trial state is more than the metal can carry.
  3. If yes, pull it back onto the yield limit and record how much permanent (plastic) deformation happened. This is the return mapping.

With large deformations, the elastic stretch at a point is a symmetric 3×3 matrix, call it b. Step 3 is much easier in the matrix’s own axes. Any symmetric 3×3 matrix can be written like this:

b = Q Λ Qᵀ

Λ holds three numbers, the eigenvalues: how much the material is stretched along three perpendicular directions. Q holds those directions, the eigenvectors. You rotate into those axes, the plasticity update becomes an easy problem on three numbers, and you rotate back.

Problem is, this happens at every point, at every stage of every time step. That’s millions of small decompositions in a short run.

The test case

A copper Taylor bar. G.I. Taylor came up with it in 1948 and it’s a very blunt test. You fire a short metal cylinder flat end first into a rigid wall, and you measure what’s left. The front squashes into a mushroom while the back is still travelling, and the final shape tells you how strong the metal is at high strain rate.

The strength depends on strain rate and temperature (a Johnson-Cook model). I run it on a coarse mesh of 475 vertices, in two ways:

Plus a two-body version of the impact with 950 vertices and a simpler hardening law.

How I measured

All on a 16-thread desktop. I ran the jobs side by side, one per physical core, in six rounds. Between rounds I rotated which job got which core (a Latin square), so no level gets the lucky core. Each figure below is the median of the six.

The spread between rounds was 0.1 to 0.6 %. I only count a gain when it beats twice that spread, or 2 %, whichever is bigger. Every step below clears it easily.

Each run alone on an idle machine tells the same story. The total Lagrangian bar goes 13.72 s → 8.85 s → 5.61 s that way.

Level 0: where I started

A while back the profile showed 3×3 eigendecompositions eating most of the per-step work. I replaced a general-purpose library routine with a dedicated iterative 3×3 solver. That iterative solver is level 0 here, the oldest path I can still switch on.

Level 1: closed-form eigensolver, 22 to 35 % off

An iterative solver repeats rotations until the matrix is diagonal. A closed-form one computes the eigenvalues directly from a formula. At n = 3 that’s a short fixed sequence of operations, with no loop to converge.

The tricky part is accuracy. Impacts produce lots of states with two or three equal eigenvalues (uniaxial compression, near-hydrostatic), and that’s exactly where naive 3×3 formulas fall over. So the closed-form path takes extra care there.

case level 0 level 1 saving
Taylor bar, ALE 11.70 s 8.97 s 23.2 %
Taylor bar, total Lagrangian 13.85 s 8.97 s 35.3 %
Two-body Taylor impact 2.70 s 2.10 s 22.3 %

The eigensolver is the only thing that changes between level 0 and level 1. So turns out on the total Lagrangian bar the iterative solver was at least 35 % of the whole run.

The closed-form solver is now the default.

Level 2: batches of vertices, another 6 to 36 %

The plasticity kernel used to handle one vertex at a time. One vertex per call leaves the compiler little room to vectorise. The new block kernel takes vertices in small batches. The same arithmetic then runs on several vertices at once with SIMD instructions.

case level 1 level 2 saving
Taylor bar, ALE 8.97 s 8.29 s 7.4 %
Taylor bar, total Lagrangian 8.97 s 5.74 s 36.1 %
Two-body Taylor impact 2.10 s 1.97 s 6.0 %

The ALE number is smaller for a boring reason. On the ALE mesh only one of the two places that call the plasticity update uses the block kernel so far. On the total Lagrangian mesh both do.

I also kept a reference version of the block kernel. The fast one matches it bit for bit in every output frame.

Wall time per run at each level, three cases

All three levels side by side:

case level 0 level 1 level 2 total
Taylor bar, ALE 1204 µs 927 µs 853 µs 1.41×
Taylor bar, total Lagrangian 805 µs 540 µs 334 µs 2.41×
Two-body Taylor impact 2500 µs 1937 µs 1828 µs 1.37×

(Wall time per time step. The step counts agree within 6 steps at every level, so basically all of the speedup is work saved per step.)

Cumulative speedup over level 0

A funny side effect is that the total Lagrangian bar used to be the slow one. Now it beats the ALE bar by 2.5 s, even though it takes almost twice as many time steps (17,186 against 9,717).

What the profile said

BALEK has its own phase timers. On the total Lagrangian bar, level 0 against level 2:

So at level 0 the material update and the commit were 10.66 s of a 13.80 s run, about three quarters. At level 2 they’re 2.52 s of 5.61 s. The parts I didn’t touch moved by 0.04 s at most.

On the ALE bar the right-hand side went from 8.96 s to 7.14 s and the commit from 1.96 s to 0.38 s.

Profile phases at level 0 and level 2, both Taylor bars

Checks after every level

Of course a speedup only counts if the answer stays put.

check level 0 level 1 level 2
ALE: final length 19.180 mm 19.179 mm 19.180 mm
ALE: mushroom radius 6.632 mm 6.633 mm 6.632 mm
ALE: peak temperature 499.7 K 499.7 K 499.7 K
ALE: peak plastic strain 1.8433 1.8434 1.8434
Total Lagrangian: final length 19.419 mm 19.419 mm 19.419 mm
Total Lagrangian: mushroom radius 6.258 mm 6.258 mm 6.258 mm
Total Lagrangian: peak plastic strain 1.6082 1.6081 1.6083

The final length agrees within 0.00024 mm and the radius within 0.00071 mm. The peak temperature agrees to two parts in 100,000.

Energy and mass are conserved at every level. Energy drift sits between 10⁻¹⁶ and 3 × 10⁻¹⁵, and mass drift is at most 4.4 × 10⁻¹⁶. That’s double-precision round-off. (On the total Lagrangian mesh mass is exact by construction, which is a bit of a cheat.)

The QA checkers pass at every level.

Worst change between levels, and conservation

Same source, different bits

Fun one from when I first wrote the closed-form solver. It changed the output of the iterative path, the routine I hadn’t touched at all.

I swapped old and new object files one at a time until it came down to a single file, and the disassembly showed what happened. The new call sites made the compiler stop inlining the iterative routine. The out-of-line copy, under fast-math-style optimisation, rounds slightly differently. Floating-point addition isn’t associative, so when the compiler is allowed to reorder things, different machine code can mean different last bits. Same source, different bits 😅

I moved the new solver into its own file, the old machine code came back identical, and the iterative path reproduced its references exactly again.

Why the block kernel is opt-in for now

The closed-form solver is now the default. The block kernel stays opt-in until it has gone through the full QA bench. I also want it in the second ALE call site first, since that’s where the ALE bar is still leaving time on the table.

Until then it’s one switch, set at runtime.

Bref

References

← All articles