ccacheでクラウドMacのネイティブビルドを高速化

ccacheでクラウドMacのネイティブビルドを高速化

iOSプロジェクトはSwiftコードが少量でも、Objective-C、C、C++のコンパイル単位を数十個抱えていることがあります。クラウドMacのジョブが毎回新しい作業ディレクトリで開始されると、こうしたネイティブ依存関係に対してClangが再実行され、インクリメンタルビルドの利点が失われます。ccacheは、このような重複作業の削減に適しています。プリプロセス結果、コンパイルオプション、コンパイラの識別情報からキーを生成し、キャッシュにヒットすればオブジェクトファイルをそのまま再利用します。

ただし、ccacheはXcode全体を高速化する汎用スイッチではありません。導入前に、時間がかかっているのが本当にClangの処理であり、Swiftフロントエンド、リンク、リソースのコンパイル、テストではないことを確認する必要があります。

キャッシュ対象を先に特定する

完全なビルドログを保存し、CompileC、CompileC normal、および.m、.mm、.c、.cc、.cppファイルを検索します。これらの処理が明確な割合を占めている場合に限り、ccacheを導入する価値があります。純粋なSwiftプロジェクトでは、正しく設定しても効果がほぼゼロになる可能性があります。

まず、次の3つをベースラインとして記録します。

  • クリーン後のフルビルド時間。
  • コードを変更せずに再ビルドしたときの時間。
  • ネイティブコンパイル単位の数と、最も時間がかかるターゲット。

最初からDerivedData全体をキャッシュしないでください。DerivedDataにはインデックス、モジュールキャッシュ、ビルドデータベース、中間生成物が含まれ、Xcodeのバージョン、SDK、絶対パス、プロジェクトグラフに強く依存します。ccacheのディレクトリは、独立して観測できるキャッシュ単位として扱う方が適しています。

まず、ジョブがClangを繰り返し実行していることを確認してから、ヒット率を検討します。そうしないと、リンクの低速化、Swiftの型チェック、スクリプトフェーズの実行時間を、キャッシュ無効化の問題だと誤認しやすくなります。

ラッパー経由でClangを実行する

ビルド環境にccacheをインストールしても、システムのコンパイラファイルを置き換えてはいけません。代わりに、プロジェクトのワークスペース内に軽量なラッパーを2つ作成し、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

コンパイルのたびに値が変わる時刻マクロをソースコードで使用することも避けます。ビジネス要件としてビルド時刻が必要な場合は、別ファイルに分離し、影響を受けるコンパイル範囲を最小限に抑えてください。

2回のビルドでキャッシュヒットを検証する

初回導入時は、分離した2回の受け入れテストを実施します。1回目は統計をリセットし、空のキャッシュを保持した状態でフルビルドを完了します。2回目はDerivedDataを削除し、ccacheは残したまま、まったく同じツールチェーンとオプションで再ビルドします。2回目に直接ヒットが発生して初めて、再利用が以前のビルドディレクトリに依存していないことを確認できます。

cacheable calls、hits、misses、uncacheable callsを確認し、全体のヒット率だけを見ないでください。初回ビルドは、もともとmissが大半になるのが正常です。2回目もすべてmissになる場合は、次の順序で調査します。

  1. ビルドログを確認し、ラッパーが呼び出されていることを確かめる。
  2. 2回のワークスペース、SDK、アーキテクチャ、コンパイルオプションを比較する。
  3. 生成ヘッダーファイルが毎回変化していないか確認する。
  4. 時刻マクロ、ランダム値、絶対パスが含まれていないか確認する。
  5. ジョブ終了後にキャッシュディレクトリが実際に保存されていることを確認する。

uncacheable callsが多い場合は、ccacheのデバッグログを有効にしてコンパイル単位を1つだけ再現し、ビルド全体に対する大量の診断ファイル生成を避けます。原因を特定したらデバッグを無効にし、ログ自体がディスクを圧迫しないようにしてください。

クリーンアップとロールバックの境界を定める

キャッシュ上限は無制限に増やすのではなく、ネイティブターゲットの規模に応じて決めます。ccache --cleanupを定期的に実行し、ディレクトリ容量、追い出されたエントリ数、書き込み失敗を監視してください。複数の並行ジョブで同じディレクトリを共有できますが、そのディレクトリは信頼できるローカルファイルシステム上に配置する必要があります。不安定なリモートマウントでは、短縮したコンパイル時間がメタデータアクセスの待ち時間で相殺されてしまいます。

誤ったキャッシュ再利用が疑われる場合は、まずログとキャッシュ統計を保存し、CCACHE_DISABLE=1を指定して同じコマンドを再実行します。問題が解消する場合は、ツールチェーン、ターゲット、構成ごとにキャッシュ範囲を狭めてください。検証を無効化したり、タイムスタンプ判定を緩めたり、ヘッダーファイルの変更を無視したりする方法を長期的な修正として採用してはいけません。

最終的な合格基準は、見栄えのよいヒット率ではなく、次の3点が同時に成立することです。生成物がキャッシュ無効時と一致していること、2回目のネイティブコンパイルが大幅に減っていること、キャッシュ無効化をツールチェーンまたは入力の変化によって説明できること。この3点を満たして初めて、ccacheは保守可能なビルド基盤になります。

よくある質問

ccacheはSwiftのコンパイルも直接高速化できますか?

できません。この構成ではClangを経由するObjective-C、Objective-C++、C、C++が対象です。Swiftには安定したDerivedDataと適切な並列数、Xcodeの増分ビルドを使います。

2回目のビルドでもキャッシュがヒットしないのはなぜですか?

まずXcodeがラッパーを実際に呼んでいるか確認します。その後、作業パス、コンパイル引数、生成ヘッダー、毎回変化するマクロを比較してください。

CIではDerivedDataとccacheのどちらを保存すべきですか?

最初はccacheディレクトリを独立して保存する方法が堅実です。DerivedDataはXcodeの版、絶対パス、SDK、プロジェクト状態への依存が強いためです。

次回のビルドに向けて専用の物理Macを準備

開発、継続的インテグレーション、ローカルモデルのテストに適した3種類の固定構成からクラウドMacを選び、タスク期間に合わせてセットアップできます。

クラウドMacを構成する