用 ccache 加速云端 Mac 的原生依赖编译

用 ccache 加速云端 Mac 的原生依赖编译

一个 iOS 工程可能只有少量 Swift 代码,却带着数十个 Objective-C、C 或 C++ 编译单元。云端 Mac 的任务每次进入新工作目录后,这些原生依赖重新调用 Clang,增量构建优势随之消失。ccache 适合处理这一层重复工作:它根据预处理结果、编译参数和编译器身份生成键,命中时直接复用目标文件。

它不是通用的 Xcode 加速开关。接入前要先确认耗时确实落在 Clang,而不是 Swift 前端、链接、资源编译或测试阶段。

先确认缓存对象

保留一份完整构建日志,搜索 CompileC、CompileC normal 与 .m、.mm、.c、.cc、.cpp 文件。若这些步骤占据明显时间,ccache 才有投入价值。纯 Swift 工程即使配置正确,收益也可能接近零。

先记录三项基线:

  • 清理后完整构建耗时;
  • 不改代码再次构建的耗时;
  • 原生编译单元数量与最慢的几个目标。

不要一开始就缓存整个 DerivedData。它包含索引、模块缓存、构建数据库和中间产物,与 Xcode 版本、SDK、绝对路径及工程图耦合较深。ccache 目录更适合作为独立、可观察的缓存单元。

先证明任务重复调用了 Clang,再讨论命中率。否则很容易把链接变慢、Swift 类型检查或脚本阶段耗时误判为缓存失效。

用包装器接管 Clang

在构建环境中安装 ccache 后,不要替换系统编译器文件。应在工程工作区创建两个轻量包装器,再通过 Xcode 构建设置传入。这样删除参数即可回退,也不会影响同一节点上的其他任务。

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 负责 C 与 Objective-C,CPLUSPLUS 负责 C++ 与 Objective-C++。不要包装链接器,也不要把包装器伪装成 Swift 编译器。执行后应从详细日志确认真正运行的是 .ci/bin/clang,仅看到 ccache 已安装并不能证明 Xcode 使用了它。

让缓存键跨任务稳定

CI 常把同一提交检出到带任务编号的目录。源码没有变化,绝对路径却不同,调试信息、头文件路径和编译参数都可能改变。CCACHE_BASEDIR 与 base_dir 会把工作区内路径改写为相对形式,是跨任务复用的第一步。

仍需固定以下输入:

固定工具链与构建参数

缓存键应包含 Xcode 大版本、SDK、目标架构、配置名称和锁文件摘要。升级工具链时主动切换缓存命名空间,比让新旧对象混在同一目录更容易审计。

同一缓存不能同时服务 Debug 与 Release,也不要让不同架构共享人为简化的键。ccache 会识别参数差异,但外层持久化键过宽会带来大量无效下载与淘汰。

控制生成文件

每次写入当前时间、随机标识或临时绝对路径的生成头文件,会让依赖它的编译单元全部失效。生成脚本应在内容变化时才覆盖文件,并使用临时文件比较后原子替换:

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

避免在源码中使用会随编译变化的时间宏。若业务确实需要构建时间,把它放进单独文件,减少受影响的编译范围。

用两轮构建验收命中率

首次接入应执行两轮隔离验收。第一轮清空统计但保留空缓存,完成完整构建;第二轮删除 DerivedData,保留 ccache,再以完全相同的工具链和参数重建。第二轮出现直接命中,才能证明复用不依赖旧构建目录。

检查 cacheable calls、hits、misses 与 uncacheable calls,不要只看总命中百分比。首次构建本来就应以 miss 为主;若第二轮仍全部 miss,按以下顺序排查:

  1. 查看构建日志,确认包装器被调用;
  2. 比较两轮的工作区、SDK、架构和编译参数;
  3. 检查生成头文件是否每次变化;
  4. 查看是否存在时间宏、随机值或绝对路径;
  5. 确认任务结束后缓存目录确实被保存。

若 uncacheable calls 很多,使用 ccache 调试日志只复现一个编译单元,避免为整次构建生成海量诊断文件。找到原因后关闭调试,防止日志本身占满磁盘。

设定清理与回退边界

缓存上限应按原生目标规模决定,而不是无限增长。定期执行 ccache --cleanup,并监控目录体积、淘汰数量与写入失败。多个并发任务可以共享同一目录,但底层目录必须位于可靠的本地文件系统;不稳定的远程挂载会把节省的编译时间消耗在元数据访问上。

遇到疑似错误复用时,先保留日志和缓存统计,再以 CCACHE_DISABLE=1 重跑同一命令。若问题消失,按工具链、目标和配置缩小缓存范围;不要把关闭校验、放宽时间戳判断或忽略头文件变化当成长期修复。

最终验收标准不是某个漂亮的命中率,而是三件事同时成立:产物与禁用缓存时一致,第二轮原生编译显著减少,缓存失效能够由工具链或输入变化解释。满足这三点后,ccache 才是一层可维护的构建基础设施。

常见问题

ccache 能直接加速 Swift 编译吗?

不能把它当作 Swift 编译缓存。ccache 主要包装 Clang,对 Objective-C、Objective-C++、C 和 C++ 编译单元有效;Swift 仍应依靠稳定的 DerivedData、合理并发和 Xcode 自身的增量机制。

为什么第二次构建仍然没有缓存命中?

先检查 Xcode 实际调用的编译器是否为包装器,再比较工作区路径、编译参数、生成头文件和宏定义。路径随机化、时间戳宏或每次变化的生成文件都会改变缓存键。

CI 应该保存整个 DerivedData 还是只保存 ccache 目录?

优先把 ccache 目录作为独立缓存保存。DerivedData 与 Xcode 版本、工程状态和绝对路径耦合更深,恢复前需要更严格的键和失效策略。

为下一次构建准备独享物理 Mac

从三档固定配置中选择适合开发、持续集成或本地模型测试的云端 Mac,并按任务周期完成配置。

配置云端 Mac