Cache compilation in CI with ccache - #179
Open
kylelutze wants to merge 1 commit into
Open
Conversation
CI rebuilds every object from scratch on each of the four builds a run performs, across two container images. Wrapping the compilers in ccache lets the compiles hit a content-addressed store instead. Only ccache's store is cached. The build/ tree and .sconsign.dblite stay out of it: those are incremental state whose correctness depends on scons's dependency scanner having recorded every input, and it does not record all of them -- DET_INIT is read straight from the environment, and the Configure results in config.h and options_cache.py are not tracked either. Persisting that across runs is how a stale object survives a source change. ccache re-hashes the source, every header it includes by content, the compiler binary and the full command line on every compile, so scons still rebuilds its dependency graph from a fresh checkout each run and only the individual compiles are reused. CCACHE_SLOPPINESS is left unset for the same reason: include_file_mtime and include_file_ctime would trade content hashing for timestamps, and time_macros would let ccache serve a stale __DATE__/__TIME__ banner for GlobalContainerArgs.cpp. CCACHE_COMPILERCHECK=content covers a g++ point release landing inside a base image. Two tripwires: the per-run statistics are printed, so a change to a widely included header that still shows near-total direct hits is visible, and the existing harnesses already run against the produced binaries. Only master writes the cache, and it writes whenever the compiles ran, including runs a later test step failed -- the entries are content-addressed, so a failing test says nothing about whether the objects are the right objects. Enabled by CCACHE=1 in the environment rather than a scons option, so it cannot stick in options_cache.py. Windows is not covered.
kylelutze
force-pushed
the
ci/ccache-build-caching
branch
from
September 7, 2026 01:35
97960c2 to
3d0d242
Compare
Contributor
|
Review of the ccache change. Six findings, most notable first. None is a correctness blocker for the common path (push-triggered fresh run); 1–3 are edge cases around re-runs and manual dispatch that would silently defeat caching rather than fail loudly.
(written by Junior, my agent) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CI rebuilds every object from scratch on each of the four builds a run performs, across two container images. This wraps the compilers in ccache so the compiles hit a content-addressed store instead.
Only ccache's store is cached
build/and.sconsign.dblitestay out of it. Those are incremental state whose correctness depends on scons's dependency scanner having recorded every input, and it doesn't record all of them —DET_INITis read straight from the environment (SConstruct:322), and the Configure results inconfig.h/options_cache.pyaren't tracked either. Persisting that across runs is how a stale object survives a source change.ccache re-hashes the source, every header it includes by content, the compiler binary and the full command line on every compile. So scons still rebuilds its dependency graph from a fresh checkout every run and decides what to compile from scratch; only the individual
g++ -cinvocations are reused. It's stricter than scons here —DET_INITlands inCXXFLAGS, so ccache keys on it where scons doesn't.CCACHE_SLOPPINESSis deliberately unset:include_file_mtime/include_file_ctimetrade content hashing for timestamps, andtime_macroswould let ccache serve a stale__DATE__/__TIME__banner forGlobalContainerArgs.cpp:347.CCACHE_COMPILERCHECK=contentcovers a g++ point release landing inside a base image.Tripwires
ccache -s -vis printed per run — a change to a widely included header that still shows near-total direct hits means the cache isn't seeing it.A cold build is
gh cache deleteon the twoccache-ubuntu:*entries, then re-run. On demand rather than on a timer: ccache keys on content, so a stale entry needs a hash collision, not a stale clock, and a weekly cold build spends 14 minutes of two runners to re-assert that.Cache lifecycle
Only master saves; PR branches can't read each other's caches anyway, and letting every PR write would churn the quota and evict what PRs restore from. Master saves whenever the compiles ran, including runs a later test step failed — the entries are content-addressed, so a failing harness says nothing about whether the objects are the right objects, and a build that compiled the whole tree and then failed the LAN test shouldn't throw the tree away.
!cancelled()rather thanalways()so a cancelled run doesn't upload during the grace period.CCACHE_MAXSIZE=1G. A single cold build is ~130M per image; each master push touching a widely included header adds roughly another tree's worth before LRU catches up. Entries are keyed byrun_id, so GitHub's 10G repo quota evicts the old ones and only the newest prefix match matters.Enabling
CCACHE=1in the environment, not a scons option, so it can't stick inoptions_cache.py. Shared helper inscons/ccache.py, used by both SConstructs. It resolves ccache viashutil.which— scons scrubsPATHfor build commands, so the usual/usr/lib/ccachesymlink trick would silently do nothing here. Missing ccache withCCACHE=1set is a hard error rather than a silent uncached build.Verified locally
With a logging pass-through shim (no ccache on the dev machine):
CCACHE=1, no ccache onPATH→ aborts withCCACHE is set but ccache was not found on PATH.test/SConstructemitsccache g++ -o TestsRunner.o -c …and the object builds.Configureis fine with the prefixed compiler.CCACHEunset → zeroccacheentries incompile_commands.json, andconfig.h/options_cache.pybyte-identical. Default builds untouched.The first master run will be a cold build that populates the cache; the speedup starts after that.
Not covered
Windows. The msys2 job needs path translation between the MSYS-style
CCACHE_DIRand the Windows pathactions/cachetars, which I couldn't test locally. The SConstruct side already works for it if we want it as a follow-up.