Files
ConformalLabpp/doc/architecture/compile-time.md
Tarik Moussa 9ea7d15aa0 perf: add 4 workflow modes (BUILD_TESTING / DEV_BUILD / HEADERS_CHECK / ccache)
Four orthogonal opt-in build modes on top of the existing PCH + Unity
defaults.  Each addresses a specific iteration scenario; defaults are
unchanged (PCH + Unity stays the canonical fast full-rebuild path).

(A) HEADERS_CHECK target — opt-in via -DCONFORMALLAB_HEADERS_CHECK=ON
    Per-public-header smoke-compile sentinels.  For each of the six
    public CGAL umbrella headers, a stub TU `#include <…>\nint main(){}`
    is generated at configure time and compiled in isolation.
      * Full headers_check build:  ≈ 12 s
      * Incremental after touching one header:  ≈ 0.1 s
    Use case: "did my refactor still parse the public API?" without
    waiting 55 s for the full CGAL test build.

(C) DEV_BUILD mode — opt-in via -DCONFORMALLAB_DEV_BUILD=ON
    PCH stays on; Unity Build is forced off (both globally AND on the
    cgal-tests target which previously overrode the global setting).
    Trade-off: full clean rebuild ~75 s (+36 % vs the 55 s default)
    but incremental rebuild after editing a single test file drops
    from ~46 s (unity batch) to ~16 s (single TU + relink).
    Flip on for trial-and-error sessions, flip off before measuring
    CI build time or shipping a PR.

(D) ccache integration — default ON, disable with -DCONFORMALLAB_USE_CCACHE=OFF
    Detects `ccache` on PATH and prepends it to compile + link
    launchers.  On Apple clang + PCH + Unity the macOS-local hit
    rate is currently 0 % (3 separate friction points documented
    in doc/architecture/compile-time.md § "ccache — honesty notes");
    stays neutral when it doesn't help.  Real payoff on Linux CI
    (g++ + traditional PCH) where 80 %+ hit rates are typical.

(BUILD_TESTING=OFF) Standard CMake gate, now respected end-to-end.
    Wrapped both `add_subdirectory(tests)` AND the FetchContent of
    GoogleTest in `if(BUILD_TESTING)`.  Pass `-DBUILD_TESTING=OFF`:
      * Configure ≈ 1 s
      * Build ≈ 0 s
      * 0 object files
      * No GTest fetch
    Use case: IDE-syntax-check workflow that needs
    `compile_commands.json` but does NOT need to download GTest
    or build any test binary.

doc/architecture/compile-time.md gains:
  * a "Workflow modes — what to choose when" section with a 4-row
    switch matrix and a "mode matrix at a glance" comparison table
  * a ccache honesty-notes block listing the three macOS friction
    points (PCH artefact caching, Unity Build path randomisation,
    CMake launcher integration) — Linux CI is where the lever pays
    off

Verified: default build 53 s wall, 236/236 tests pass; all opt-in
modes tested end-to-end with their expected workflow numbers
documented in the doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:15:09 +02:00

9.0 KiB
Raw Blame History

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.