CMake

Speed Up Your Build System: Advanced CMake TipsA slow build system drains developer time and momentum. CMake is a powerful meta-build system that generates native build files (Makefiles, Ninja files, Visual Studio projects, etc.). Properly using CMake can yield dramatic build-time improvements, incremental-build reliability, and lower developer friction. This article covers advanced, practical techniques to speed up your CMake-based builds for large C++ projects.


Why build speed matters

Fast builds increase iteration speed, enable more frequent testing, and reduce CI costs. Optimization points include: reducing the amount of work the compiler/linker must do, improving parallelism, cutting unnecessary rebuilds, and using better toolchains or caches.


Choose the right generator: prefer Ninja

  • Use Ninja as the default generator when possible. Ninja excels at parallel builds and has lower scheduling overhead than Make/Visual Studio for incremental builds.
  • To set Ninja: run cmake -G Ninja or configure your CI/tooling to use the Ninja generator.

Why Ninja helps: it produces focused, fine-grained build actions and schedules tasks efficiently, which is especially beneficial for projects with many small translation units.


Use target-based CMake and modern CMake practices

  • Prefer modern CMake (target_* commands) over global commands (add_definitions, include_directories, link_libraries).
  • Define dependencies and usage requirements with target_include_directories, target_compile_definitions, target_compile_options, and target_link_libraries.
  • Benefits:
    • CMake can compute precise dependency graphs, avoiding unnecessary rebuilds.
    • Targets encapsulate compile settings so incremental rebuilds are minimized.

Example:

add_library(my_lib ...) target_include_directories(my_lib PUBLIC include) target_compile_options(my_lib PRIVATE -O2 -g) 

Minimize header dependencies and use the PIMPL/opaque-pointer idiom

  • Headers drive recompilation. Reducing includes in headers and preferring forward declarations cuts rebuild scope.
  • Use the PIMPL idiom to decouple implementation details from public headers, reducing changes that force recompiles across many translation units.
  • Consider the “include what you use” approach: each file should directly include the headers it depends on.

Split large headers and avoid heavy templates in headers when possible

  • Move non-template implementations to .cpp files.
  • For heavy template code, consider explicit template instantiation to reduce compile-time duplication across TUs.

Explicit instantiation example:

// foo.cpp template class MyTemplate<int>; 

Use unity/jumbo builds selectively

  • Unity builds concatenate multiple .cpp files into one to reduce compiler overhead and improve inlining cross-TU.
  • They can drastically reduce build overhead but may hide ODR issues or increase memory usage; use for CI or release builds, not necessarily for EVERY developer workflow.
  • CMake support: create “unity” source files or use existing CMake modules (many projects have scripts to generate unity builds).

Improve header parsing with precompiled headers (PCH)

  • Precompiled headers can drastically reduce compile time for projects with expensive common headers (big STL usage, Boost).
  • Use target_precompile_headers(…) (CMake 3.16+) to add PCH in a target-safe way.
  • Ensure PCH is stable across builds—avoid frequently changing headers in the PCH set.

Example:

target_precompile_headers(my_lib PRIVATE <vector> <string> "myproject/pch.h") 

  • Link time can become dominant in large projects. Techniques:
    • Use OBJECT libraries (add_library(name OBJECT …)) to compile sources once and reuse object files in multiple targets.
    • On platforms/linkers that support it, prefer thin archives (e.g., ar –thin for GNU ar) to avoid copying object files.
    • Where available, use incremental or fast linkers (lld, gold) instead of slower system linkers.

CMake tips:

  • Create an object library for shared implementation:
    
    add_library(core_objs OBJECT a.cpp b.cpp) add_library(core STATIC $<TARGET_OBJECTS:core_objs>) 
  • Switch linker via toolchain settings or CMake variables (CMAKE_LINKER, CMAKE_CXX_COMPILER_LAUNCHER).

Use cache and compiler launchers: ccache, sccache, distcc

  • ccache and sccache cache compiled object files keyed by source + compile flags. They can drastically reduce rebuild times across clean builds or CI.
  • Configure via environment or CMake’s compiler launcher:
    
    set(CMAKE_C_COMPILER_LAUNCHER sccache) set(CMAKE_CXX_COMPILER_LAUNCHER sccache) 
  • For distributed compilation, distcc and icecc can be combined with ccache/sccache for further speedups.

Build only what changed: fine-grained targets & componentization

  • Break a monolithic target into smaller targets so modifying one library only rebuilds its consumers as necessary.
  • Use INTERFACE libraries for purely header-only components to avoid unnecessary binary targets.

Reduce unnecessary rebuilds: stable build IDs and generated files

  • Avoid generating headers or files with timestamps or nondeterministic content; these cause rebuild churn.
  • Where you must generate files, generate deterministic content and place generated headers in a consistent include directory tracked by CMake.
  • Use configure_file(… @ONLY) carefully; prefer content that changes only when inputs change.

Improve parallelism: more cores, tuned job counts, and resource control

  • Encourage developers to use -jN where N ~ cores + few. Ninja automatically scales well; with Make use make -j.
  • Be mindful of memory use. For large projects on machines with limited RAM, reduce parallelism to avoid thrashing.
  • CI runners: choose machines with more CPU and memory for faster parallel builds.

  • LTO can increase compile/link time but reduce runtime cost and possibly object size. Consider enabling LTO only for release builds or CI where tradeoffs favor runtime performance.
  • CMake supports LTO through target_link_options or via CMakePresets/toolchain flags, and via the INTERPROCEDURAL_OPTIMIZATION property.

Example:

set_target_properties(my_lib PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) 

Profile builds and identify hotspots

  • Use compiler and build system profiling tools:
    • Ninja: ninja -t commands or ninja -v to observe commands.
    • GCC/Clang: use -ftime-report or -Q –help=optimizers to see costly steps.
    • Use tools like buildprof, clcache stats, or custom timing wrappers around compiler calls.
  • Target the biggest time sinks first (e.g., particular long-compiling files, heavy templates).

Use incremental linking and faster linkers

  • Use incremental linking where supported (MSVC incremental link).
  • Prefer LLD (LLVM’s linker) or gold where they are faster than system ld; test compatibility and symbol resolution.

Leverage continuous integration caching

  • Cache compiled artifacts, ccache/sccache caches, and build output across CI runs.
  • Use CI cache keys that include compiler version, toolchain, and relevant flags to avoid stale cache misses.
  • Store dependencies (third-party builds) in cache to avoid rebuilding them each run.

Keep third-party dependencies out of hot paths

  • Vendor or package external libraries as prebuilt binaries for faster iteration.
  • Use package managers (Conan, vcpkg) with binary caches to avoid rebuilding deps each time.
  • If building from source, isolate third-party builds into separate CI jobs or cache their build output.

Practical CMake configuration checklist

  • Use Ninja generator by default.
  • Use target_* instead of global commands.
  • Add precompiled headers via target_precompile_headers.
  • Use object libraries for shared compilation units.
  • Add sccache/ccache as compiler launchers.
  • Break large targets into smaller libraries.
  • Avoid changing generated file content unnecessarily.
  • Use fast linkers (lld/gold) and enable incremental linking where useful.
  • Cache CI build artifacts and compiler caches.
  • Profile and target the slowest compile units.

Example CMake snippet combining several tips

cmake_minimum_required(VERSION 3.20) project(myproj LANGUAGES CXX) # Use ccache/sccache if available find_program(SCCACHE_EXEC sccache) if(SCCACHE_EXEC)   set(CMAKE_C_COMPILER_LAUNCHER ${SCCACHE_EXEC})   set(CMAKE_CXX_COMPILER_LAUNCHER ${SCCACHE_EXEC}) endif() add_library(core_objs OBJECT src/a.cpp src/b.cpp) target_compile_features(core_objs PUBLIC cxx_std_20) target_precompile_headers(core_objs PRIVATE <vector> <string> "include/myproj/pch.h") add_library(core STATIC $<TARGET_OBJECTS:core_objs>) target_include_directories(core PUBLIC include) target_link_libraries(core PUBLIC some_thirdparty_lib) set_target_properties(core PROPERTIES INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) 

Common pitfalls and how to avoid them

  • Overusing unity builds hides problems: use them selectively.
  • Putting frequently changed headers into PCH defeats the purpose—keep PCH stable.
  • Using global include/link flags causes unnecessary rebuilds; prefer target-based scope.
  • Blindly enabling maximum parallelism on low-memory machines causes swapping and slows builds overall.

Closing note

Speeding up builds is a combination of tooling, project structure, and careful CMake usage. Start by measuring: profile your build, identify hotspots, then apply targeted changes (Ninja, PCH, object libraries, caching). Incremental improvements compound—reducing a few seconds per file yields big wins across many files and many developers.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *