The trouble with CMake configurations

2021 October 22

This is the third post in a series on C++ developer experience. In the first post, I described my vision and introduced a vocabulary for this series. In the second post, I explained the difference between local and global compiler flags. In this post, I want to talk about the trouble with build types or configurations in CMake.

Recall that I want to make it easy for library users, i.e. dependents, to choose which build they use for each of the dependencies in their dependency graph. I gave an example of mixing debug builds of some dependencies with release builds of other dependencies in order to aid debugging of some components without paying the cost of slowing down all components. I didn't come up with this idea, but I've never actually seen it in practice, and in my experience, it is difficult to achieve with CMake.

Disclaimer

Up to this point, I have tried to avoid fixating on any particular build system, but CMake is the one I use because I believe it is the one most widely used in the community, to the point of being a de facto standard. I can already hear the shouts that it is not a build system, but a build system generator, but that stopped being the case in my opinion when they added cmake --build in version 2.8, which lets users configure, build, test, and install projects in a cross-platform way by interacting with CMake only, with no knowledge or concern of the underlying build system. CMake is the build system I have in the back of my mind as I write this series. I have tried and will continue to try to keep the ideas general, but this post is colored very specifically by my experience with CMake.

Definitions

In CMake, a build type or configuration is a name. That's it. These two terms are often used interchangeably, but I will stick to configuration here. For single-configuration generators (e.g. Unix Makefiles), the configuration is chosen at configure time by the CMAKE_BUILD_TYPE variable. For multi-configuration generators (e.g. Visual Studio 16 2019), the configuration is chosen at build time by the command-line option --config. Whenever I use the term configuration, I am referring to this choice, however it was made.

What does it mean to talk about the configuration? When using CMake to build, we build a configuration. Now, with a single-configuration generator, we know what that configuration will be, but it still doesn't make sense to think of the configuration as a property of the project. The configuration is a property of the build step.

Recalling my earlier vocabulary, an artifact is a library or executable, and any variation in the construction of that artifact creates a uniquely identifiable build of the artifact, whether or not it changes the artifact's ABI. The set of possible variations includes linking against different builds of dependencies. Artifacts may have "debug" and "release" builds, i.e. builds that are colloquially called "debug" and "release", but that distinction is independent of (albeit often correlated with) the configuration.

The configuration has implications throughout the project.

  • Some variables have special siblings which have a configuration in their name and are used only when the chosen configuration matches that name. For example, CMAKE_CXX_FLAGS holds flags passed to the compiler for all configurations of all C++ artifacts, while CMAKE_CXX_FLAGS_DEBUG holds flags passed to the compiler (in addition to CMAKE_CXX_FLAGS) for only debug configurations of all C++ artifacts.
  • Generator expressions can conditionally evaluate to different values based on the chosen configuration by using $<CONFIG:cfgs>.
  • Some variables have different defaults based on the chosen configuration, e.g. MSVC_RUNTIME_LIBRARY.

Users can choose any configuration name they like, but CMake assigns default values for a few specially chosen configurations. For example, here are the default values for the siblings of CMAKE_CXX_CFLAGS when using the Microsoft Visual C++ (MSVC) family of generators:

Configuration CMAKE_CXX_FLAGS_<CONFIG> Runtime Library Symbol Table Optimize Inline Assert
Debug /MDd /Zi /Ob0 /Od /RTC1 debug include none none enable
Release /MD /O2 /Ob2 /DNDEBUG non-debug exclude speed any disable
RelWithDebInfo /MD /Zi /O2 /Ob1 /DNDEBUG non-debug include speed declared disable
MinSizeRel /MD /O1 /Ob1 /DNDEBUG non-debug exclude size declared disable

Different configurations cannot see each other

Imagine you have a CMake project to build an artifact B that links against library A, and you want to build B in a debug configuration but link against A in a release configuration. If A is imported into your project using find_package or find_library, and that search identifies a pre-constructed release build of A, then you can choose the debug configuration for your project and continue. (It likely won't link on Windows, though, for reasons explained below.)

But if you're building A because it was added to your project via add_subdirectory or FetchContent, then A and B, and in fact all artifacts, must share the same global configuration. There is no way to choose different configurations for different artifacts in the same dependency graph. It is not even possible within the language of CMake to choose a configuration for an artifact. There is no target property corresponding to the choice of a configuration. Even with a multi-configuration generator, it is not possible for artifacts built in one configuration to refer to artifacts built in another configuration. They cannot see each other.

The main reason for this limitation, as far as I can tell, is that the configuration effectively chooses the build of the standard library. Remember that all artifacts in a single dependency graph must be linked against the same build of any library within that graph, including the standard library, to ensure ABI compatibility (in the general case). The configuration adds a few global compiler flags, passed to the compiler for all artifacts in the project, like the definition NDEBUG and optimization level. For the libstdc++ standard library implementation compiled with GCC or Clang, its ABI happens to be unaffected by those flags, as of today, but that might not be the case forever. For MSVC, the configuration adds more global flags to enable run-time error checks and to choose which build of the run-time library to link against. That last flag certainly does affect the ABI. This difference often stings Windows developers who try to mix debug and release builds of different libraries, whether built in the same project or not.

One way to remove this limitation would be to introduce special targets representing different builds of the standard library. There's nothing stopping users from doing this now, but doing it correctly requires special care:

  • Delete the values of all the global variables affecting build commands, e.g. CMAKE_CXX_FLAGS (and its siblings).
  • Assign appropriate global ("public" in CMake parlance) compiler and linker flags properties to the standard library targets.
  • Check that at most one standard library target appears in any dependency graph.

It's much easier for CMake to just tie the standard library to the configuration chosen at build time.

How can different builds co-exist?

If we can't mix configurations when building a dependency graph, does that make it impossible to mix debug and release builds in a dependency graph? No, it just means that each artifact needs to define separate debug and release targets to facilitate that choice for dependents. CMake doesn't make it easy though.

Imagine you want to create debug and release targets that differ only in their sets of local compiler flags. The debug target disables optimization, enables assertions, and includes a symbol table. The release target chooses the opposite for all three flags. The global compiler flags chosen by the configuration will still be applied to both targets. This can cause problems with a certain kind of flag: a flag with no anti-flag.

What is an anti-flag? An anti-flag is a flag that cancels the effect of an earlier flag. MSVC has flag /D to add a preprocessor definition, and flag /U to remove a preprocessor definition, even one that was added by an earlier /D flag. /U is the anti-flag of /D, and vice versa. For GCC and Clang, the equivalent flags are -D and -U.

Now consider the flag for MSVC to generate a separate symbol table file, /Zi. If this flag is present, the file is generated. If it is absent, the file is not generated. There is no anti-flag for this flag. If the flag is present in the global flags chosen by the configuration, there is no way to cancel it in the local flags for your release target. Instead, you must remove the flag from the global flags, but that will affect all the other targets in your project, including dependencies, which may not be what you want or what they expect, depending on the flag.

So far I've focused on debug and release builds because of their special interaction with the CMake configuration. There are many other dimensions on which artifacts may offer multiple builds, like static vs dynamic linkage. Or consider a mobile application that offers different tiers (free, value, premium) with different sets of features enabled via preprocessor definitions. Each dimension is called an option, and each point on a dimension is called a choice. The set of builds for an artifact grows combinatorically as the set of its options grows. Trying to define a separate target for each build becomes a nightmare for the author.


It's become clear to me now that CMake does not think about dependency graphs the way I want to think about them. In the next post, I will walk through what I perceive is the philosophy of CMake and my idea for a build system better suited to my demands.