# 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 <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 in
  `normalise_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:

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 `<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.
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`.
