用 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 編譯嗎?

不能。這套配置主要包裝 Clang,因此適用於 Objective-C、Objective-C++、C 與 C++ 編譯單元;Swift 仍要依賴穩定的 DerivedData、適當並行度與 Xcode 增量編譯。

為什麼第二次建置仍然沒有快取命中?

先確認 Xcode 確實呼叫包裝器,再比較工作目錄、編譯參數、產生的標頭檔與動態巨集。這些內容只要變動,就可能建立不同的快取鍵。

CI 應保存 DerivedData 還是只保存 ccache 目錄?

建議先把 ccache 目錄當作獨立快取保存。DerivedData 與 Xcode 版本、絕對路徑、SDK 及工程圖狀態的耦合更高。

準備下一次建置所需的獨享物理 Mac

從三種固定配置中選擇適合開發、持續整合或本地模型測試的雲端 Mac,並依照任務週期完成設定。

設定雲端 Mac