Compiler Cache

A compiler cache stores object files from earlier compilations, keyed by source content and compiler flags, and returns them when the same inputs recur.

A compiler cache is a store of compiler output from earlier builds, keyed by a hash of the compilation inputs, so a translation unit whose inputs have not changed is served from the store instead of being compiled again. Because the key comes from content rather than from file timestamps, the same store produces hits on a clean checkout and on a machine that has never seen the project before.

That second property is why compiler caches appear in GitHub Actions workflows at all. A hosted job starts on a machine with no build tree from the previous run, which defeats timestamp based rebuild logic and leaves content addressing as the mechanism that still works.

Definition

A compiler cache is a wrapper placed in front of the compiler. The build system calls the wrapper with the arguments it would have passed to the compiler. The wrapper hashes the inputs of that one compilation, looks the hash up in a store, and either copies out the stored object file or runs the real compiler and writes the result back under the hash.

The unit of caching is one translation unit: one source file compiled to one object file with -c. Linking is outside the model, because a link step reads object files that the cache itself just produced and its inputs are not fixed by a single source file. The ccache manual lists the invocation shapes that are skipped for this reason, including a compiler called for linking and a compiler called to compile several source files in one command.

What goes into the key

The key has to change when the produced object file would change, and stay identical otherwise. That pulls in more than the source file. ccache documents two ways of assembling it, and the difference between them decides how a header edit behaves.

Hashed inputDirect modePreprocessor mode
Compiler identity and versionYesYes
Source file contentYesFolded into the preprocessed output
Preprocessor output from running the compiler with -ENoYes
Command line optionsYesYes, except the options that affect include file lookup
Contents of every included headerRecorded in a manifest and revalidatedFolded into the preprocessed output
Standard error written by the preprocessorNoYes

Direct mode hashes the source file and the options and skips the preprocessor, which is the faster path. It then reads a manifest stored under that hash. The manifest holds references to cached results together with the paths of the include files that were read and hash sums of those files, so the wrapper can check whether the headers behind a stored result are still the headers on disk. When no manifest entry revalidates, ccache falls back to preprocessor mode for that file and pays for one -E run.

Options are part of the key, so a flag change is a full miss across the tree. Moving a project from -O2 to -O3, changing the C++ standard, or upgrading the compiler package invalidates every entry at once, and the next build refills the store from scratch.

The two implementations a workflow is likely to use

Both common implementations are open source and both are drop-in wrappers.

  • ccache is a C and C++ compiler cache. Its compiler support covers GCC, Clang, MSVC, clang-cl, the Intel compilers, and NVCC (ccache manual).
  • sccache is a compiler wrapper in the same style with a wider language range and remote storage. It caches assembler, C and C++ through GCC, Clang and MSVC, Rust through rustc, CUDA through nvcc and Clang, and HIP through hipcc, and it can keep the store on local disk, S3, Google Cloud Storage, Azure Blob Storage, Redis, memcached, WebDAV, or the GitHub Actions cache (sccache repository).

sccache documents two limits worth knowing before it goes into a Rust workflow: crates that invoke the system linker are not cached, which covers bin, dylib, cdylib and proc-macro crates, and incrementally compiled crates are not cached at all, so CARGO_INCREMENTAL=0 is the usual setting alongside it (sccache repository).

Neither tool keeps state between GitHub Actions jobs on its own. The cache directory has to be restored and saved by the workflow, or live on a runner whose disk survives from one job to the next.

Example

A CMake project builds 400 translation units. The workflow installs ccache, restores the cache directory with actions/cache, and points CMake at the wrapper through the compiler launcher variables.

name: build
on:
  push:

jobs:
  compile:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get update && sudo apt-get install -y ccache ninja-build
      - name: Restore compiler cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/ccache
          key: ${{ runner.os }}-ccache-${{ github.sha }}
          restore-keys: |
            ${{ runner.os }}-ccache-
      - name: Configure
        run: |
          cmake -B build -G Ninja \
            -DCMAKE_BUILD_TYPE=Release \
            -DCMAKE_C_COMPILER_LAUNCHER=ccache \
            -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
      - name: Build
        run: cmake --build build
      - name: Cache statistics
        run: ccache -s

The key carries the commit sha so that every run writes a new entry, and the restore-keys prefix pulls the most recent earlier entry in at the start of the job. ccache -s at the end prints the counters the store keeps, which separate direct mode hits from preprocessed hits and from misses.

Now trace four runs of that workflow against the same repository. The header in question, include/geometry/vec3.h, is included by 12 of the 400 source files.

RunChange since the previous runWhat the wrapper doesCompilations that reach the compiler
1First run, empty storeNo manifest exists for any source file400
2One .cpp body edited399 manifests revalidate and return stored objects1
3vec3.h edited388 manifests revalidate; the 12 files that include the header fail revalidation and fall back to preprocessor modeUp to 12
4-O2 changed to -O3The option is part of the key, so no entry matches400

Run 3 is the case the manifest exists for. The wrapper does not need to know which files include vec3.h, because the manifest recorded the include set from the last successful compilation of each file, and the header hash comparison answers the question directly. The 12 affected files then take the slower path, and even there a hit is still possible: if the edit sits inside a block that the preprocessor drops for a given file, the preprocessed output is byte identical to the previous run and preprocessor mode matches an existing result.

Run 4 is the case that catches teams out. A compiler upgrade delivered by a base image update has the same effect, because the compiler identity is hashed alongside the options. A store that looked healthy for weeks goes cold on the run after the image changes, and the first build afterwards pays full price.

FAQ

What is a compiler cache?

A store of compiler output from earlier builds, addressed by a hash of the compilation inputs. When a later build presents the same source content, the same compiler, and the same options, the stored object file is copied into place and the compiler is skipped.

How is a compiler cache different from an incremental build?

An incremental build compares timestamps and a dependency graph inside one build tree, so it needs that tree to survive. A compiler cache hashes the inputs of each compilation, so it produces hits on a clean checkout and on a machine that has never built the project before.

Does a compiler cache work in GitHub Actions?

Yes, once the cache directory outlives the job. Every hosted job starts on a fresh machine, so the cache directory has to be restored at the start of the job and saved at the end, or held on a runner whose disk persists between jobs.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.