Skip to main content

A Practical Checklist for Optimizing C++ Build Times with CMake and Ninja

Every C++ developer knows the pain of waiting for a build. You make a one-line change, hit compile, and then stare at the terminal for minutes—or longer. Over time, those minutes add up to hours of lost productivity each week. This guide is for teams and individuals who want to reclaim that time. We'll walk through a concrete checklist of optimizations using CMake and Ninja, from basic setup to advanced caching and distribution. By the end, you'll have a clear set of actions to cut your build times and keep your workflow flowing. Who This Checklist Is For—and What Happens Without It This checklist is aimed at C++ developers who already use CMake and are looking for systematic ways to speed up their builds. It's especially relevant for teams working on medium-to-large codebases—think tens of thousands of source files or more—where even small inefficiencies compound.

Every C++ developer knows the pain of waiting for a build. You make a one-line change, hit compile, and then stare at the terminal for minutes—or longer. Over time, those minutes add up to hours of lost productivity each week. This guide is for teams and individuals who want to reclaim that time. We'll walk through a concrete checklist of optimizations using CMake and Ninja, from basic setup to advanced caching and distribution. By the end, you'll have a clear set of actions to cut your build times and keep your workflow flowing.

Who This Checklist Is For—and What Happens Without It

This checklist is aimed at C++ developers who already use CMake and are looking for systematic ways to speed up their builds. It's especially relevant for teams working on medium-to-large codebases—think tens of thousands of source files or more—where even small inefficiencies compound. If you're a solo developer on a small project, some of these steps might be overkill, but the principles still apply.

The cost of slow builds

Slow builds don't just waste time; they break concentration. When a build takes more than 30 seconds, developers tend to switch contexts—check email, browse the web—and it takes another 15 minutes to regain focus. Multiply that by several builds a day, and the productivity loss is enormous. Teams that ignore build performance often end up with frustrated developers and longer iteration cycles.

What typically goes wrong

Without a deliberate optimization strategy, several common problems creep in. First, the default CMake generator (Unix Makefiles) is single-threaded by default, so you're not using your CPU cores efficiently. Second, developers often rebuild files that haven't changed because of incorrect dependency tracking or overzealous include directories. Third, link time becomes a bottleneck, especially with large static libraries or many shared objects. Finally, teams miss out on simple wins like caching object files or distributing compilation across machines.

This checklist addresses each of those issues step by step. We'll start with the prerequisites you need to have in place, then move through the core workflow, tools, variations for different constraints, and finally how to debug when things go wrong.

Prerequisites: What You Need Before You Start

Before diving into optimizations, make sure your environment is set up correctly. This section covers the tools and configurations you should have in place.

CMake version and generator selection

Use CMake 3.15 or later—earlier versions lack some of the features we'll rely on, like the CMAKE_BUILD_PARALLEL_LEVEL variable and better support for Ninja. The single most impactful change is switching your generator from "Unix Makefiles" to "Ninja". Ninja is designed for speed: it starts faster, processes build files more efficiently, and supports parallel execution out of the box. To use Ninja, simply pass -G Ninja when running CMake, or set it in your CMakePresets.json.

Installing and verifying Ninja

Ninja is available via most package managers. On Ubuntu, apt install ninja-build; on macOS, brew install ninja; on Windows, it's included with Visual Studio or can be downloaded separately. Verify the installation with ninja --version. If you see a version number, you're good to go.

Compiler and linker considerations

Use a modern compiler that supports parallel compilation and incremental linking. GCC 9+ and Clang 10+ are good choices. For linkers, consider using LLD (the LLVM linker) or mold, which are significantly faster than the GNU gold linker, especially for large projects. On Linux, you can set the linker via CMake: set(CMAKE_LINKER /usr/bin/ld.lld) or by passing -DCMAKE_LINKER=mold.

Resource awareness

Know your hardware. Run nproc (Linux) or sysctl -n hw.ncpu (macOS) to see how many cores you have. For parallel builds, a good rule of thumb is to use N+1 jobs, where N is the number of cores, to keep the CPU busy while I/O waits. However, if you're memory-constrained, you might need to reduce that number to avoid swapping.

Core Workflow: Step-by-Step Build Optimization

With prerequisites in place, here's the core workflow to optimize your build. Follow these steps in order for best results.

Step 1: Switch to Ninja and set parallel jobs

If you haven't already, reconfigure your project with cmake -G Ninja. Then, when building, use ninja -j $(nproc) (or ninja -j $(( $(nproc) + 1 ))). You can also set the default parallelism in CMake by adding set(CMAKE_BUILD_PARALLEL_LEVEL 8) in your top-level CMakeLists.txt, but we recommend letting Ninja decide based on the -j flag.

Step 2: Enable ccache for object file caching

ccache caches compiled object files based on the preprocessed source and compiler flags. Install ccache (apt install ccache or brew install ccache), then tell CMake to use it by setting -DCMAKE_CXX_COMPILER_LAUNCHER=ccache. For best results, also set -DCMAKE_C_COMPILER_LAUNCHER=ccache. ccache can reduce rebuild times by 50–90% for clean builds and incremental changes, especially when switching branches or applying patches.

Step 3: Use unity (or amalgamated) builds

Unity builds combine multiple source files into a single translation unit, reducing the number of compiler invocations. CMake supports this via the UNITY_BUILD property. Add set_target_properties(my_target PROPERTIES UNITY_BUILD ON) to enable it. However, be cautious: unity builds can break code that relies on file-level static variables or anonymous namespaces, and they may increase memory usage per compilation. Test thoroughly before deploying.

Step 4: Optimize include directories and headers

Unnecessary includes cause recompilation of files that didn't change. Use target_include_directories with PRIVATE or INTERFACE instead of blanket include_directories. Consider using iwyu (include-what-you-use) to find and remove unused includes. Also, use precompiled headers (PCH) for stable, large headers like <vector> or <iostream>. Enable PCH in CMake with target_precompile_headers.

Step 5: Split large targets into smaller libraries

Monolithic targets force the linker to process many object files at once. Break your project into smaller static or shared libraries. CMake's add_library and target_link_libraries make this straightforward. Not only does this speed up linking, but it also improves incremental builds—if only one library changes, only that library needs to be relinked.

Step 6: Link with a fast linker

As mentioned, replace the default linker with LLD or mold. On Linux with Clang, add -fuse-ld=lld to your compiler flags. For GCC, you might need to install the lld package and use -fuse-ld=lld as well. The speedup is dramatic, especially for large projects: link times can drop from minutes to seconds.

Tools, Setup, and Environment Realities

Beyond the core workflow, several tools and environment tweaks can further accelerate builds.

Distributed builds with distcc or Icecream

If you have multiple machines on a network, consider distributed compilation. distcc sends preprocessed source files to remote compilers and collects the object files. Set it up with cmake -DCMAKE_C_COMPILER_LAUNCHER=distcc -DCMAKE_CXX_COMPILER_LAUNCHER=distcc. Icecream is a similar but more sophisticated tool that manages a pool of compile nodes. Both require careful network setup and can introduce latency, but for large teams, they can multiply build throughput.

Build profiles and presets

CMake presets (CMakePresets.json) let you define build configurations with different optimization levels, generators, and cache settings. Create a preset named "release-fast" with Ninja, ccache, and LLD, and another "debug" with minimal optimization but full debug symbols. This way, developers can switch between configurations without remembering all the flags.

Containerized builds

Docker or Podman can provide reproducible build environments, but they add overhead. Use buildkit's cache mounts to persist ccache and dependency downloads across builds. For example, mount /ccache as a volume. This avoids re-downloading and recompiling dependencies every time you spin up a container.

Monitoring and profiling

Use ninja -d explain to see why targets are being rebuilt—it prints the reason for each build step. For deeper analysis, ninja -t graph outputs a Graphviz file you can visualize. Also, CMake's --trace and --trace-expand options help debug configuration issues. For profiling the build itself, tools like perf or FlameGraph can identify hot spots.

Variations for Different Constraints

Not all projects are the same. Here are variations of the checklist for common scenarios.

Small project (fewer than 100 source files)

For small projects, most optimizations are overkill. Focus on switching to Ninja and enabling ccache. Unity builds may not help much and could introduce issues. Use a simple CMakeLists.txt with a single target. Skip distributed builds and fast linkers unless you're already using them.

Large monorepo with many targets

For large projects (hundreds of thousands of files), invest in a proper build system architecture. Use modular libraries, unity builds for stable groups of files, and precompiled headers. Consider using a build graph cache like ccache for all targets. Distributed builds become essential—set up a cluster with Icecream or distcc. Use mold or LLD for linking. Profile regularly to catch regressions.

Cross-platform projects (Windows, macOS, Linux)

Cross-platform builds face additional challenges. On Windows, Ninja works with Visual Studio's toolchain; use cmake -G "Ninja" -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl. ccache works on Windows via ccache.exe. For linking, LLD is available on all three platforms. Be aware that file system differences (case sensitivity, path separators) can affect include handling. Use CMake's file(TO_CMAKE_PATH) and file(TO_NATIVE_PATH) for portability.

Embedded or resource-constrained environments

If you're building on a machine with limited RAM or CPU cores, reduce parallelism (e.g., -j2 or -j4). Use ccache to avoid recompilation. Disable unity builds if they cause memory thrashing. Consider using a remote build server with more resources.

Pitfalls, Debugging, and When Things Go Wrong

Even with a solid checklist, things can break. Here's how to diagnose and fix common issues.

Ninja not found or generator errors

If CMake complains about not finding Ninja, ensure it's installed and in your PATH. On Windows, you might need to run CMake from a developer command prompt. Double-check that you're using the correct generator name: Ninja (capital N).

ccache not caching

If ccache doesn't seem to speed things up, check its statistics with ccache -s. Common reasons: the cache is too small (increase with ccache -M 50G), compiler flags differ between builds (e.g., debug vs. release), or the preprocessor output varies due to timestamps or __DATE__ macros. Use ccache --zero-stats and rebuild to see if hits increase.

Unity build breaks

Unity builds can cause name collisions or static variable issues. To debug, disable unity builds for specific files by setting set_source_files_properties(file.cpp PROPERTIES UNITY_GROUP ""). Alternatively, use UNITY_BUILD_MODE GROUP to control grouping. Test thoroughly before enabling on production branches.

Slow linking despite fast linker

If linking is still slow, check for circular dependencies or excessively large static libraries. Use nm or objdump to list symbols. Consider using --gc-sections to discard unused sections. Also, ensure you're using the correct linker: ldd --version or ld.lld --version should show the expected version.

Incremental builds rebuilding too much

Use ninja -d explain to see why each file is being rebuilt. Common causes: modified headers that are included widely, changed compiler flags, or CMake reconfiguration. Use CMAKE_LINK_DEPENDS_NO_SHARED to avoid unnecessary relinking of shared libraries. Also, check that your CMakeLists.txt doesn't use add_definitions or include_directories globally, which can cause cascading rebuilds.

Builds failing with out-of-memory errors

Reduce the number of parallel jobs with -j. Disable unity builds. If using LLD, try -Wl,--no-omagic or other flags to reduce memory usage. Consider splitting the project into smaller libraries.

To wrap up, here are three concrete next steps: First, switch your CMake generator to Ninja today—it's the single biggest win. Second, install and enable ccache; you'll see immediate improvements on rebuilds. Third, replace your linker with LLD or mold. Once those are in place, explore unity builds and distributed compilation based on your project's size and team resources. Regularly profile your builds and revisit this checklist as your codebase evolves.

Share this article:

Comments (0)

No comments yet. Be the first to comment!