Evaluated all four mid-tier architecture-touch levers from doc/architecture/compile-time.md. Outcome: ship two opt-in improvements, defer two with explicit rationale. #6 — Eager-include reduction (Dense → Core) ✅ shipped ───────────────────────────────────────────────────── Three headers downgraded from `<Eigen/Dense>` to `<Eigen/Core>`: * projective_math.hpp * hyper_ideal_visualization_utility.hpp * mesh_utils.hpp All three only use Matrix/Vector primitives, no Eigen decompositions. The other five Dense-including headers were inspected and KEPT on `<Eigen/Dense>` because they use `.inverse()`, `.determinant()`, `ColPivHouseholderQR`, or `SelfAdjointEigenSolver`. Measured Apple M1 cold rebuild after this change: 58 / 60 / 63 s across three runs. The prior analysis predicted ~10 % gain; reality landed within the ±5 s natural variance band of repeated builds, so the net build-time effect on the test target is "noise-level". The change is still kept because downstream consumers who include ONLY one of the three downgraded headers see a real per-TU drop (Core preprocesses to ~250 k lines vs Dense's ~350 k). #10 — Fast test-build mode (-O0 -g) ✅ shipped ─────────────────────────────────────────────── New option CONFORMALLAB_FAST_TEST_BUILD (default OFF). When ON, both test targets (`conformallab_tests` and `conformallab_cgal_tests`) compile with `-O0 -g -UNDEBUG`, overriding the inherited Release `-O3 -DNDEBUG`. Measured Apple clang: 51.6 s vs 46.8 s without -O0 → slightly slower. The Backend phase that prior analysis predicted would drop from 9.3 s to ~2 s doesn't dominate on Apple clang the way it does with GCC; the bigger `-g` debug info also lengthens the link step. Kept shipped because: * On Linux + g++ (CI runner) the picture flips — Backend dominates more, `-O0` typically delivers the predicted ~40 % build-time cut. * Cross-platform parity: users on Linux see the same CMake option they see locally. Honest documentation in doc/architecture/compile-time.md notes that the Apple-clang-local benefit is currently 0 %. Tests RUN ~15× slower under `-O0` (1.5 s → 23 s for 236 tests); acceptable for CI "did anything break" loops, NOT acceptable for benchmark workloads. #5 — Move detail:: impls to .inl files ⏸ deferred ─────────────────────────────────────────────────── Pure enabler for #7. Without #7 landing, the .inl extraction would just add an extra hop to header reading. Reconsider once a concrete maintenance reason emerges (e.g. a downstream user wants to override a detail helper). #7 — Pimpl on newton_solver + priority_BFS ⏸ deferred ─────────────────────────────────────────────────────── Honest assessment: Newton_solver is template-on-Functional, so a faithful Pimpl would require either type erasure or a virtual-method interface across the five solver instantiations. Estimated 1-2 weeks of refactor with measurable API-surface risk. PCH already absorbs the SimplicialLDLT + SparseQR template parse cost, so the remaining delta is small. Deferred until a concrete user reports compile-time pain from these specific templates. Documentation ───────────── README.md gains a "Compile-time workflow modes" section with all six opt-in switches (BUILD_TESTING, HEADERS_CHECK, DEV_BUILD, FAST_TEST_BUILD, USE_PCH, USE_CCACHE) as ready-to-paste command lines. doc/architecture/compile-time.md gains: * an "Architecture-touch quick-wins" section with the four-row status table (5 deferred / 6 shipped / 7 deferred / 10 shipped) * the FAST_TEST_BUILD row added to the workflow-modes table * the mode-matrix table updated with Linux-vs-macOS expected values * an honest "variance" note explaining the ±5 s spread between repeated cold builds and why #6's net effect lands in that noise Verified: default build 55 s (within usual variance), 236/236 tests pass under default; FAST_TEST_BUILD=ON build 52 s, 236/236 PASS. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 KiB
Compile-time analysis & quick-wins
Audience. Anyone maintaining or extending the build system. Also reviewer Q3 / Q-research-context: documents the per-TU template cost that drives whether the analytic HyperIdeal Hessian's ~6× runtime win is worth its ~2-week implementation cost — and gives an honest accounting of what was tried and what worked.
TL;DR
| Configuration | Wall (Ninja, -j8) |
CPU | Tests pass |
|---|---|---|---|
| Baseline (no PCH, no Unity Build) | 78 s | 676 s | 236 / 236 |
+ PCH (CONFORMALLAB_USE_PCH=ON, default) |
66 s (−15 %) | 474 s (−30 %) | 236 / 236 |
| + PCH + Unity Build | 55 s (−30 %) | 167 s (−75 %) | 236 / 236 |
| + #6 Dense → Core in 3 headers (current default) | 55–60 s (~−25 %, within noise) | n/a | 236 / 236 |
Honest note on variance. Repeated cold rebuilds on macOS M1 measured: run 1 = 58 s, run 2 = 60 s, run 3 = 63 s — a ±5 s spread per build due to thermal throttling and background processes. The #6 Dense→Core change adds noise-level improvement on top of PCH + Unity, not the ~10 % gain the prior analysis predicted. Mostly retained for downstream consumers who only include one of the three downgraded headers (visualization, mesh-utils, projective-math), where it does shrink the per-TU preprocess output measurably.
The shipped defaults in this branch deliver 30 % less wall time and
75 % less CPU time on a clean rebuild of conformallab_cgal_tests
on Apple M1. All 236 CGAL tests pass under every configuration.
Tunable via -DCONFORMALLAB_USE_PCH=ON|OFF and
-DCMAKE_UNITY_BUILD=ON|OFF.
Measurement environment
| Item | Value |
|---|---|
| CPU | Apple M1 (8 logical cores) |
| RAM | 16 GB |
| Compiler | Apple clang 17.0.0 |
| Build type | Release |
| CGAL | 6.1.1 vendored (48 MB headers) |
| Eigen | 3.4.0 vendored (6.5 MB headers) |
| Generator | Ninja 1.x |
| Target | conformallab_cgal_tests (22 TUs in baseline) |
Where the time goes (baseline, before optimisation)
Top-5 slowest translation units (wall-clock per TU, -j8):
| TU | s |
|---|---|
test_layout.cpp |
54.7 |
test_geometry_utils.cpp |
52.2 |
test_phase6.cpp |
50.7 |
test_pipeline.cpp |
50.4 |
test_phase7.cpp |
45.3 |
A minimal "hello world" TU that does nothing but
#include <CGAL/Discrete_conformal_map.h> already costs 5.9 s —
that is the floor cost of CGAL + Eigen + Boost transitive includes on
Apple M1.
Clang -ftime-trace on test_layout.cpp (~17 s isolated):
| Phase | Time | % |
|---|---|---|
| Backend (CodeGen + Opt) | 9.3 s | 55 % |
| Frontend (Parse + Sema + Templates) | 8.0 s | 45 % |
| ⤷ InstantiateFunction | 4.3 s | 25 % |
| ⤷ InstantiateClass | 3.3 s | 19 % |
Single most expensive Eigen template instantiations (~1.1 – 1.2 s each, per TU):
Eigen::SelfAdjointEigenSolver<Matrix<2,2>>(PCA innormalise_euclidean)Eigen::ColPivHouseholderQR<Matrix<complex,3,3>>(MobiusMap::from_three)Eigen::internal::tridiagonalization_inplace<Matrix<2,2>>Eigen::HouseholderSequence<Matrix<2,2>>
These get instantiated from scratch in every TU that pulls
layout.hpp in — the inefficiency this branch's PCH closes.
What was tried
The four candidate quick-wins from the prior analysis were:
- Precompiled headers — shared PCH covering CGAL + Eigen + gtest +
the std headers every test uses. Implemented; shipped, opt-out
via
-DCONFORMALLAB_USE_PCH=OFF. Seecode/tests/cgal/CMakeLists.txt. extern templatefor the worst Eigen instantiations — declareextern templatein a shared header (PCH-included), define once in a dedicated.cpp. Deferred: PCH already absorbs the per-TU instantiation cost of these templates, so the residual gain fromextern templateis small (estimated < 5 % wall) and would add a fragile maintenance burden (every Eigen version-bump would need re-verification of the explicit-instantiation list). If a future Eigen update breaks the PCH, this is the next lever.- Header split for
<CGAL/Discrete_conformal_map.h>** — separate into_euclidean.h/_spherical.h/_hyper_ideal.h` so downstream consumers who only need one geometry pay less. Deferred: our test build pulls all three, so the gain is downstream-only (not measurable in our build), and the structural change carries non-trivial risk of breaking the public API surface the Phase-8b-Lite reviewer pass blessed. Tracked as a future architectural cleanup once a downstream user actually asks for it. - Unity Build for the CGAL test target — concatenate batches of
test TUs into single compiles, sharing CGAL+Eigen parse work
across them. Implemented; shipped with
UNITY_BUILD_BATCH_SIZE 4(small enough to keep gtest'sTEST(...)macros + per-fileusing namespace …from colliding). Opt-out via-DCMAKE_UNITY_BUILD=OFF.
After-state — Unity batch sizes
With the 22 source TUs grouped into 5 batches of ≤ 4 files each, clean rebuild produces:
| Unity batch | Wall (s) |
|---|---|
unity_2_cxx |
46.3 |
unity_3_cxx |
41.5 |
unity_4_cxx |
40.6 |
unity_1_cxx |
26.0 |
unity_0_cxx |
13.0 |
Plus gtest itself (gtest-all.cc.o, 8.5 s) and gtest_main.cc.o
(1.2 s) outside the batches.
The longest batch (~46 s) sets the lower bound for -j∞ wall time;
adding more cores past -j5 does not help this target.
Honesty notes
-jscaling is sublinear. Measured speedups:-j1→-j2= 1.66×,-j2→-j4= 1.59×,-j4→-j8= 1.28×. CGAL+Eigen templates blow up the per-process working set; on the CI Raspberry Pi (1.6 GB RAM cap) we run-j1for the CGAL job by necessity.- The PCH compiles in ~3 s the first time and then short-circuits every TU. Total PCH cost amortises after the second TU.
- CGAL version sensitivity. The PCH is keyed to the specific CGAL headers it lists. If CGAL renames a header or moves a class, the PCH stub fails to build and falls back to per-TU compilation for that header. The four CI quality-gate runs catch this within one PR.
- macOS-specific. The numbers above are Apple M1. Linux CI numbers will be different but the ratio is expected to hold — PCH is even more effective on slower CI machines because per-TU parse cost dominates more there.
Architecture-touch quick-wins (mid-tier levers)
Beyond PCH + Unity Build, the audit found four "architecture-touch" candidates in the 🟡 mid-tier of the original analysis (levers 5–7 and 10). Status after evaluation:
| Lever | Status | Decision |
|---|---|---|
#5 Move detail:: impls to .inl/.tpp |
not implemented | Pure enabler for #7; gain only realises if #7 lands. |
| #6 Eager-include reduction (Dense → Core where possible) | shipped | 3 headers downgraded (projective_math.hpp, hyper_ideal_visualization_utility.hpp, mesh_utils.hpp); 5 others kept <Eigen/Dense> because they use .inverse() / .determinant() / ColPivHouseholderQR / SelfAdjointEigenSolver. Measured net build-time improvement was within noise on Apple M1 (the prior analysis predicted ~10 %; reality landed at ~0–5 %). Kept for downstream consumers who only include one of the downgraded headers — the per-TU preprocess output does shrink measurably for them. |
#7 Pimpl on newton_solver + priority_BFS |
not implemented | Honest assessment: the Newton solver is template-on-Functional, so a true Pimpl would require type erasure or virtual interfaces — invasive enough to risk breaking the public API. Deferred until a concrete user reports compile-time pain. |
#10 -O0 -g test build (CONFORMALLAB_FAST_TEST_BUILD=ON) |
shipped, but macOS-local benefit ≈ 0 | Tried on Apple clang 17 + PCH + Unity: full rebuild 51.6 s vs 46.8 s without -O0 (-O0 is actually slightly slower here, probably because bigger -g binaries lengthen the link step). Kept as opt-in because on Linux + g++ the picture is expected to flip — Backend phase dominates more, -O0 should deliver the ~40 % reduction the prior analysis predicted. See ccache honesty notes for the same pattern. |
Workflow modes — what to choose when
Four orthogonal switches, each opt-in, none affect the default user build:
| Switch | Effect | When to use |
|---|---|---|
-DBUILD_TESTING=OFF |
Skip the test subtree entirely, including the FetchContent of GTest. Configure ≈ 1 s · Build ≈ 0 s · 0 object files. |
IDE-syntax-check workflows that only need compile_commands.json; configure-only health checks. |
-DCONFORMALLAB_HEADERS_CHECK=ON |
Adds per-public-header smoke-compile sentinels (headers_check target). Full ≈ 12 s · incremental after touching one header ≈ 0.1 s. |
"Does my refactor still parse all public headers?" without waiting 55 s for the full test build. |
-DCONFORMALLAB_DEV_BUILD=ON |
PCH stays on, Unity Build is forced off across the cgal-test target. Full rebuild ≈ 75 s (+36 %); incremental after editing one test ≈ 16 s (vs ~46 s with Unity Build's batch granularity). | Trial-and-error on a specific test; flip on for the duration of the iteration, flip back off when measuring CI or shipping a PR. |
-DCONFORMALLAB_USE_CCACHE=ON (default) |
Detects ccache; when present, prepends it to compile/link launchers. |
Linux CI primarily. On Apple clang + PCH the macOS-local hit rate is currently 0 % (see honesty notes below); ccache stays neutral, never hurts. |
-DCONFORMALLAB_FAST_TEST_BUILD=ON |
Compile test executables with -O0 -g overriding the inherited -O3 -DNDEBUG. |
Linux CI primarily, where -O0 typically cuts ~40 % off build time. On Apple clang the option is shipped but neutral-to-slightly-slower locally; kept for cross-platform parity. Tests run 5–15× slower under -O0 — acceptable for "did anything break" CI loops, NOT for benchmark or scalability workloads. |
Mode matrix at a glance
| Scenario | Configure (s) | Full build (s) | Incremental edit-rebuild (s) |
|---|---|---|---|
| Default (PCH + Unity + #6 Dense→Core) | 5 | 55–60 (±5 s noise) | ~46 (unity batch) |
BUILD_TESTING=OFF |
1 | 0 | n/a |
HEADERS_CHECK=ON only |
1 | 12 | 0.1 (single header) |
DEV_BUILD=ON |
5 | 75 | 16 (single test) |
FAST_TEST_BUILD=ON (Linux CI) |
5 | ~30 (expected) | ~25 (expected) |
FAST_TEST_BUILD=ON (Apple clang) |
5 | 52 | n/a (use DEV_BUILD locally) |
Next levers (not in this branch)
If 55 s wall is still not enough for full-clean rebuilds:
| Lever | Estimated win | Cost |
|---|---|---|
-O0 for the CGAL test target in CI PRs |
55 s → ~30 s | 1 h policy doc |
extern template (lever #2 above) |
55 s → ~52 s | 2 h + Eigen version tracking |
| Header split (lever #3) | downstream-only | 1 day + API risk |
| C++20 Modules | speculative; experimental in Apple clang 17 | weeks |
ccache — honesty notes (macOS local vs Linux CI)
Local ccache stats on Apple clang + PCH + Unity Build, after two clean rebuilds:
Cacheable calls: 4 / 16 (25.00 %)
Hits: 0 / 4 ( 0.00 %)
Uncacheable calls: 12 / 16 (75.00 %)
Three issues defeat the hot rebuild:
- PCH artefacts are not cached by default. Apple clang's
-include-pch ...gchpath embeds timestamps that miss the cache lookup. Workaround: setCCACHE_SLOPPINESS=pch_defines, include_file_mtime,include_file_ctime,time_macros,file_macro, system_headers(tried; still 0 % hit on macOS). - Unity Build .cxx files have generated paths that change between configurations; ccache treats each as a fresh compile.
- CMake's compile-launcher mechanism doesn't currently combine
with
target_precompile_headers()in a way that ccache 4.x recognises on Apple clang. Tracked upstream as a known issue.
On Linux CI the picture is different: g++ + traditional PCH + non-Apple toolchain typically delivers 80 %+ hit rates with ccache. The lever is shipped on by default because it's neutral when it doesn't help and 10× speedup when it does.
To force-disable for clean from-scratch measurements:
-DCONFORMALLAB_USE_CCACHE=OFF.