An iOS project may contain very little Swift code while still bringing in dozens of Objective-C, C, or C++ translation units. Whenever a Cloud Mac job starts in a new working directory, those native dependencies invoke Clang again, eliminating the benefits of incremental builds. ccache is designed for this layer of repeated work: it generates a key from the preprocessor output, compiler options, and compiler identity, then reuses the object file directly on a hit.
It is not a universal switch for speeding up Xcode. Before integrating it, confirm that the time is actually being spent in Clang rather than the Swift frontend, linking, resource compilation, or tests.
Identify What Should Be Cached First
Keep a complete build log and search for CompileC, CompileC normal, and files ending in .m, .mm, .c, .cc, or .cpp. ccache is worth the effort only when these steps account for a meaningful share of the build time. Even with a correct configuration, a pure Swift project may see almost no benefit.
Record three baseline measurements first:
- the duration of a clean full build;
- the duration of another build with no code changes;
- the number of native translation units and the slowest targets.
Do not begin by caching all of DerivedData. It contains indexes, module caches, build databases, and intermediate artifacts that are tightly coupled to the Xcode version, SDK, absolute paths, and project graph. The ccache directory is better treated as a separate, observable cache unit.
First prove that the job repeatedly invokes Clang, then evaluate the hit rate. Otherwise, slower linking, Swift type checking, or time spent in script phases can easily be mistaken for cache failure.
Route Clang Through Wrappers
After installing ccache in the build environment, do not replace the system compiler files. Instead, create two lightweight wrappers in the project workspace and pass them through Xcode build settings. Rolling back then requires only removing the parameters, and other jobs on the same node remain unaffected.
set -eu
export CCACHE_BIN="$(command -v ccache)"
export CCACHE_DIR="$HOME/.cache/pingmini-ccache"
export CCACHE_BASEDIR="$PWD"
mkdir -p .ci/bin "$CCACHE_DIR"
cat > .ci/bin/clang <<'SH'
#!/bin/sh
exec "${CCACHE_BIN:?CCACHE_BIN is required}" /usr/bin/clang "$@"
SH
cat > .ci/bin/clang++ <<'SH'
#!/bin/sh
exec "${CCACHE_BIN:?CCACHE_BIN is required}" /usr/bin/clang++ "$@"
SH
chmod +x .ci/bin/clang .ci/bin/clang++
ccache --set-config="cache_dir=$CCACHE_DIR"
ccache --set-config="base_dir=$PWD"
ccache --set-config="compiler_check=content"
ccache --set-config="compression=true"
ccache --set-config="max_size=20G"
ccache --zero-stats
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-derivedDataPath "$DERIVED_DATA" \
build \
CC="$PWD/.ci/bin/clang" \
CPLUSPLUS="$PWD/.ci/bin/clang++"
ccache --show-stats
CC handles C and Objective-C, while CPLUSPLUS handles C++ and Objective-C++. Do not wrap the linker, and do not make the wrapper masquerade as the Swift compiler. After the build, confirm in the verbose log that .ci/bin/clang was actually executed. Seeing that ccache is installed does not prove that Xcode used it.
Keep Cache Keys Stable Across Jobs
CI systems often check out the same commit into directories that include a job number. The source code has not changed, but the absolute path has, which can alter debug information, header paths, and compiler options. CCACHE_BASEDIR and base_dir rewrite paths within the workspace as relative paths, making them the first step toward reuse across jobs.
The following inputs still need to remain fixed:
Pin the Toolchain and Build Parameters
The cache key should include the Xcode major version, SDK, target architecture, configuration name, and lockfile digest. When upgrading the toolchain, deliberately switch to a new cache namespace. That is easier to audit than mixing objects from old and new toolchains in the same directory.
The same cache must not serve both Debug and Release, and different architectures should not share an artificially simplified key. ccache detects parameter differences, but an overly broad outer persistence key causes unnecessary downloads and eviction.
Control Generated Files
A generated header that records the current time, a random identifier, or a temporary absolute path on every run invalidates every translation unit that depends on it. Generation scripts should overwrite a file only when its contents change, using a temporary file for comparison followed by an atomic replacement:
generate_config > "$TMPDIR/build-config.new"
if ! cmp -s "$TMPDIR/build-config.new" Generated/build-config.h; then
mv "$TMPDIR/build-config.new" Generated/build-config.h
else
rm "$TMPDIR/build-config.new"
fi
Avoid time macros in source code when their values change with every compilation. If the application genuinely needs the build time, place it in a separate file to limit the scope of recompilation.
Verify Cache Hits with Two Builds
The initial integration should be validated with two isolated runs. For the first run, reset the statistics while retaining an empty cache, then complete a full build. Before the second run, delete DerivedData but preserve ccache, and rebuild with exactly the same toolchain and parameters. Direct hits during the second run are what prove that reuse does not depend on the previous build directory.
Inspect cacheable calls, hits, misses, and uncacheable calls rather than looking only at the overall hit percentage. The first build should naturally consist mostly of misses. If the second run still produces only misses, investigate in this order:
- Check the build log to confirm that the wrappers were invoked;
- compare the workspace, SDK, architecture, and compiler options between the two runs;
- check whether generated headers change on every run;
- look for time macros, random values, or absolute paths;
- confirm that the cache directory is actually persisted after the job finishes.
If there are many uncacheable calls, use the ccache debug log to reproduce only one translation unit. This avoids generating a large volume of diagnostic files for the entire build. Disable debugging after finding the cause so that the logs themselves do not fill the disk.
Set Cleanup and Rollback Boundaries
Set the cache limit according to the size of the native targets rather than allowing unlimited growth. Run ccache --cleanup regularly, and monitor directory size, eviction counts, and write failures. Multiple concurrent jobs can share the same directory, but the underlying directory must reside on a reliable local file system. An unstable remote mount can consume all the saved compilation time in metadata access.
If you suspect incorrect reuse, preserve the logs and cache statistics first, then rerun the same command with CCACHE_DISABLE=1. If the problem disappears, narrow the cache scope by toolchain, target, and configuration. Disabling validation, relaxing timestamp checks, or ignoring header changes should not be treated as a long-term fix.
The final acceptance criterion is not an impressive hit rate. Three conditions must hold at the same time: the artifacts must match those produced with the cache disabled, the second run must perform substantially less native compilation, and cache invalidation must be explainable by toolchain or input changes. Only then does ccache become a maintainable layer of the build infrastructure.
Frequently asked questions
Can ccache speed up Swift compilation directly?
No. In this workflow ccache wraps Clang and helps Objective-C, Objective-C++, C, and C++ translation units. Swift still depends on stable DerivedData, suitable concurrency, and Xcode's incremental build behavior.
Why does the second build still report no cache hits?
Verify that Xcode is invoking the wrapper first. Then compare workspace paths, compiler flags, generated headers, and changing macros, because any of them can produce a different cache key.
Should CI persist DerivedData or only the ccache directory?
Persist ccache as a separate cache first. DerivedData is more tightly coupled to the Xcode version, absolute paths, SDK state, and project graph, so it requires stricter restoration keys.
Prepare a Dedicated Physical Mac for Your Next Build
Choose one of three fixed cloud Mac configurations for development, continuous integration, or local model testing, then configure it for the duration of your task.