# 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** (current default) | **55 s (−30 %)** | 167 s (−75 %) | 236 / 236 | 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 ` 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>` (PCA in `normalise_euclidean`) * `Eigen::ColPivHouseholderQR>` (`MobiusMap::from_three`) * `Eigen::internal::tridiagonalization_inplace>` * `Eigen::HouseholderSequence>` 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: 1. **Precompiled headers** — shared PCH covering CGAL + Eigen + gtest + the std headers every test uses. Implemented; **shipped**, opt-out via `-DCONFORMALLAB_USE_PCH=OFF`. See `code/tests/cgal/CMakeLists.txt`. 2. **`extern template` for the worst Eigen instantiations** — declare `extern template` in 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 from `extern template` is 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. 3. **Header split** for `** — 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. 4. **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's `TEST(...)` macros + per-file `using 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 * **`-j` scaling 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 `-j1` for 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. ## 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. | ### Mode matrix at a glance | Scenario | Configure (s) | Full build (s) | Incremental edit-rebuild (s) | |---|---:|---:|---:| | Default (PCH + Unity) | 5 | **55** | ~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) | ## 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: 1. **PCH artefacts are not cached by default.** Apple clang's `-include-pch ...gch` path embeds timestamps that miss the cache lookup. Workaround: set `CCACHE_SLOPPINESS=pch_defines, include_file_mtime,include_file_ctime,time_macros,file_macro, system_headers` (tried; still 0 % hit on macOS). 2. **Unity Build .cxx files** have generated paths that change between configurations; ccache treats each as a fresh compile. 3. **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`.