[RFC][Modules] The Modules Cache System for the -fmodules-driver mode

Introduction

Previously and even today, only implicit module builds were (and still are) supported for Clang modules exclusively. The new -fmodules-driver adds support for explicit module builds for both Clang modules and C++ named modules. However, one of the main advantages of modules is their ability to being cached. Currently, -fmodules-driver does not support this. Therefore, the RFC strives towards an effort to propose an efficient, correct and safe filesystem cache which integrates seamlessly with Clang flags using the -fmodules-driver mode.

The Modules Cache System at Command Line Interface(CLI) level

To illustrate how this feature will look like at CLI level, let us take an example.

CLI Command:

clang++ -std=c++23 main.cpp A.cpp -fmodules-driver -fmodules -fmodule-map-file=module.modulemap -o result.out -fmodules-driver-cache-path=prebuilt -fmodules-prune-interval=604800

Files corresponding to the above command:

// main.cpp
#include "MyLib.h"
import std;
import A;

auto main() -> int {
   std::println("{}", make_greeting("modules"));
   std::println("The answer is: {}", get_answer());
}
// A.cpp
export module A;
import std;

export auto make_greeting(std::string_view Name) -> std::string {
   return std::format("Hello, {}!", Name);
}
// module.modulemap
module MyLib {
   header "MyLib.h"
   export *
}
// MyLib.h
auto get_answer() -> int { return 42; }

When the CLI command is executed, the following cache entries will be populated in cache:

ls prebuilt/*/*.{pcm,o,out}    
# prebuilt/1AYBIGPM8R2GA/main.o
# prebuilt/3L1K4LUA6O31B/A.pcm
# prebuilt/3L1K4LUA6O31B/A.o
# prebuilt/VH0YZMF1OIRK4/MyLib.pcm
# prebuilt/VH0YZMF1OIRK4/MyLib.o
# prebuilt/7Q9X2CPL5M8TZ/result.out

Deep Dive into the Internals of The Modules Cache System

The Modules Cache System will have 3 major areas to tackle when implementing it:

  • File System Cache
  • Cache Concurrency Control
  • Integrating The Modules Cache System with Clang flags

File System Cache

At present, only generation of valid ContextHash for Clang modules is supported. With the new File System Cache, we are going to support generation of valid ContextHash for Clang modules, C++ named modules and non-modules.

File System Cache Structure

There are 3 types of nodes in the cache file system tree:

  1. cache-root directory: It is the root directory of our cache structure.
  2. <CompilationGraph_NodeType>_ContextHash_N directories: the directories which are named after the cache key of their corresponding source/module/non-module file. They store the prebuilt artifacts and metadata corresponding to a particular source/module/non-module file.
  3. artifact files: the various prebuilt artifacts. Examples: .pcm, .o, .out files.

Algorithm to detect changes in dependencies and make build/rebuild decisions

The below described algorithm is effective in detecting any changes/additions in any node or edge of CompilationGraph( More details about CompilationGraph data structure can be found in the merged PR #152770).

Determining ContextHash using Recursive Hashing: We are going to traverse the CompilationGraph recursively in top-down fashion(i.e., from parent nodes to subsequent children nodes) starting from the node RootNode(we are not going to generate ContextHash for RootNode).

For each JobNode that we encounter, its corresponding ContextHash computation may include 3 hash values:

  1. HashOfContents: It is the hash of the contents of that JobNode. Contents include - source/module file name, textually included header files and any driver arguments that can affect emitted output after compilation of that file. [Note: we will get information about ClangModuleDeps or NamedModuleDeps through HashOfParent.]
  2. HashOfParent: ContextHash of JobNode’s parent JobNode(s). This will be passed down recursively from parent JobNode(s) to a child JobNode.
  3. CombinedHash: It is the hash obtained after combining HashofContents and all HashOfParent(s).

There can be 2 types of JobNodes depending on its parent node:

  1. JobNode with only RootNode as parent: in this case, its ContextHash is just HashofContents.
  2. JobNode with at least one other JobNode as parent: in this case, its ContextHash is CombinedHash.

Build/Rebuild Decision & Storing Cache Entries: After generating the ContextHash of a particular JobNode, we have to make a build/rebuild decision. Inside the cache-root directory, if we are unable to find the directory with ContextHash as name(i.e., the <CompilationGraph_NodeType>_ContextHash_N directory), we need to build/rebuild.
If we need to build/rebuild, execute the -cc1 job. Store its emitted output in a path determined by cache-root and ContextHash of that JobNode.

[Note: If a parent JobNode was rebuilt, the decision to rebuild the children JobNode(s) will be automatically propagated. This is because when the ContextHash of a parent JobNode changes, the ContextHash of its children JobNode(s) will also change due to top-down chaining of HashOfParent(s) in recursion.]

I would need to implement the method:

CacheLookUp for writing/reading/touching to and from cache. The filesystem cache can lookup a particular artifact file using cache-root path, ContextHash and name of the artifact file.

Cache Concurrency Control

Flowchart that explains the proposed cache concurrency control system:

Important factors to consider:

  1. In a particular process, a file must be built at most one time.
  2. Locks are acquired per file being built.
  3. Cache management and actual compilation should be dealt separately.
  4. Maintaining cache correctness should be the utmost priority. We have to maintain correctness even at the cost of duplicate builds.

Integrating The Modules Cache System with Clang flags

-fmodules-driver-cache-path

The -fmodules-driver-cache-path flag is a new flag we are going to introduce. This is because we are, for the first time, adding support for caching C++ named modules and non-modules. The -fmodules-driver-cache-path flag works in a similar fashion as -fmodules-cache-path as described in the Clang Modules documentation under Command-line parameters section.

-fmodules-prune-interval and -fmodules-prune-after

Let’s take the example files from The Modules Cache System at Command Line Interface(CLI) level and the case of -fmodules-prune-interval CLI command. The command is executed and cache entries are populated.

Assume at some frame of time between 0 seconds and default delay, module MyLib was modified. Say, its ContextHash changes from VH0YZMF1OIRK4 to K4N8Z1WQ7R2XM. Then due to recursive chaining, ContextHashes for main.cpp artifacts and result.out will also change. New cache entries will be added after a fresh execution of -cc1 jobs:

ls prebuilt/*/*.{pcm,o,out}    
# prebuilt/1AYBIGPM8R2GA/main.o
# prebuilt/3L1K4LUA6O31B/A.pcm
# prebuilt/3L1K4LUA6O31B/A.o
# prebuilt/VH0YZMF1OIRK4/MyLib.pcm
# prebuilt/VH0YZMF1OIRK4/MyLib.o
# prebuilt/7Q9X2CPL5M8TZ/result.out
# prebuilt/K4N8Z1WQ7R2XM/MyLib.pcm
# prebuilt/K4N8Z1WQ7R2XM/MyLib.pcm
# prebuilt/P9T3L6YV0H5DA/main.o
# prebuilt/X2M7Q4C8B1ZRL/result.out

From the above files listing, we see that artifacts inside prebuilt/1AYBIGPM8R2GA, prebuilt/VH0YZMF1OIRK4 and prebuilt/7Q9X2CPL5M8TZ became obsolete. After the default delay, -fmodules-prune-interval will take an attempt and try to remove these obsolete directories and files. If successful, the obsolete cache entries will be purged:

ls prebuilt/*/*.{pcm,o,out}
# prebuilt/3L1K4LUA6O31B/A.pcm
# prebuilt/3L1K4LUA6O31B/A.o
# prebuilt/K4N8Z1WQ7R2XM/MyLib.pcm
# prebuilt/K4N8Z1WQ7R2XM/MyLib.pcm
# prebuilt/P9T3L6YV0H5DA/main.o
# prebuilt/X2M7Q4C8B1ZRL/result.out
1 Like

I’m a bit confused - if the compiler’s managing the cache, that sounds to be a lot like an implicit build (ie: one the build system doesn’t have to be aware of).

How do these two things (implicit modules, and this proposed cache system) differ in general, and specifically in terms of “what does the build system need to know?”?

Previously and even today, only implicit module builds were (and still are) supported for Clang modules exclusively.

What’s the meaning? I guess you’re saying clang module cache?

For cache key, I feel for named module, it is enough to hash the commnad line, the source and its dependent module file.


CC @naveen-seth

1 Like

RE the cache key:

For the Clang module cache, a CowCompilerInvocation is first generated and then modified to add all module dependencies and other adjustments. This CowCompilerInvocation is also cleaned up to remove any options that should not affect the compiler hash. The full -cc1 command-line used for building the cache key is then generated from this compiler invocation. This produces a context hash that isn’t affected by the ordering of arguments in the original command-line, or by options that don’t impact the module build.
Doing the same in ModulesDriver.cpp would also make for a stable cache key.
(But maybe we can start with a simpler hashing and then improve on it once the basics work.)

How do these two things (implicit modules, and this proposed cache system) differ in general, and specifically in terms of “what does the build system need to know?”?

The main motivation behind -fmodules-driver was to enable basic modules-usage without a build system (for Clang and C++ named modules). So similar to an implicit module build, an external build system would not need to be aware of it.
However, the -fmodules-driver builds themselves are still explicit module builds internally. The driver constructs and executes explicit -cc1 module compilation jobs after discovering the deps.

1 Like

What’s the benefit/motivation for having these two modes? (what’s the externally observable difference between them, from a user’s perspective?)

Currently, C++20 named modules don’t have implicit builds. Even for simple examples like this, you must use -fmodules-driver. (Compilation is still slow because the large std module isn’t cached, which we also hope to solve with what is proposed here)

Explicit module builds also have some other benefits over implicit module builds:

But due to the complexity, this is not so wonderful in practice. Clang has to compile the same header in different preprocessor context into different module file for correctness conservatively. Then this may trigger the redeclaration in different TU problems. So that the user of implicit header modules has to design a module system bottom up carefully. And clang implicit header module has many issues with soundness and performance due to tradeoffs made for module reuse and filesystem contention.
— Standard C++ Modules — Clang 23.0.0git documentation

(We don’t make use of all of the potential benefits of explicit module builds in -fmodules-driver though, because the execution of -cc1 jobs is fully sequential.)

1 Like

While I don’t have much/any say here, as I’m not a maintainer of any of this - for whatever it’s worth, I’d be strongly in favor of an assessment of the differences between the two situations, and whether they can be unified/avoid having two implementations of similar functionality.

If there are problems with the existing implicit modules support - it’d be good to understand/document why it’s impractical to solve those to then reuse it for this use case (or not solve them, perhaps - if they’re solvable/but not essential, perhaps fixing them is orthogonal to generalizing the existing implicit modules solution for clang header modules to be applicable to C++ standard modules)

1 Like

I think this Swift Forums post (end of the “Implicit Module Builds” section and the beginning of the “Explicit Module Builds” section) does a good job of describing the benefits of explicit module builds over implicit module builds.
Because of this, it seems to me like there generally is a move away from implicit module builds on the build-system side. It has been a bit since this comment, but it seems like implicit module build mechanism could possibly become only an implementation detail of dependency scanning in the far future.
CC: @Bigcheese

Given this, I also think it makes sense for Clang’s internal modules build system to use explicit module builds in the long term. Since implicit module builds for C++ named modules didn’t exist yet, explicit builds were probably also the natural solution for the initial -fmodules-driver implementation.

I still think it would be good to implement implicit module builds for C++ named modules and I hope to make an RFC for this soon (though I probably could only start working on it until later this year):

  • The dependency scanner relies on the implicit module build mechanism for Clang modules to produce the -cc1 commands that are executed for the explicit module build (relevant rfc from 2020). Because we don’t have implicit C++ named module builds, we have to fix up the command-lines later. If the dependency scanner could also produce valid -cc1 command lines for explicit C++ named module builds, this would not just make things easier for -fmodules-driver, but for external build systems too.

  • The downstream LLVM-CAS (swiftlang/llvm-project) has support for Clang modules only. Adding LLVM-CAS support for C++ named modules would then also require the dependency scann for C++ named modules to be implemented using the implicit module build mechanism. (CC @blangmuir)

(The C++ implicit module builds would not need to be user-facing, we just need the mechanism for the scan)

1 Like

Separate from the implementation details, “Clang’s internal modules build system” you’re describing is, essentially, an implicit modules system. One where the person or tool invoking the build doesn’t have to be aware of the details of modules, their dependencies, and building them in order, etc.

What I’d like to see (but, again, I’m not a decider here, so this is only advisory, not prescriptive) is one of: an effort to avoid creating two implicit build systems, a clear/reliable commitment to converge back to one implicit modules build system, or a strong justification for having two in perpetuity.

-fmodules-driver is indeed very similar to Clang’s existing implicit build system from a user perspective, you just add a flag and the clang program handles the PCMs.

The biggest issue with Clang’s existing implicit build system is that because of the way it works, it has to make a choice between correctness and performance, and it has to choose performance or it’s useless. This mostly manifests as reusing PCMs when it doesn’t actually know it’s safe to do so (different search paths or other flags).

While this is technically fixable in the current design (by just doing the same thing clang-scan-deps does), it wouldn’t fix the other performance issues caused by each clang acting in isolation late in the frontend, and it would overall slow down implicitly built modules even more. It also wouldn’t fix the C++20 named modules problem.

-fmodules-driver/EBM still leverages implicitly built modules, but in quite a different way than just running clang -fmodules ..., and we’re increasingly specializing this mode. Most of these differences are in InProcessModuleCache, and leverage scanning occurring in a single process. This usage will stay around as it’s needed to drive EBM.

Both Apple and I believe CERN still have usage of cross process IBM, but we (Apple) have been moving away from it, and I’m not aware of anyone else adopting it for a while now due to how many issues it has.

The other major usage is LLVM itself with the modules bots, but this is mostly to ensure that this configuration keeps working. Eventually this should just be able to use -fmodules-driver, or CMake modules support if it is ever able to handle header units directly.

So where we end up is that we need Clang’s existing IBM system, but expect the -fimplicit-modules driver parts to eventually go away or just get switched over to be -fmodules-driver based. When this can happen is the hard part. If you’re careful enough, the existing IBM system works most of the time, and so we’re trying to replace an unsound system with a sound one that fundamentally has to do more work. The clean and incremental build performance needs to match, or existing users need to get switched to EBM at a higher level than inside clang.

1 Like

OK, so this is a case of wanting to build a new thing, that from the user’s perspective is equivalent to the old thing, but fixes bugs (in soundness, and… *)

If that’s the case - could we frame it more like that - rather than introducing new build flags (eg: do we need -fmodules-cache-path and -fmodules-driver-cache-path? rather than a single cache path that’s used by implicit modules builds & used differently depending on whether it’s the old implicit system or the new one?) & having -fimplicit-modules be the same flag everyone uses for the new or old thing and then have a -fenable-new-implicit-modules (& eventually swap the default & allow opt-out `-fno-enable-new-implicit-modules) that we can eventually deprecate/remove?

But I guess all these ships have already sailed and I’m just old man yelling at clouds - so carry on. Sorry for the noise.

  • I thought scan-based builds would also improve build performance (& I thought that was some of the original motivation) by providing more parallelism because you won’t have all the builds blocking on waiting for the first module to finish building (because the scan will give dep info so you can go find another non-dependent thing to build)

(also I’m just a bit sad that we’re building another implicit build system… )