Skip to content

Speed up the portable 4x4 inverse, adjugate and quaternion multiply - #76

Merged
ctreffs merged 5 commits into
masterfrom
perf/portable-inverse-quat-multiply
Oct 17, 2026
Merged

ctreffs merged 5 commits into
masterfrom
perf/portable-inverse-quat-multiply

Conversation

@ctreffs

@ctreffs ctreffs commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

Optimizations chosen by the benchmarks from #75. Only FirebladeMath's own implementations are changed.

Changes

  • 4x4 adjugate, determinant and portable inverse now share one Laplace expansion by 2×2 minors, a new internal adjugateAndDeterminant helper. It computes 12 shared sub-determinants once and builds the result directly from columns.
    • Previously, inverse computed a full adjugate (about 48 2×2 minors, many of them duplicates) plus a separate full determinant.
    • It also wrote 16 single elements through the switch-based subscript.
    • adjugate is FirebladeMath's own code on every platform. determinant and inverse change only on the portable path; with simd they still forward to simd_determinant and simd_inverse.
  • Portable quaternion multiply: the Hamilton product is now four 4-wide multiply-adds instead of 16 scalar multiplies.

Benchmark results

M1 Max, Swift 6.4, p50 over 3 runs each. Allocations stay at 0 everywhere.

Benchmark Before After
Mat4x4f.inverse (portable) 21 ns 15 ns
Mat4x4f.adjugate (simd and portable) 17 ns 15 ns
Quat4f.multiply (portable) 13 ns 10 ns

The benchmark harness itself costs about 5 ns per matrix and 3 ns per quaternion; I measured that with temporary pass-through benchmarks. Subtracting it, the portable inverse's own computation went from ~16 to ~10 ns (−37%), the portable quaternion multiply from ~10 to ~7 ns, and the adjugate is now on par with Apple's simd.

Tried and discarded: a fully vectorized adjugate (three 4-wide multiply-adds per column) measured slower, 16/17 ns. The scalar form is already vectorized well by the compiler.

Unchanged and not worth pursuing: Quat4f.actNormalize is 27 ns on both paths and dominated by normalize's square root and division. The other benchmarks are already on par with simd.

Compatibility with older compilers

Swift 6.1 and 6.2 (the Linux and Windows CI jobs, and macOS 6.1) couldn't type-check the long generic determinant sum or the vectorized quaternion product in reasonable time. Every intermediate is now explicitly typed and split into short sub-expressions, following the approach from #59. Grouping the quaternion product as (term0 + term1) + (term2 - term3) also shortened its dependency chain, which is where the extra speed in the table comes from.

Tests

  • New PortableVsSIMDTests: simd as the reference. It compares the portable 4×4 inverse, determinant and adjugate and quaternion multiply (Float and Double) against Apple's simd, which is exactly what FirebladeMath calls when the simd trait is on.
    • Inputs: 256 pseudo-random matrices and quaternion pairs, plus identity, diagonal, permutation, rotation + translation, small and large scales, singular matrices (determinant only) and the quaternion basis.
    • Adjugate reference: simd has no adjugate, so the reference is inverse · determinant.
    • Tolerances: about 10× the worst rounding difference measured, e.g. Double inverse 5e−13 and Float inverse 2e−4.
    • Where it runs: only when simd is importable but the trait is off. So the macOS CI job now runs the tests a second time with --disable-default-traits.
  • New Matrix4x4CofactorTests: compares against an independent cofactor expansion on 32 matrices, plus a singular matrix.
  • New QuaternionMultiplicationTests: compares against an independent scalar Hamilton product on 64 pairs, plus the basis rules (i·j = k, j·i = −k, i² = −1) and |p·q| = |p|·|q|.
  • Mutation testing: I injected 22 deliberate single-point bugs into the changed code, one at a time: sign flips, swapped factors or lanes, a transposed result, a wrong determinant term, and a missing reciprocal in inverse.
    • The full suite catches all 22.
    • PortableVsSIMDTests alone also catches all 22.
    • Before these tests, three Quat4d bugs went unnoticed, because the old Quat4d tests only multiplied identity by i.
  • Results: 311 tests pass with simd and 319 without. Both check-inlinable configurations pass, and lint is clean.

The same simd comparison for all other functions that have both implementations (108 in total) will come in a separate PR.

https://claude.ai/code/session_011WSUtoLptjea3s3iC5wTNC

Guided by the Benchmarks package:

- 4x4 adjugate, determinant and portable inverse now share one Laplace
  expansion by 2x2 minors (12 shared factors) instead of re-deriving
  every 3x3 minor and computing the determinant separately, and build
  the result from columns instead of 16 element-wise subscript writes.
  adjugate is FirebladeMath's own code on every platform.
- The portable quaternion Hamilton product uses four 4-wide
  multiply-adds instead of 16 scalar multiplies.

Mat4x4f p50 (M1 Max): portable inverse 21 -> 16 ns, adjugate
17 -> 15 ns (both paths), portable Quat4f multiply 13 -> 12 ns. A fully
vectorized adjugate measured slower (16/17 ns) and was not kept.

Tests: Matrix4x4CofactorTests compares adjugate, determinant and
inverse against an independent cofactor expansion on 32 matrices in
Float and Double, plus a singular matrix.

Claude-Session: https://claude.ai/code/session_011WSUtoLptjea3s3iC5wTNC
The Swift 6.1 and 6.2 compilers (Linux, Windows, macOS 6.1 CI) failed
with "unable to type-check this expression in reasonable time" on the
determinant sum in adjugateAndDeterminant and on the vectorized
quaternion product. Explicitly type every intermediate and split the
sums into short sub-expressions.

Grouping the quaternion product as (term0 + term1) + (term2 - term3)
also shortens its dependency chain: portable Quat4f.multiply 12 -> 10 ns,
portable Mat4x4f.inverse 16 -> 15 ns (p50, M1 Max).

Claude-Session: https://claude.ai/code/session_011WSUtoLptjea3s3iC5wTNC
A mutation run over the changed code (22 injected single-point bugs)
showed that three faults in the portable Quat4d Hamilton product went
unnoticed: the existing Quat4d tests only multiplied identity by i.

QuaternionMultiplicationTests checks Float and Double against an
independent scalar reference on 64 pseudo-random pairs, the basis rules
(i*j = k, j*i = -k, i*i = -1, ...) and |p*q| = |p|*|q|. With it, all 22
mutations are caught.

Claude-Session: https://claude.ai/code/session_011WSUtoLptjea3s3iC5wTNC
PortableVsSIMDTests runs FirebladeMath's portable 4x4 inverse,
determinant, adjugate and quaternion multiplication (Float and Double)
against Apple's simd, which is what FirebladeMath forwards to when the
simd trait is on. Inputs: 256 pseudo-random matrices / quaternion pairs
plus identity, diagonal, permutation, rotation+translation, small and
large scale, singular matrices (determinant only) and the quaternion
basis. simd has no adjugate, so the reference is inverse * determinant.
Tolerances are ~10x the worst measured rounding difference.

The suite is only compiled when simd is importable but the trait is off,
so the macOS CI job gets a second test run with --disable-default-traits.

On its own, the suite catches all 22 injected single-point bugs in the
changed code.

Claude-Session: https://claude.ai/code/session_011WSUtoLptjea3s3iC5wTNC
@ctreffs
ctreffs enabled auto-merge (squash) October 17, 2026 18:37
@ctreffs
ctreffs merged commit 6339ab8 into master Oct 17, 2026
16 checks passed
@ctreffs
ctreffs deleted the perf/portable-inverse-quat-multiply branch October 17, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant