Skip to content

Eigen 3.4.0 -> 5.0.1: no measurable performance change on Apple Silicon - #1152

Merged
danielepanozzo merged 5 commits into
mainfrom
danielepanozzo/eigen-5.0.1
Nov 4, 2026
Merged

danielepanozzo merged 5 commits into
mainfrom
danielepanozzo/eigen-5.0.1

Conversation

@danielepanozzo

@danielepanozzo danielepanozzo commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Bumps Eigen from 3.4.0 to 5.0.1 (2025-11-11), the latest official release. On an M3 Max, performance is unchanged: across all 222 ctest tests, the sum of best times is 197.4 s on 3.4.0 and 198.1 s on 5.0.1 (+0.4%). Every heavy test lands within -0.8%..+1.1%, and single-threaded tetwild is within 1% in every phase. All tests pass on both versions. Results are not bit-identical. The case for merging is staying current, not speed.

Changes

  • cmake/recipes/eigen.cmake: GIT_TAG 3.4.0 -> 5.0.1, still from Google's mirror. The mirror's tag is the same commit as GitLab's, bc3b39870ecb690a623a3f49149a358b95c5781d, checked with git ls-remote against both.

  • components/tetwild/.../orig/State.cpp: includes <cassert> before igl/bounding_box_diagonal.h. This was the only build break with Clang, and it comes from a dependency. In header-only mode the pinned libigl (2022, 3ea7f948) includes mat_max.cpp/mat_min.cpp, which call assert() without including <cassert>. Eigen 3.4 had been including it for them; Eigen 5 does not. The include gets its own block so that clang-format's include sorting doesn't move it after the libigl header.

  • cmake/recipes/polysolve.cmake: 231f7e8 -> 1b01e6cec1 (Update Eigen to 5.0.1 and fix resulting MSVC SparseQR build polyfem/polysolve#109), polysolve's own Eigen 5.0.1 fix and the one commit after the previous pin. It fixes the MSVC-only break below. Its other changes don't reach wmtk: polysolve's eigen and json recipes return early because wmtk creates both targets first, and the new allow_non_grad_convergence option (default false) only changes the log level of a finished solve. On macOS with Eigen 5.0.1, all 222 ctest tests pass with the new pin, and serial tetwild (W1 and W2 below) writes byte-identical meshes to the old pin at the same speed: 145.8 s vs 145.9 s, and 120.4 s vs 120.4 s. The measurements below were taken with the old pin and still apply.

  • components/simwild/.../test_simwild_tracking.cpp: tracking-example-simwild-volume's round-trip bound goes from 1e-6 to 1e-5. After main (with Shared holding of curves and points in the 2D and 3D bases (triwild, tetwild, simwild 2D and 3D) #1150) was merged in, it failed on arm64 Release only, macOS and Linux: the worst of its 2,000 points came back off by 1.26e-6 (Apple clang) and 1.93e-6 (GCC). On the same tree with Eigen 3.4, that point comes back off by 3.5e-7, so the old bound only had a 3x margin. Both versions build the same map, with identical stats, and the same point is the worst. I traced it operation by operation. Its error stays at rounding (1e-15) until one operation raises it to 1e-12. Then a radial map leaves its patch through a sliver face, 3e-6 as high as it is long, at a grazing angle, and magnifies the error about 6e5 times. That is the mechanism the test's own comment names. None of the inverted operations is on its path. 1e-5 is still about 1000x below what a map that isn't a bijection would miss by, which is a tet's size (1e-2 and up there). The two other 1e-6 round-trip bounds (test_simwild_tracking.cpp:342, test_tetwild_tracking.cpp:354) pass everywhere and are unchanged.

Why the polysolve bump. The first CI run failed only on Windows, in polysolve's linear/Solver.cpp, with '_MatrixType': is not a class or namespace name. polysolve forward-declared Eigen::SparseQR to give it a default ordering and named the parameter _MatrixType. MSVC resolves that default against the parameter names of Eigen's own definition, which were _MatrixType in 3.4 and are MatrixType_ in 5. Clang and GCC use the declaration that wrote the default, so only MSVC broke. The other CI jobs passed on that first run: Linux x86-64 and arm64, and macOS, in Debug and Release, plus pre-commit. With the new pin (ad522bad4d), every job passed, both Windows ones included.

Nothing in wmtk uses the APIs 5.0 removed or moved: Eigen::all/Eigen::last (now Eigen::placeholders::), and aligned_allocator's std::allocator base.

The ABI pin still holds. EIGEN_MAX_ALIGN_BYTES=16 / EIGEN_MAX_STATIC_ALIGN_BYTES=16 are honoured by 5.0's ConfigureVectorization.h exactly as in 3.4. Both macros are guarded by #ifndef, and the only override is EIGEN_DONT_ALIGN, which nothing defines. The build reaches 413 units with the pin, 138 of them also with ipc-toolkit's EIGEN_DONT_VECTORIZE. wmtk_test_manifold_extraction and wmtk_test_topological_offset pass; they are the tests that caught the split ABI before. #1151 carries over. #1151 has since landed, and this branch merges main without conflicts. On x86-64 it now sets EIGEN_MAX_ALIGN_BYTES to 32 (64 for AVX-512), so every unit allocates with handmade_aligned_malloc at one alignment. Everywhere else it stays 16, so arm64, where everything below was measured, is unaffected. 5.0.1 applies the same rules as 3.4: EIGEN_DEFAULT_ALIGN_BYTES is the larger of EIGEN_MAX_ALIGN_BYTES and the inferred ideal, Memory.h takes malloc only where that is 16, and handmade_aligned_malloc/handmade_aligned_realloc default to the calling unit's alignment. The block layout differs: 5.0.1 stores a one-byte offset before the block where 3.4 stored the original pointer. A build only ever contains one Eigen, so this does not matter. On macOS the merged tree passes all 223 ctest tests, among them #1151's eigen-heap-one-allocator-across-units. Only CI's x86 jobs run the 32-byte path. On the merge commit (3b36a69b71), Linux x86-64 and Windows, in Debug and Release, configured with "Eigen allocates by handmade_aligned_malloc() at 32 bytes", and every CI job passed all of its tests, eigen-heap-one-allocator-across-units included.

Measurements

Setup. Apple M3 Max (12P + 4E), macOS 26.6.2, Apple clang 21.0.0, Release (-O3 -DNDEBUG), default options (all components, unit and component tests, Python bindings), main at dea81789aa. Main has since gained #1151 and the isotropic_remeshing changes in #1137 and #1139; both sides of the A/B were built from the same base.

There were two build directories from the same checkout. The Eigen 5.0.1 one differed only by -DCPM_eigen_SOURCE=<5.0.1 checkout>. After normalizing paths, all 1045 compile commands are identical apart from the Eigen include path. A fresh configure of this branch with no override fetches the same commit, and its headers are byte-identical to the benchmarked ones.

Method. The machine was shared with other jobs (load average 3-14), so the two builds ran interleaved, test by test, alternating which went first, and the tables compare the best time per test. Load only ever adds time, so the minimum is the cleanest signal. 604 timed runs, 0 failures.

ctest: all 222 tests

test runs per build 3.4.0 best 5.0.1 best 5.0.1 / 3.4.0 3.4.0 range 5.0.1 range
wmtk_test_tetwild 7 76.05 s 76.49 s 1.006 76.0–85.7 76.5–93.4
wmtk_test_topological_offset 7 40.35 s 40.33 s 0.999 40.4–44.3 40.3–44.7
wmtk_test_simwild 7 32.00 s 32.18 s 1.006 32.0–33.9 32.2–34.7
wmtk_test_isotropic_remeshing 15 13.65 s 13.62 s 0.998 13.6–17.4 13.6–16.4
wmtk_test_triwild 7 6.92 s 6.98 s 1.009 6.9–7.5 7.0–7.7
wmtk_test_tracking 7 6.61 s 6.64 s 1.004 6.6–7.2 6.6–7.1
wmtk_test_qslim 7 5.37 s 5.38 s 1.003 5.4–5.9 5.4–5.7
TetWild gives the same result from memory and from a file 7 4.85 s 4.91 s 1.011 4.9–5.2 4.9–5.2
SimWild gives the same result from memory and from a file 7 2.23 s 2.25 s 1.011 2.2–3.1 2.3–2.4
Tagged meshing offset and prismatic processing compose in memory 7 1.70 s 1.70 s 1.001 1.7–1.8 1.7–1.8
TetWild embeds a named surface in memory 7 1.15 s 1.15 s 0.995 1.2–1.2 1.1–1.2
wmtk_test_shortest_edge_collapse 7 0.88 s 0.87 s 0.992 0.9–1.1 0.9–1.1
the other 210 tests 1 5.62 s total 5.65 s total 1.005
all 222, sum of best 197.4 s 198.1 s 1.004

Two early numbers looked like real effects and turned out to be noise:

  • tetwild, +16%. One interleaved round showed wmtk_test_tetwild 16% slower on 5.0.1. Nearly all of that was tetwild-surface-folds-none-made, a full 8-thread run whose operation order depends on the schedule. That case ranges 25.5–35.1 s on 3.4.0 and 25.2–39.1 s on 5.0.1, with best times 25.45 s vs 25.17 s.
  • isotropic_remeshing, 6–10% faster. Under load, wmtk_test_isotropic_remeshing looked faster on 5.0.1. Eight more pairs on a quieter machine (load about 3) were very tight: 13.66–13.70 s vs 13.70–13.75 s, 5.0.1 between 0.2% and 0.6% slower in every pair.

On the quieter machine, two more rounds of the heavy tests gave a sum of best times of 197.68 s vs 196.26 s (0.993).

tetwild, single-threaded (num_threads: 0), bunny via wmtk_app

  • W1 is the serial twin of tetwild-surface-folds-none-made: check_surface_folds, history_output, max_iterations: 10.
  • W2 is the default parameters.

3 runs per build, alternating order:

3.4.0 runs 5.0.1 runs best 5.0.1 / 3.4.0
W1 wall 155.7, 147.9, 146.0 s 166.5, 150.6, 145.9 s 1.000
W2 wall 141.7, 122.8, 120.7 s 128.7, 126.5, 120.4 s 0.998

Best time per phase, from the TETWILD_PHASES log line, as 5.0.1 / 3.4.0:

simplify insertion optimization finalize
W1 0.997 1.009 1.001 0.990
W2 0.996 1.008 0.999 0.996

Peak RSS was 2–4% lower on 5.0.1: W2 373–378 MB vs 385–393 MB, W1 401–417 MB vs 418–424 MB. This is the /usr/bin/time -l maximum resident set size, and the two versions build slightly different meshes (see below), so read it as "not worse" rather than as a win.

Results are not bit-identical

Each build is deterministic: across its own serial runs, only the timing lines of the log differ. Between 3.4.0 and 5.0.1, the outputs differ from the first stage on.

  • After simplification, the simplified bunny has identical connectivity (16185 vertices, 32165 faces, same face list), but 5561 vertices differ in the last bit, by at most 2.8e-17. That is a different rounding order inside Eigen expressions.
  • Through the optimization, the differences propagate: in the first smoothing pass, Newton iteration counts shift by a handful out of 17767 solves.
  • At the end, the outputs match closely. W1: max energy 39.4487 on both, average 6.15051 vs 6.15047, same final #V/#T. W2: max 39.4487 and average 6.31673 on both.

No test depends on exact output, and all of them pass.

Python tests

Same pass counts on both versions. These are not registered by ctest here, because Homebrew's Python 3.15 has no NumPy, so I ran them from a venv with NumPy 2.5.4:

file tests 3.4.0 5.0.1
test_memory_io.py 17 OK, 3.6 s OK, 3.6 s
test_tracking.py 27 OK, 40.5 s OK, 39.2 s
test_tracking_correspondences.py 4 OK, 32.1 s OK, 31.8 s

Not measured

  • Performance on Linux and Windows. CI shows the GCC and MSVC builds pass their tests, but nothing here was timed on x86, whose SIMD paths could behave differently from NEON.
  • Compile time. The 5.0.1 build was split across two runs by the libigl break, so I have no clean wall-time comparison.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ULwCxzbAACoHpC1mnmkvr8

danielepanozzo and others added 5 commits October 31, 2026 00:06
5.0.1 (2025-11-11) is the latest official release; the mirror's tag is the
same commit as GitLab's, bc3b39870ecb690a623a3f49149a358b95c5781d.

Nothing in wmtk uses the APIs 5.0 removed or moved (Eigen::all/last,
aligned_allocator's std::allocator base). One build break, in a dependency:
the pinned libigl's mat_max.cpp/mat_min.cpp, which bounding_box_diagonal.h
includes in header-only mode, call assert() without including <cassert>, and
Eigen 3.4 had been including it for them. tetwild's orig/State.cpp, the one
unit that reaches them, now includes <cassert> first.

The ABI pin (EIGEN_MAX_ALIGN_BYTES/EIGEN_MAX_STATIC_ALIGN_BYTES=16) is
unchanged and still honoured: 5.0's ConfigureVectorization.h guards both
with #ifndef, and Memory.h picks malloc vs handmade_aligned_malloc the same
way as 3.4.

Performance on an M3 Max: no measurable difference. Over all 222 ctest
tests, interleaved A/B, the sum of best times is 197.4 s on 3.4.0 and
198.1 s on 5.0.1 (1.004); every heavy test is within 0.992-1.011.
Single-threaded tetwild on bunny is within 1% per phase. Results are not
bit-identical (last-bit differences from the first simplification on) and
the final quality matches to five digits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULwCxzbAACoHpC1mnmkvr8
Both Windows jobs of #1152 stopped in polysolve's linear/Solver.cpp:
"'_MatrixType': is not a class or namespace name", then "SparseQR: too
few template arguments". polysolve forward-declared Eigen::SparseQR to
give it a default ordering, naming the parameter _MatrixType. MSVC
resolves that default against the parameter names of Eigen's own
definition -- which were _MatrixType in 3.4, and are MatrixType_ in 5.
Clang and GCC look at the declaration that wrote the default, so only
Windows broke.

polysolve fixed it upstream in 1b01e6cec1 (polysolve#109, "Update Eigen
to 5.0.1 and fix resulting MSVC SparseQR build"), the one commit after
the previous pin: an alias that spells the ordering out instead of the
forward declaration. Its other changes do not reach wmtk: its own eigen
and json recipes return early because wmtk creates both targets first,
and the new allow_non_grad_convergence option (default false) only
changes the level a finished solve is logged at.

Checked on macOS with Eigen 5.0.1: all 222 ctest tests pass, and serial
tetwild on bunny (both workloads of #1152) writes byte-identical meshes
to the old pin, at the same speed (145.8 s vs 145.9 s, 120.4 s vs
120.4 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULwCxzbAACoHpC1mnmkvr8
… sliver

The test failed on arm64 Release (macOS and Linux) after main was merged into the Eigen 5.0.1
branch: the worst of its 2,000 points came back off by 1.26e-6 (Apple clang) and 1.93e-6 (GCC),
over the 1e-6 bound. With Eigen 3.4 the same point comes back off by 3.5e-7, so the bound held by
a factor of 3.

The map is the same with both Eigen versions (same operations built, same pairs, 25 inverted
operations, none of them on this point's path), and so is the mechanism. The point's error stays
at rounding (1e-15) back to operation 513, which raises it to 5e-13 (3.4) or 2e-12 (5.0.1), and
operation 235's radial map then magnifies it about 6e5 times: its ray leaves the patch through a
face 3e-6 as high as it is long, at a grazing angle, and the cap map carries the point's
coordinates on that face across. That is the "rays can graze a face of a sliver" the comment
names; Eigen 5 only changes the last bits that reach it.

1e-5 leaves 5x over the worst measured, and is still three orders below what a map that is not
a bijection would miss by, the size of a tet, 1e-2 and up there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULwCxzbAACoHpC1mnmkvr8
@danielepanozzo
danielepanozzo merged commit 46f9876 into main Nov 4, 2026
12 checks passed
@danielepanozzo
danielepanozzo deleted the danielepanozzo/eigen-5.0.1 branch November 4, 2026 22:42
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