Using ccache and sccache in spatial wheel CI

This page answers one question: your matrix recompiles GDAL, PROJ and GEOS on every run, so how do you put a content-addressed compiler cache in front of that build and make it hit reliably across ephemeral CI runners? It sits inside the Async Build Execution and Cache Strategies section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the wiring, the storage choice and the statistics that tell you whether it is working.

Where a compiler cache sits relative to the other caches in a spatial build Three cache layers in order of what they store. The container image layer holds the toolchain and system packages and is keyed on the Dockerfile and base digest. The compiler object cache holds individual object files and is keyed on preprocessed source content. The native prefix cache holds the finished libraries and is keyed on their versions. The compiler cache is the only one whose key is computed from content rather than declared, which makes it the only one that cannot serve a wrong result. image layers the toolchain and system packages — keyed on the Dockerfile and base digest changes rarely; a miss means rebuilding the image, not the wheel compiler objects individual .o files — keyed on preprocessed source, flags and compiler identity content-addressed, so a hit cannot be wrong; this is the layer this page is about native prefix the finished libgdal, libproj, libgeos — keyed on versions and the build script fastest when it hits; a bad key here serves objects built against a different library

Context & Root Cause

A compiler cache intercepts each compilation, hashes the preprocessed source together with the compiler identity and the exact flags, and returns a stored object file when that hash has been seen before. Nothing about your build has to change: the cache is installed as the compiler driver, and the build system continues to invoke what it thinks is the compiler.

The reason this matters more for spatial builds than for most is scale. GDAL is thousands of translation units, PROJ and GEOS several hundred more, and a routine change — bumping a patch version, editing one source file, adding a configure flag — recompiles the lot. With a warm object cache, the same change recompiles only what actually differs, turning a twelve-minute native build into seconds. The obstacle is that CI runners are ephemeral, so the cache has to be persisted somewhere and restored with a key that hits.

Solution / Fix

This targets ccache 4.8+ or sccache 0.7+, GCC 13 or Clang 16, and a manylinux build container.

1. Install the cache and put it in front of the compiler

export CCACHE_DIR=/ccache
export CC="ccache gcc"
export CXX="ccache g++"
ccache --set-config=max_size=5G
ccache --set-config=compression=true

For CMake-driven builds, the launcher variables are cleaner than rewriting CC, because they survive CMake’s own compiler identification:

cmake -B build \
  -DCMAKE_C_COMPILER_LAUNCHER=ccache \
  -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

2. Make the hash stable across runners

# Paths and timestamps differ per runner and would otherwise miss every time
export CCACHE_BASEDIR="$PWD"
export CCACHE_NOHASHDIR=true
export CCACHE_SLOPPINESS=locale,time_macros,include_file_mtime,include_file_ctime

CCACHE_BASEDIR rewrites absolute paths inside the hash to be relative to the build root, which is what makes a cache populated in /home/runner/work usable from /build.

3. Persist it between jobs

- uses: actions/cache@v4
  with:
    path: /ccache
    key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ github.sha }}
    restore-keys: |
      ccache-${{ matrix.os }}-${{ matrix.arch }}-

The commit in the key with a broad restore-keys prefix is deliberate: every run saves a fresh entry and restores the most recent previous one, so the cache moves forward instead of going stale at whichever commit first populated it.

4. Report the statistics

ccache --show-stats --verbose | tee ccache-stats.txt

Verification

# 1. The cache is actually being used, not bypassed
ccache --show-stats | grep -E 'cache hit|cache miss'
# expected: a non-zero hit count on the second and later runs
# 2. The hit rate is high enough to matter
python - <<'PY'
import re, sys
txt = open("ccache-stats.txt").read()
hit = int(re.search(r"cache hit \(direct\)\s+(\d+)", txt).group(1))
miss = int(re.search(r"cache miss\s+(\d+)", txt).group(1))
rate = hit / (hit + miss) if hit + miss else 0
print(f"hit rate {rate:.0%}")
sys.exit(0 if rate > 0.5 else 1)
PY
# 3. The wheel is identical whether the cache hit or missed
CCACHE_DISABLE=1 python -m build --wheel -o out-cold
python -m build --wheel -o out-warm
sha256sum out-cold/*.whl out-warm/*.whl | awk '{print $1}' | sort -u | wc -l
# expected: 1

The third check is the reassurance people want before trusting a cache in a release pipeline. Because the key is derived from content, a hit is by construction an object compiled from identical inputs — but demonstrating it once, on your own stack, is cheap and settles the question.

ccache or sccache

The two tools solve the same problem with different storage models, and the choice follows from where your runners are.

ccache and sccache compared across five properties Ccache stores objects in a local directory, which the CI cache action must upload and download per job; it supports C and C plus plus and is the simpler setup. Sccache can store objects in a shared object store such as S3 or a Redis instance, which removes the per-job transfer and shares hits across every runner and branch; it needs credentials and a storage policy. Both are content-addressed and neither can serve a wrong object. ccache sccache where objects live a local directory local, or a shared object store cost per job upload and download the whole store none — fetched per object sharing across runners only through the CI cache immediate, across branches too setup two environment variables credentials and a bucket policy languages C, C++, and more C, C++, Rust, CUDA start with ccache; move to sccache when the store outgrows the CI cache's per-entry limit

The size threshold is the practical decision point, and a compiled GDAL reaches it sooner than people expect. A store holding the objects for GDAL, PROJ, GEOS and their codecs across two architectures runs to a few gigabytes, which is beyond several providers’ per-entry cache limits — and the failure when it is exceeded is silent: the save step reports success and every subsequent restore misses.

Compression helps and does not remove the ceiling. Setting a maximum size below the provider’s limit keeps the store within bounds and simply evicts the least recently used objects, which for a build that recompiles most of the same code every time is a reasonable trade.

Making the Hit Rate Stay High

A cache that hits at ninety per cent and then quietly drops to zero is worse than none, because the cost stays and the benefit does not. Four things move the rate, and all four are visible in the statistics.

Four causes of a collapsed cache hit rate and the statistic that reveals each A changed compiler produces misses across the board and appears as a jump in the compiler-check statistic. Absolute paths in the hash produce misses whenever the build directory differs and are fixed by the base directory setting. A changed flag set invalidates everything and appears as a full-miss run after a configure change. And an evicted store shows as a cache size at its maximum with a rising miss count. Each is diagnosable from the statistics output rather than by guesswork. the compiler changed a base-image refresh brought a new GCC — every hash differs seen as: a full-miss run with no source change; fixed by pinning the image digest absolute paths in the hash the build directory differs between runners seen as: consistent misses across runners; fixed by CCACHE_BASEDIR the flags changed a configure change altered CFLAGS for every translation unit seen as: one full-miss run, then normal — expected, not a fault the store is full size at maximum with rising misses; raise the cap or narrow what is cached

The third row is worth calling out because it looks like a regression and is not. A configure change that alters the flags legitimately invalidates every object, and the next run repopulates. Treating that single full-miss run as a failure leads people to disable the cache; recognising it as expected keeps it in place for the ninety-nine runs where it works.

Printing the statistics at the end of every job is what makes all four visible. Two lines in the log — hit rate and store size — turn cache health from something nobody watches into something anyone reviewing a slow build can check.

Pitfalls & Alternatives

Caching the object store and the native prefix with the same key. They have different invalidation conditions: the prefix depends on library versions, the objects on source content. One key for both means either stale objects or needless misses.

Forgetting that sccache needs the server running. It starts a background process on first use and inherits its configuration then; changing environment variables afterwards has no effect until it is restarted. A stop-and-start at the top of the job removes a confusing class of no-op configuration.

Enabling CCACHE_SLOPPINESS broadly. The settings listed above are safe for a reproducible container build; adding more — pch_defines, system_headers — trades correctness for hit rate in ways that can produce objects compiled against headers that have changed.

Assuming the cache helps a clean-room release build. If your release policy is to build from an empty cache for provenance reasons, the cache is a development convenience only, and the release timing should be planned around the cold number. That is a legitimate policy and worth stating explicitly rather than discovering.

Frequently Asked Questions

Does a compiler cache affect reproducibility?

It should not, and it is worth proving on your own stack. Because objects are keyed on preprocessed content, compiler identity and flags, a hit returns something compiled from identical inputs. The byte-comparison check above is the demonstration; run it once when you adopt the cache and once whenever the toolchain changes.

What hit rate should I expect?

For a build where only your own sources change, well above ninety per cent — the native stack is untouched and every object comes from the cache. For a build following a dependency bump, close to zero for that library and high for everything else. A rate that is persistently in the middle usually means paths or flags are varying between runs.

Should the cache be shared between branches?

For reading, yes; it makes contributor builds fast. For writing, restrict it to the default branch so an experimental branch cannot populate a store that a release build later reads. That asymmetry is easy to express in most CI systems and removes an entire class of surprise.

Is it worth caching for macOS and Windows too?

Yes, and the mechanics differ slightly: ccache works well on macOS, and on Windows sccache is the more practical choice because it handles MSVC. The gain is the same in kind — the native stack stops being recompiled — and on the slower hosted runners it is often larger in absolute terms.

Can the cache replace the native prefix cache?

Not entirely. The object cache still has to run the build system, which means configure steps, linking and installation happen on every run even when every compilation is a hit. A prefix cache skips all of that, which is why the two compose well: the prefix cache when it hits, the object cache when it does not.

How do I clear it when something looks wrong?

ccache --clear locally, and a new cache key prefix in CI — bumping a version string in the key is simpler and safer than trying to delete entries from a provider’s store. Do it rarely; a cleared cache costs one slow run for every runner and platform.