ALib C++ Framework
by
Library Version: 2605 R0
Documentation generated by doxygen
Loading...
Searching...
No Matches
Observations Made When Shifting ALib to C++20-Modules

In early 2025 we migrated ALib to C++20-Modules. This page summarizes what we learned.

1. How C++20-Modules Reshaped Our Library’s Structure

Before C++20-Modules entered the scene, we had already broken our library, ALib, into smaller, self-contained units we called ALib Modules. The reasoning behind this was explained in an older version of the manual, but since it’s a common practice that most libraries follow, we decided to move the discussion to an extra page. In short, we didn’t want to clutter the main manual with something that seasoned developers would find obvious.

Still, when C++20-Modules came knocking, they had a huge positive impact on ALib’s structure. Before the transition, we did our best to avoid circular inclusions of header files... but in some cases, we intentionally allowed them because they simply made sense! To understand why circular dependencies cropped up, let’s look at how ALib handles optional dependencies (explained in the manual). Some optional dependencies naturally form a circle with the modules they rely on. For example:

  • Module B depends on module A to function. (A mandatory dependency.)
  • However, module A can offer extra features if module B is also included in the ALib Build.

A real-world example is the relationship between ALib EnumRecords and ALib Resources. Enum records don’t need resourced strings to work. But when resourced strings are available, it makes sense to represent those records using them. So, where do you put the code for this optional feature? Before C++20-Modules, the answer was: “in the lower-level module, of course!” After all, this follows the principle of “separation of concerns.”

Here’s what we did: we included the higher-level module’s header file conditionally — only if it was part of the build. Simple, right?

Structural Improvements with C++20-Modules
C++20-Modules made us rethink this tactic entirely. The new language rules don’t allow this kind of interdependence anymore. We had to shift all extension code to the higher-level module. Why? Because, with C++20-Modules, the module interface definition must have visibility into both the dependent and dependent-on modules from the start.

Now here’s the fun part: C++20-Modules and C++ Namespaces are totally independent concepts. This means we could keep the original namespace structure intact while reorganizing the implementation. For instance, ALib Resources (which uses the alib::resources namespace) now "injects" additional functions and types into the alib::enumrecords namespace. Neat, right?

If you’re new to C++20-Modules, this behavior might seem strange at first. But technically, it’s the correct approach. In fact, it’s often the only way to make things work! The beauty of this approach became more apparent over time. It wasn’t any one big change but rather a series of small adjustments that eventually started to align. Things we had long accepted as "not ideal, but manageable" just naturally began to fall into place.

Here is what we gained:
Our library expanded from 18 to 25 modules, with responsibilities better separated than ever. There are far fewer “exceptions to the rule” in our Programmer’s Manual now! The structure is cleaner, simpler, and easier to navigate. Most importantly, the learning curve for new users has significantly eased.

Key Takeaways

  • Sometimes, being forced to do things "the right way" works out for the best. C++20-Modules really pushed us toward better architecture.
  • Don’t forget: C++ Modules and Namespaces are independent concepts. Injecting new functionality into existing namespaces is totally fine. From the user’s perspective, the interface feels like it belongs exactly where it’s supposed to; meanwhile, the compiler has everything neatly organized behind the scenes.
  • Yes, transitioning to C++20-Modules was tedious. We gained a better structure of our codebase. (But, read on, this does not mean that we love C++20-Modules!)

2. How The Transition Affected Legacy Compilation Speeds

It’s important to mention that the structural improvements we talked about earlier were not technically tied to C++20-Modules. They were just a true prerequisite and preparing our code for the new module, export, and import keywords, without using them yet.

That said, we did have some concerns about compilation-performance during this transition. Specifically, we were a bit nervous that abandoning the well-established paradigm of “One Type, One Header” might negatively impact compile times for the library. This approach had been great for keeping include chains granular and as lean as possible. However, we moved away from it in favor of a new strategy: “One Header Per C++20-Module”. (Details here)

Under the new system, if a small piece of code uses something like the String class, it now also pulls in all of the related string types, corresponding utilities like type traits and so forth. Sounds inefficient? We thought so too!

But here’s where things got interesting: The drop in the compile-times of our legacy (non-C++20-Module) builds before and after the change was only around 10%. Why? There are a couple of reasons for that:

  • The reality is, small units that use only one type are rare. Even in cases where you start small, you’ll very quickly need another type, or another header, and so on. Thanks to indirect dependencies, before you know it, nearly everything gets included anyway.
  • To boost compilation speed, we were already using precompiled headers, which rendered the “keep headers granular” effort largely pointless. Honestly, with precompiled headers in play, keeping headers lean is hardly worth discussing!

Key Takeaways
In the end, this change didn’t impact the (legacy, non-C++20-Module!)-build times very much. With the added simplicity of module-level headers, it was absolutely worth it. Sometimes, breaking away from old paradigms isn’t as scary as it seems!

3. How C++20-Module Builds Perform

When transitioning our (now) 25 traditional ALib Modules, each representing a distinct namespace (like alib::strings, alib::boxing, or alib::expressions) into 56 discrete C++20-Modules, we made sure to carefully evaluate and optimize the boundaries between these modules to minimize compile-time overhead.

The result? Strategic segmentation. While the core namespaces remained intact, we split less frequently used components into smaller C++20 submodules. That’s how our 25 ALib Modules grew into 56 C++ modules! The relationship between these modules and submodules is detailed in the manual (see this section), where a quick look at the table at the end tells you everything about the strategy we followed.

But now for the big surprise: Switching the build system to use C++20-Modules caused the overall compilation time for the library to increase... by almost a factor of 10!

3.1 Wait… What? Isn’t C++20 Supposed to Speed Things Up?

At first glance, this feels completely counterintuitive. After all, wasn’t one of the key promises of C++20-Modules to reduce compilation times? Let’s break it down step by step to uncover what’s going on. Stay tuned as we unravel what’s happening behind the scenes—and the lessons we learned along the way!

Before diving into the details, it's worth noting that we execute our builds on a high-performance machine equipped with an AMD Threadripper processor boasting 128 hardware threads. While this provides significant computational power, it also amplifies the bottlenecks described below. Because fewer threads are utilized during key stages of a C++20-Module build, the impact is more pronounced on this system. To put it in perspective: on machines with fewer threads, the observed increase in build times might be closer to a factor of 5, rather than 10.

Legacy Build Behavior
In traditional builds, all .cpp files (compilation units) are processed in parallel. During a fresh build, all hardware threads quickly hit 100% workload utilization. Compilation units are independent of each other, which makes it easy for the build system to maximize parallelism by utilizing as many threads as available.

Differences with C++20-Module Builds
When building with C++20-Modules, the process changes significantly, introducing new bottlenecks:

  1. Dependency Graph Construction
    Before starting any compilation, the build system needs to determine the dependency graph for all modules. No compilation can begin until this is complete, meaning that 127 out of 128 threads are left waiting while this step is performed.
  2. Module Interface Compilation
    For C++20-Modules, module interface files (e.g., .pcm files with Clang) must be generated before the implementation files can be compiled. However:
    • Modules that depend on others cannot be started until their dependencies are built.
    • In the case of ALib, the module dependency graph is deep, not wide (see the dependency graph), further limiting parallelism. Only a few modules can be compiled simultaneously due to the sequential nature of the graph.
  3. Implementation File Compilation
    Once the module interfaces are built, the process moves on to compiling the implementation files. However, further challenges arise:
    • At the time of testing, ALib had 199 headers (each covering a few types and included by .mpp files) versus only 116 compilation units.
    • While ALib is far from being a “header-only” library, many core types and templates must be provided as headers due to its low-level and general-purpose nature.
    • This imbalance limits opportunities for parallelism during the compilation of implementation files.

3.2 The Shocking Discovery: Slower Application Builds

Perhaps even more surprising than the library build times is this: Applications consuming the C++20-Modules of ALib also experience an increase in build times. While we do not see factors, we still see rates of 10% to 40% increase. Note that we here compare module-based builds to traditional ones that use precompiled headers. We initially anticipated improvements here: precompiled module interfaces (which are better tailored to individual compilation units) seemed likely to outperform the broader precompiled headers of legacy builds. Unfortunately, current compiler implementations seem to incur significant overhead when opening and handling C++20-Modules for individual compilation units. This overhead appears to outweigh the potential benefits of modular builds.

3.3 A Specific Problem For Low-Level Libraries?

We suspect that implementers of the C++ Standard Library may encounter similar challenges as we did. The need to export a significant portion of overall types can result in higher build times, even with future toolchain improvements. While current performance drawbacks are often attributed to tooling inefficiencies, our analysis suggests that C++20-Modules inherently introduce additional overhead for low-level, type-rich libraries in comparison to traditional approaches, such as precompiled headers.

Although we remain optimistic that advancements in compiler technology will alleviate some of these challenges, we believe it is important to temper expectations regarding the compilation performance of foundational libraries, such as the C++ Standard Library or Boost, when adopting C++20-Modules.

4. Further Issues With Other Compilers

We had performed the transition using Clang in its newest version. When this compiled we tried GCC and the next huge problem appeared: GCC interprets the C++20 standard according to modules probably different than Clang. Unfortunately, this means more strictly! With GCC an implementation (!) unit must not reference any types from another module whose implementation unit in turn references the current module. This is a very strict rule, and our source code violates it in many cases. Again: That is the same source code that compiles well with Clang. To solve this issue, we had to come up with new "intermediate modules", real artificial ones, only for the sake of aligning the code to the standard. Given the already huge negative impact on compile-times with the current structure, we decided to not addressing this issue until the standard is more mature and compilers are consistent.

Note
Update March 2026: It seems like the GCC team is working on a fix for this issue!

5. Summary

As of today (March 2026) our C++20-Module experience in ALib is mixed: architecture improved, but build throughput regressed for this kind of low-level framework. Splitting broad, interdependent framework code into many module units improves structure, yet it also deepens dependency chains and reduces parallelism in the module-interface phase. In practice, this can make full library builds much slower, and even consumer builds can stay slower than a strong legacy setup with precompiled headers.

Compiler behavior is also still uneven. GCC is strict with “own purview” situations and rejects certain indirect implementation-unit cycles. Clang historically accepted more cases, but newer versions moved toward stricter conformance. The safe engineering rule is: design import graphs so they cannot flow back into the current module, even indirectly through implementation units.

MSVC is strict in a slightly different way. It enforces module-unit structure very hard: module declaration placement, preprocessor-only global module fragments, and warnings for include inside module purview. For the exact GCC-style indirect purview cycle, historical evidence shows MSVC accepted at least some cases that GCC rejected, so behavior has not always matched GCC. But this is not a portability guarantee and can change across MSVC versions.

6. Status, Outlook, Current Directions

Let's try to be short and concreate:

  1. ALib's support for C++20-Modules has to be named "experimental" alone due to the fact that compilers behave differently. As of today it is restricted to the (newer) Clang compiler.
  2. We will most certainly never adopt to set of rules that GCC and MSVC currerntly demand.
    And even more: Should Clang develop its rules into their direction, we are prepared to drop C++20-Modules support completely. (Honestly, in this case we consider the whole C++20-Modules approach to be a dead-end and we bet on it becoming a huge failure, similar to "export templates" in C++98.)
  3. We do not enable ALib C++20 compilation in our own projects.
  4. We do not recommend using the ALib C++20-Modules compilation feature in your projects, unless you are interested in experimenting or have other good reasons that we are not aware of.
  5. We will try our best to continue to provide at least Clang compatibility and test our releases for that.