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.
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.
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.
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.
Related
- Async build execution and cache strategies — the parent guide on the four caches and their keys.
- How to set up build caching for C extensions — the persistence options in more detail.
- Pinning and caching manylinux images in CI — the layer above, and why a floating tag destroys the hit rate.