Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,715 words · 1 segments analyzed
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