An Empirical Study of Module Maps: Faster C++ Builds Without Changing Source Code
Large C++ projects often spend a surprising amount of time repeatedly parsing the same third-party headers. Header-only and template-heavy libraries make this even worse: every translation unit pays the cost again.
C++20 Modules provide a way out, but fully migrating a large codebase from #include to import is rarely realistic. This article describes a more incremental approach: use Clang module maps to translate selected #include directives into module imports during the build, while keeping application source code unchanged.
TL;DR
- Goal: Reduce C++ build cost without rewriting application code.
- Approach: Package stable third-party libraries as C++20 modules and use Clang module maps to map existing includes to those modules.
- Bazel integration: Add module interfaces and module map metadata to
cc_library/cc_binaryrules. - Result: In an
async_simpleexperiment, CPU time dropped from 13m 51s to 4m 6s, a 70.4% reduction. - Portability note: The prototype uses ACC convenience pragmas, but the core idea can be reproduced with upstream Clang using explicit exports and explicit Bazel metadata.
1. Motivation: Modules Without a Source Migration
The classic C++ include model is simple, but expensive. If 100 translation units include the same heavy header, the compiler may parse much of that header 100 times. This is especially painful for third-party libraries that are:
- Header-heavy: Most implementation lives in headers.
- Template-heavy: Instantiation and semantic analysis are expensive.
- Stable: The library changes much less frequently than application code.
C++20 Modules can compile such libraries once and reuse the compiled module representation. The hard part is adoption: requiring every user to replace #include <foo.hpp> with import foo; is intrusive and often not acceptable for existing codebases.
Clang module maps offer a practical bridge. They allow the build to treat selected headers as belonging to a module, so existing includes can be translated into imports by the compiler.
2. The Key Idea: Translate Includes into Imports
Clang documents this mechanism in Using Clang Module Map to Avoid mixing #include and import problems. At a high level, a module map tells the compiler:
module asio {
header "asio/include/asio.hpp"
header "asio/include/asio/ssl.hpp"
}
With the right build setup, when application code writes:
#include <asio.hpp>
#include <asio/ssl.hpp>
Clang can map those headers to the corresponding module and import the prebuilt module instead of reparsing the full header graph in every translation unit.
This keeps the application-facing API unchanged. The migration happens in the build configuration and in the third-party library packaging layer.
Terminology can be confusing here:
- C++20 named modules are the standard module language feature.
- Clang module maps describe how headers are mapped to modules.
- Clang header modules are Clang’s header-based module mechanism.
- Bazel C++ module support is the build-system layer that schedules scanning, module compilation, and normal C++ compilation actions.
This experiment uses module maps as a bridge between traditional headers and C++20 module artifacts, not as a source-level replacement for named module declarations.
3. Bazel Integration
To make this work in Bazel, the C++ rules need to understand two pieces of module metadata:
| File | Purpose |
|---|---|
xxx.cppm | Defines the C++20 module interface. |
xxx.modulemap | Declares which headers should be mapped to the module. |
In this experiment, I extended rules_cc with a module_header_maps attribute, so a target can explicitly declare its module map files alongside its module interfaces.
A target using this mechanism looks like this:
cc_library(
name = "asio",
srcs = [
"asio/src/asio.cpp",
"asio/src/asio_ssl.cpp",
],
hdrs = glob([
"asio/include/**/*.hpp",
"asio/include/**/*.ipp",
]),
includes = ["asio/include"],
local_defines = ["ASIO_SEPARATE_COMPILATION"],
visibility = ["//visibility:public"],
deps = [
"@boringssl//:ssl",
],
module_interfaces = ["asio.cppm"],
module_header_maps = ["asio.modulemap"],
)
The dependency behavior is intentionally conservative:
- Within one target:
srcsdepend on modules declared by the target’smodule_header_maps;module_interfacesdo not. - Across targets: if Target A depends on Target B,
srcsin Target A also depend on modules declared by Target B;module_interfacesin Target A still do not.
This keeps module dependencies available to implementation files without forcing module interfaces to depend on every transitive header module.
4. Example: Packaging Asio as a Module
Asio is a good example because it is header-heavy and frequently included by application code:
#include <asio.hpp>
#include <asio/ssl.hpp>
To package it as a module, we add a module interface and a module map.
The following module interface uses ACC-specific convenience pragmas. Section 7 explains how to approximate this with upstream Clang.
4.1 asio.cppm
#pragma ACC modules "export-all"
#pragma ACC modules "export-macros"
module;
#include "asio.hpp"
#include "asio/ssl.hpp"
export module asio;
This module interface reuses Asio’s existing headers inside the global module fragment and declares the resulting module as asio.
The two ACC pragmas are internal compiler extensions used to make third-party library packaging easier:
export-all: automatically exports declarations from the included headers.export-macros: automatically exports macro definitions from the included headers.
These pragmas are convenience helpers only. They do not change the semantic model of the library, and they are not supported by upstream open-source Clang today.
4.2 asio.modulemap
module asio {
header "asio/include/asio.hpp"
header "asio/include/asio/ssl.hpp"
}
The module map declares which headers should be associated with the asio module. After this is wired into Bazel, application code can keep using normal includes, while the compiler maps matching includes to the prebuilt module.
5. Case Study: async_simple
I tested this approach on async_simple, using the main branch at commit d8abc4fe. The modified version is available in the github_modulemap_demo branch, with the main change captured in commit 5c891cd.
Benchmark setup:
- Build type: clean build with no local cache reuse.
- Remote cache: disabled.
- Compiler: ACC, an internal Clang-based compiler built on Clang 20.
- Bazel: internal Bazel 9 with additional C++20 Modules support.
- Machine: Ubuntu 26.04, using a 96-core parallel build.
5.1 Overall Build Time
| Metric | Non-Module Mode | Module Mode | Improvement |
|---|---|---|---|
| Wall time | 28s | 25s | -10.7% |
| CPU time | 13m 51s | 4m 6s | -70.4% |
The wall-time improvement is modest because the build still has a long-tail task, LazyTest.cpp. However, the CPU-time reduction is substantial. This is the clearest signal that the compiler is doing much less repeated work.
With lower build parallelism, the wall-time improvement becomes more visible:
| Parallelism | Non-Module Mode | Module Mode | Improvement |
|---|---|---|---|
| 16 | 45.237s | 34.139s | 24.5% |
| 8 | 55.676s | 43.929s | 21.1% |
| 4 | 99.862s | 68.202s | 31.7% |
5.2 Single-File Analysis: LazyTest.cpp
| Metric | Non-Module Mode | Module Mode | Notes |
|---|---|---|---|
| Compile time | 28.0s | 19.5s | About 30% faster |
| Template instantiations | ~9,000 | ~5,000 | About 44% fewer |
| Processed classes | 500+ | 7 | Much narrower symbol visibility |
| Processed headers | 324 | 2 | Can theoretically be reduced further |
Even for the long-tail file, module mode reduces compile time and significantly reduces the amount of template and header work.
6. Verifying Include-to-Import Translation
Clang can report when an include is translated into a module import. Add:
-Rmodule-include-translation
This is useful when validating that the module map is actually taking effect. In practice, this diagnostic should show the selected headers being replaced by imports of the corresponding modules.
7. Reproducing This with Upstream Clang
The core include-to-import translation is based on upstream Clang behavior. The parts that need special handling are the internal convenience features used to package third-party libraries and the Bazel metadata used to describe module maps.
7.1 Replacing export-all
#pragma ACC modules "export-all" is relatively easy to replace. In an upstream-only setup, the module interface can explicitly export or re-export the public declarations that should be visible from the module.
This is more manual than the ACC pragma, but it preserves the same user-facing behavior: application code can continue to include the original headers, and the build system can map those includes to the module.
7.2 Replacing export-macros
#pragma ACC modules "export-macros" is harder. C++20 Modules do not provide equivalent macro export behavior, so code that depends on public macros still needs an include path.
A practical compromise is to isolate public macro definitions into a dedicated lightweight header. If a third-party library needs to expose macros, keep that header limited to macro definitions and minimal dependencies. Application code can include this small macro-only header, while the expensive headers are handled by modulemap-based include translation.
This topic is also being discussed in LLVM. See the RFC on extensions to export macros / preprocessor states for C++20 modules.
7.3 Avoiding Module Name Scanning in Bazel
One practical Bazel issue is module name discovery. Today, Clang does not provide a native interface for Bazel to scan a module map and discover which module it exposes.
A cleaner open-source design is to make the module name explicit in the rule attribute. Instead of modeling module_header_maps as a list, model it as a dictionary:
module_header_maps = {
"asio": "asio.modulemap",
}
The key is the module name, and the value is the module map file. With this shape, Bazel no longer needs to infer the module name by scanning the module map. The rule author provides the mapping explicitly, and the build graph can use it directly.
See the related rules_cc pull request: Support Using Clang Module Map to Avoid mixing #include and import problems.
8. When This Approach Works Best
This approach is most useful for libraries that are expensive to parse but relatively stable:
- Good candidates: third-party libraries, foundational utility libraries, networking libraries, serialization libraries, and template-heavy dependencies.
- Less ideal candidates: frequently changing application modules, libraries with complex macro-heavy public APIs, or code that relies on subtle include-order side effects.
Module granularity matters. A good default is one module per third-party library. For libraries with clear internal boundaries, such as crypto and ssl in BoringSSL, splitting them into separate modules may improve incremental build performance.
9. Limitations and Future Work
This is not a universal replacement for normal includes. The main limitations are:
- Macro compatibility: macros are still the hardest part to model cleanly with C++20 Modules.
- Manual module interfaces: upstream Clang users need explicit exports or re-exports instead of ACC’s automatic export helpers.
- Manual module maps:
modulemapfiles still need to be written by hand. Bazel does generatecppmapfiles internally, but I have not found a practical way to reuse that infrastructure for this workflow. - Build-rule support: Bazel rules need first-class metadata for module maps and module names.
- Library hygiene: libraries with strong include-order assumptions may need cleanup before they work well as modules.
Even with these limitations, module maps provide a practical migration path. They let teams get many of the compile-time benefits of C++20 Modules while keeping existing application source code unchanged.
10. Conclusion
Module maps are not a complete C++20 Modules migration strategy, but they are a practical bridge for stable, header-heavy dependencies. They allow teams to reduce repeated parsing cost while keeping application source code unchanged.
The main trade-off is that some packaging work moves into the build and dependency layer: module interfaces, module maps, macro compatibility, and Bazel metadata still need careful handling. Even so, this path can deliver meaningful build-time wins before a full source-level module migration is realistic.
11. References
- Clang Standard C++ Modules documentation
- Clang Modules and Module Map documentation
- Using Clang Module Map to Avoid mixing #include and import problems
- Bazel C++ Modules tracking issue
- [RFC] Extensions to export macros/(preprocessor states) for C++20 modules
rules_ccPR: Support Using Clang Module Map to Avoid mixing #include and import problems- Bazel
cppmapgeneration issue async_simpleasync_simplemodulemap demo branchasync_simplemodulemap demo commit e55c2df