Faster Than Ninja

Posted on 5 Aug 2026 by Boris Kolpackov

Some months ago I read an article about a tool for snooping on slow build systems that can be used to find build bottlenecks. While the whole article is quite illuminating, this quote stood out to me:

Ninja is not a 100% fair comparison to other tools, because it benefits from some "baked in" build logic by the tool that created the ninja file, but I think it's a reasonable "speed of light" performance benchmark for build systems.

To clarify, by "baked in" the author means that nobody writes Ninja build files by hand. Rather, a second tool, such as CMake, is invoked to generate them and some steps performed during this generation phase (examples below) should ideally be part of the build phase. Also, Ninja is notoriously minimalist, providing only the bare minimum of functionality, especially on the change tracking side of things (again, examples below). A modern build system would be expected to provide more.

Still, it would be interesting to see how close a modern, native (that is, without the generation step) build system can approach the "speed of light".

Let's take a look at how build2 measures up. While there is a number of substantial projects (such as Boost and Qt) that can be built with both build systems, finding a project of substance that would result in an apples-to-apples comparison is difficult because when we package more complex projects for build2, we invariably have to untangle the "ball of intra-dependencies" structure into something more orderly (for a good example, take a look at the upstream qtbase module versus build2 packages). And this usually results in a slightly different set of intermediate build artifacts, like bootstrap and utility libraries.

So we will have to make do with something simpler, where we can make sure the same set of object files and binaries is produced with more or less identical compile and link options. In the end I've picked Xerces-C++, an XML parser/serializer for C++. It has quite a few features (like XML Schema validation) so it's not exactly tiny, measuring 299 C++ translation units that are linked into a shared library.

We are going to test a full, from-scratch build, the same as in the quoted article. Ninja completes this build on my machine (see Benchmark Details below) in 3.4s:

Time (mean ± σ):    3.429 s ± 0.029 s  [User: 48.536s, System: 5.033s]
Range (min … max):  3.383 s … 3.464 s  10 runs

Before we measure build2, let's at least acknowledge the elephant in the room: while Ninja builds the project in 3.4s, CMake takes 15.6s to generate the Ninja build files. So if you had Xerces-C++ as a dependency of your project and it was being built from scratch, you would wait 19 seconds, not 3.4, for this build.

With the matching configuration (same C++ compiler, C++ standard, debug build, etc) build2 takes 3.8s, or about 11% slower:

Time (mean ± σ):    3.808 s ± 0.046 s  [User: 58.037s, System: 8.012s]
Range (min … max):  3.746 s … 3.886 s  10 runs

Pretty close, but not at the speed of light. Let's see if we can get there. Maybe building in vacuum will help?

To try to get closer to Ninja's time we are going to make the comparison more accurately apples-to-apples. As discussed above, Ninja is notoriously minimalist with build2 providing a lot of functionality that Ninja does not. And some of this functionality has measurable cost, performance-wise. So we are going to disable a few features to closer match the amount of work done by Ninja.

The first feature that we will disable is the more precise change tracking for C and C++ source files. Ninja simply checks whether the file's modification time has changed and if so, recompiles it. build2, in contrast, performs an extra step in this case: it tokenizes the (partially-preprocessed) source file and computes the checksum of the resulting tokens. If this checksum hasn't changed since the last time the file was compiled, then it skips recompiling it. This ignores whitespace-only changes (as long as they do not alter the column numbers of the tokens) and is very useful during development (and is critical in some case, like if you want to change your project's version with every commit). But tokenizing all the 299 translation units in the from-scratch build has an upfront cost, even if it may pay off during further incremental builds.

The way to disable this ignorable change detection is to tell build2 that the project is read-only (which is done automatically by the package manager for external dependencies). In this case build2 will fall back to using just the modification time, the same as Ninja. With this change our build time goes down to 3.4s, pretty much the same as Ninja's:

Time (mean ± σ):    3.433 s ± 0.055 s  [User: 51.093s, System: 6.701s]
Range (min … max):  3.383 s … 3.551 s  10 runs

Let's see if we can go even faster. Next, we disable compression in the file cache. We will discuss the file cache in more detail a bit later but for now let's just say that by disabling compression we trade temporary disk space usage for speed:

Time (mean ± σ):    3.355 s ± 0.067 s  [User: 49.987s, System: 6.153s]
Range (min … max):  3.281 s … 3.471 s  10 runs

And now we are 2.2% faster than Ninja! While this may not seem like much, it becomes more impressive considering build2 still does a lot more than Ninja. Some of this work is done by CMake and some is just not done at all.

For example, build2 generates the XercesVersion.hpp header from XercesVersion.hpp.in as part of the build while Ninja leaves this to CMake. build2 also makes sure this file is properly change-tracked (while Ninja expects you to re-run CMake manually). This header is included in pretty much every translation unit in Xerces-C++, meaning that no compilation can start until it is generated. As an experiment, I hacked the build2 build file to pretend XercesVersion.hpp is static. That increased the gap to 2.6%.

build2 also has to extract a lot more information from the compiler, something that in the case of Ninja is, again, done by CMake. Things like the compiler id (GCC, Clang, etc) and its version, target platform, list of system header and library search paths, C and C++ standard libraries used, etc. This information is both made available to build files as well as used to implement more precise change tracking. For example, for C and C++, besides tracking changes to the standard inputs such as the source file itself, all the included headers, and the compile options, build2 also tracks the compiler id/version, system header search paths, and environment variables that may affect the compilation.

Currently, the only reliable way to extract this information is to run the compiler. And, unfortunately, to extract all the bits listed above, we have to run it multiple times. For example, in case of GCC, there are 10 invocations in total, 5 for C and 5 for C++ (Xerces-C++ has a few C translation units):

LC_ALL=C gcc-15 -v
gcc-15 -g -print-multiarch
gcc-15 -g -x c -E -
LC_ALL=C gcc-15 -std=c9x -print-search-dirs
LC_ALL=C gcc-15 -std=c9x -x c -v -E -

LC_ALL=C g++-15 -v
g++-15 -g -print-multiarch
g++-15 -g -x c++ -E -
LC_ALL=C g++-15 -std=gnu++17 -print-search-dirs
LC_ALL=C g++-15 -std=gnu++17 -x c++ -v -E -

Ok, so build2 does quite a bit more and is on par or even faster than Ninja, even when we choose not to see the elephant (CMake). The next natural question to ask is how does it do it? Ninja was designed from the start with performance in mind and then saw decades of heavy use and optimization. There is little chance of any fruit, let alone low-hanging ones, left for us to pick. So what's the secret?

In a nutshell, we have to do things differently, not just better. I can think of three major design decisions that contributed to this. It's not easy to verify empirically since it would be pretty difficult to test alternative designs in isolation, but I think they are the most likely reasons.

The first is more of a what not to do rather what to do. Again, nothing will illustrate the point better than the above mentioned article:

Here’s a tiny slice of a CMake build from another open source project:

[...]

Here CMake gets Xcode’s path with xcode-select -print-path, the OS version with sw_vers, and then recursively calls cmake/make a few times for good measure, and finally compiles and links a file.

Only the green boxes in that timeline are doing useful work. One could argue that none of what CMake does is "useful work", in the sense that it just builds the thing that actually builds the project. Regardless, let’s just accept that CMake needs to do this weird cmake->make->make->clang dance to figure out the build environment.

Zooming out reveals that the weird dance happens 85 times!

Yikes, no parallelism. It also studiously re-checks the Xcode path and OS version 85 times, just in case the OS version changes mid-build.

Needless to say, you are not going to get very far performance-wise with such an approach. In contrast, in build2 we aggressively cache every piece of discovered information (but not across the build system runs) to make sure we don't redo any of the work unnecessarily.

Ok, I will stop picking on CMake and focus on Ninja. The second biggest difference between Ninja and build2 (the first being it's a native build system) is build2 being multi-threaded. While Ninja executes compilers, linkers, etc., in parallel, it performs its own housekeeping work serially, from a single thread. For example, both Ninja and build2 need to parse the header dependency information received from GCC, which is inconveniently produced as Makefile fragments. build2 does this (and a lot of other things) in parallel from multiple threads. In fact, the only serial phase in build2 is loading of build files (and even for that there are ideas on how to parallelize some parts of it).

The last design decision that I think contributes to build2 matching or exceeding Ninja's speed is a different C/C++ build model when it comes to the extraction of header dependency information (the list of headers included by every translation unit, transitively).

Ninja uses what we can call a byproduct of compilation model: during the from-scratch build, Ninja compiles every translation unit and gets the header dependency information essentially for free. It parses and stores it in its own format on disk to be used during the next build to see if any translation units need to be recompiled because some headers they include may have changed. This is a clever approach but unfortunately it doesn't work well with auto-generated headers (and is the reason why headers like XercesVersion.hpp are generated by CMake).

In build2 we perform explicit header dependency extraction before we compile a translation unit. One can reasonably expect a naive implementation to perform strictly worse than the byproduct approach, where this extraction is free. What we do in build2 is a bit more advanced: because a header dependency extraction is essentially a preprocessor run on the translation unit, we combine the dependency extraction with partial preprocessing of each translation unit. Or, in other words, instead of just extracting header dependencies, we perform partial preprocessing (-fdirective-only for GCC, -frewrite-includes for Clang) of each translation unit and get the header dependency information as a byproduct of that. Then, when the time comes to compile the translation unit, we compile this partially-preprocessed output instead of the original source file, thus saving on re-preprocessing it.

Remember the file cache compression that we disabled above? Well, that file cache is a temporary (unless C++20 modules are used) cache of the partially-preprocessed translation units.

Still, this sounds like a lot more work than what Ninja does. We even have to store the partially-preprocessed files on disk, how can this be faster than doing nothing? Interestingly, there appears to be another aspect at play: The way Ninja does it, preprocessing of translation units is spread out over the entire build, time-wise. There is also substantial demand for RAM since C and especially C++ compilation is memory-intensive. In contrast, with the build2 approach, all the preprocessing is front-loaded, it's all concentrated at the beginning of the build, before memory-intensive compilation starts. And preprocessing of a typical C/C++ translation unit involves including hundreds of header files (many of them the same as for other translation units), all of which need to be read from disk. It turns out, at least on Linux with GCC, the build2 approach performs better, likely due to better temporal locality of file access and lower memory pressure. In other words, with the build2 approach, the included headers are more likely to still sit in the system's file cache (for details, see Separate Preprocess and Compile Performance).

Benchmark Details

The benchmark was executed on a physical machine with Intel i9-12900K CPU running Debian on Samsung 980 Pro NVMe formatted as ext4. To obtain stable numbers I disabled turbo boost (failed that, the times gradually increased as the CPU got warmer):

echo 1 >/sys/devices/system/cpu/intel_pstate/no_turbo

I used the official Xerces-C++ 3.3.0 source archive for Ninja and the libxerces-c-3.3.0+3 build2 package (which is the official source overlayed with build2 support). I had to modify CMakeLists.txt to change the hardcoded C++ standard from 14 to 17 since the system version of ICU on Debian no longer supports C++14.

The CMake command to prepare the Ninja build:

CC=gcc-15 CXX=g++-15 cmake -G Ninja \
  -DCMAKE_BUILD_TYPE=Debug \
  -Dnetwork:BOOL=OFF \
  -Dtranscoder=icu \
  -Dmessage-loader=inmemory \
  -Dmutex-manager=standard \
  -Dxmlch-type=char16_t \
  ../xerces-c-3.3.0

Then to run the Ninja benchmark:

hyperfine --style=basic --warmup 1 --runs 10 \
  --prepare 'ninja clean' \
  'ninja src/all'

To run the build2 benchmark we don't need to prepare anything. Below are the command lines for the three measurements shown above:

hyperfine --style=basic --warmup 1 --runs 10 \
  --prepare 'b clean: xercesc/' \
  "b config.c=gcc-15 config.cxx=g++-15 config.cxx.std=gnu++17 \
   config.cc.coptions=-g config.bin.lib=shared \
   xercesc/"

hyperfine --style=basic --warmup 1 --runs 10 \
  --prepare 'b clean: xercesc/' \
  "b config.c=gcc-15 config.cxx=g++-15 config.cxx.std=gnu++17 \
   config.cc.coptions=-g config.bin.lib=shared \
   config.libxerces_c.build.readonly=true xercesc/"

hyperfine --style=basic --warmup 1 --runs 10 \
  --prepare 'b clean: xercesc/' \
  "b --file-cache=none \
   config.c=gcc-15 config.cxx=g++-15 config.cxx.std=gnu++17 \
   config.cc.coptions=-g config.bin.lib=shared \
   config.libxerces_c.build.readonly=true xercesc/"