ccache で Unity の生成する Xcode プロジェクトのビルドを高速化する

12 min read読了の目安(約11000字 2

ccacheを導入してUnityのiOSビルドを高速化出来ないか検証してみた を参考にさらに調査を進めたところ、Unityプロジェクトを再生成した場合でもうまくキャッシュヒットさせることができたため知見を共有します。

TL;DR

  • 🎊 ccache を利用することで Unity の生成する Xcode プロジェクトのビルドを大幅に高速化することができます。
    • サンプルプロジェクトではビルド時間が 50% になりました。
  • 🎊 Xcode プロジェクトを再生成してもキャッシュヒットするため、CI上でのビルドも高速化することができます。
  • 🌀 いくつかの未検証項目があります。
    • 特にUnity側のソースコードに変化を加えた場合のキャッシュヒット率については未調査です。

導入の手順

生成された Xcode プロジェクトの Build Settings の内容によって必要な手順が異なります。
具体的には UnityFramework ターゲットの Build Setings の Enable Modules (C and Objective-C)Yes なのか No なのかで異なります。

Enable Modules の設定が No の場合 (通常はこちら)

① ccache をインストールする

homebrew から ccache をインストールします。(執筆時点では v4.2.1 が最新)

$ brew install ccache
...
$ ccache --version
ccache version 4.2.1

ccache_wrapper を用意する

ccache_wrapper の作成

Unity プロジェクト(ないしUnityプロジェクトを含むGitリポジトリ) に ccache の呼び出しをラッパーするシェルスクリプト ccache_wrapper を追加します。

#!/bin/bash
if type "ccache" > /dev/null 2>&1; then
    export CCACHE_SLOPPINESS=pch_defines,time_macros
    exec `which ccache` $DT_TOOLCHAIN_DIR/usr/bin/clang -Xclang -fno-pch-timestamp "$@"
else
    exec $DT_TOOLCHAIN_DIR/usr/bin/clang "$@"
fi

実行権限の付与

ccache_wrapper に実行権限を付与します。

$ chmod +x ccache_wrapper

何故 ccache_wrapper を用意するのか

ccache_wrapper には以下の二つの目的があります。

  1. ccache がインストールされていない環境では ccache を使わずに、通常通りコンパイルを行うにする。
  2. cache_wrapper 内で定義された環境変数を経由して ccache の設定を行う。

③ Xcode プロジェクトで ccache_wrapper を利用する設定を行う

Unity の iOS ビルドによって生成された Xcode プロジェクトに ccache_wrapper を利用する設定を行います。
ここではUnity エディタ拡張の OnPostprocessBuild フックで Xcode プロジェクトを編集する方法で説明します。

エディタ拡張を追加する

using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;
using UnityEngine;

public class EnableCcache : IPostprocessBuildWithReport
{
    // Assets ディレクトリから ccache_wrapper への相対パス (適宜書き換えてください)
    private const string PathToCcacheWrapper = "../iOS/ccache_wrapper";

    // OnPostprocessBuild の呼び出し順 (適宜書き換えてください)
    public int callbackOrder { get; } = 0;
    
    public void OnPostprocessBuild(BuildReport report)
    {
        if (report.summary.platformGroup != BuildTargetGroup.iOS) return;
        
        // ccache_wrapper を Xcode プロジェクトと同じディレクトリにコピー
        var srcPath = Path.Combine(Application.dataPath, PathToCcacheWrapper);
        var dstPath = Path.Combine(report.summary.outputPath, "ccache_wrapper");
        FileUtil.CopyFileOrDirectory(srcPath, dstPath);

        // ccache_wrapper を利用してコンパルを行う設定を追加
        var pbxProjectPath = PBXProject.GetPBXProjectPath(report.summary.outputPath);
        var pbxProject = new PBXProject();
        pbxProject.ReadFromFile(pbxProjectPath);

        var projectGuild = pbxProject.ProjectGuid();
        pbxProject.AddBuildProperty(projectGuild, "CC", "$(PROJECT_DIR)/ccache_wrapper");
        
        pbxProject.WriteToFile(pbxProjectPath);
    }
}

Cocoapods を利用している場合

External Dependency Manager を利用している場合など、 Cocoapods によって依存ライブラリをインストール&ビルドしているケースでは Pods.xcodeproj にも ccache の設定が必要です。
(未確認ですが Pods.xcodeproj に ccache の設定を行わなくても、Unity-iPhone.xcodeproj 側のビルドは ccache が有効になるはずです。)

Podfile への設定の追記

以下の設定を Podfile に追記します。

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['CC'] = '$(PROJECT_DIR)/../ccache_wrapper' 
    end
  end
end

Unity エディタ拡張から自動で追記する場合は、以下のようなC#スクリプトを追加します。

using System.IO;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;

public class IOSBuildPostprocess : IPostprocessBuildWithReport
{
    // OnPostprocessBuild の呼び出し順 (適宜書き換えてください)
    // External Dependency Manager (EDM)を利用している場合は、EDM によるPodfile生成が 40 で pod install が 50 なのでその間の値にする必要があります。
    public int callbackOrder { get; } = 45;
    
    public void OnPostprocessBuild(BuildReport report)
    {
        if (report.summary.platformGroup != BuildTargetGroup.iOS) return;

        var podfilePath = Path.Combine(outputRootPath, "Podfile");
	var podInstallHook = @"
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['CC'] = '$(PROJECT_DIR)/../ccache_wrapper' 
    end
  end
end
";

        File.AppendAllText(podfilePath, podInstallHook);
    }
}

Enable Modules の設定が Yes の場合

① パッチ版の ccache をインストールする

Enable Modules の設定が Yes の場合、コンパイル時の clang への引数に -ivfsoverlay が追加されます。 通常の ccache は現在この引数に対応しておらずキャッシュが行われません。(ccache には multiple source files と認識される。この場合はコンパイル結果がキャッシュ対象にならない。)

この問題を解決したパッチバージョンが Homebrew tap に存在する(PSPDFKit-labs/homebrew-tap)のでそちらを使う必要があります。

Homebrew tap でパッチバージョンをインストールする

$ brew tap pspdfkit-labs/tap
...
$ brew install ccache
...
$ ccache --version
ccache version v4.1.pspdfkit

ccache_wrapper を用意する

Enable Modules の設定が Yes の場合はいくつかの ccache 設定を追加する必要があるため、ccache_wrapper の内容が異なります。

ccache_wrapper の作成

Unity プロジェクト(ないしUnityプロジェクトを含むGitリポジトリ) に ccache の呼び出しをラッパーするシェルスクリプト ccache_wrapper を追加します。

#!/bin/bash
if type "ccache" > /dev/null 2>&1; then
    export CCACHE_SLOPPINESS=pch_defines,time_macros,modules,ivfsoverlay
    export CCACHE_DEPEND=1
    exec `which ccache` $DT_TOOLCHAIN_DIR/usr/bin/clang -Xclang -fno-pch-timestamp "$@"
else
    exec $DT_TOOLCHAIN_DIR/usr/bin/clang "$@"
fi

実行権限の付与

ccache_wrapper に実行権限を付与します。

$ chmod +x ccache_wrapper

③ Xcode プロジェクトで ccache_wrapper を利用する設定を行う

Enable Modules の設定が No の場合」と同様です。そちらを参照してください。

サンプルプロジェクトでの検証結果

ccacheを導入してUnityのiOSビルドを高速化出来ないか検証してみた でも利用されている mao-test-h/DOTS-Jungle をお借りしました 🙏

検証手順

  1. Unity で iOS ビルドを行う
  2. 生成された Xcode プロジェクトを以下のコマンドでビルドして、ビルド時間を計測。
    • time xcodebuild -derivedDataPath DerivedData -project Unity-iPhone.xcodeproj -scheme Unity-iPhone
  3. Xcode プロジェクトを消してもう一度 Unity のiOS からやりなおす。
    • この時 Xcode プロジェクトの生成先は同じパスにする。

検証結果

  • ビルド時間
    • 1回目 231.25 secs → 2回目 121.27 secs
  • キャッシュヒット率
    • 399/400 = 99.75 %

1回目と2回目で、ビルド時間が 50% に短縮されました 🎊
短縮されていない時間はコンパイル以外の時間だと思われます。キャッシュヒット率が非常に高いので、規模が大きいプロジェクトほど効果が高くなるはずです。

検証環境

  • Software version
    • macOS Catalina 10.15.7
    • Xcode 12.4
    • Unity 2019.4.15f1
  • Machine spec
    • 2.4 GHz 8-Core Intel Core i9
    • 32 GB 2667 MHz DDR4

ccache_wrapper の設定項目の解説

ccache のオプション

CCACHE_SLOPPINESS

pch_defines time_macros

  • この二つのオプションはプリコンパイル済みヘッダを利用している場合に必要なオプションです。
    • Xcode では Precompile Prefix Header の設定が Yes になっている場合に必要です。
  • Unity が生成する Xcode プロジェクトではデフォルトでプリコンパイル済みヘッダを利用する設定になっているため基本的に必須のオプションです。
  • ccache のマニュアルの Precomipled Headers に詳しく書いてあります。

modules

  • C,C++ or Objective-C で Module機能(@importディレクティブ)を利用している場合に必要なオプションです。
  • Unity が生成する Xcode プロジェクトではデフォルトでModule機能は無効です。プロジェクト固有で有効にしている場合に設定する必要があります。
  • ccache のマニュアルの C++ modules に詳しく書いてあります。

CCACHE_DEPEND

  • modules の sloppiness を有効にするには、このオプションも有効にしておく必要があります。
  • このオプションを有効にすると ccache が利用するキャッシュキーの生成ロジックが Depend mode に変更されます。
    • ccache のデフォルトでは Preprocessor mode というロジックを利用しています。これは #include 等のプリプロセッサを解釈したあとのソースファイルの内容を用いてハッシュを生成するロジックです。
    • Depend mode ではコンパイル対象のソースコードが include しているファイルを抽出し、「include されているファイルのキャッシュキー」を元にキャッシュキーを生成します。
  • ccache のマニュアルの The depend mode に詳しく書いてあります。

CCCAHE_BASEDIR

  • このオプションは ccache が認識するファイルパスを相対パスに書き換えるためのオプションです。
    • ccache のデフォルトではビルド時のファイルパスを絶対パスで認識するため、 同一の ソースファイルを異なるディレクトリでビルドするとキャッシュヒットしない 状態になりますが、このオプションを指定して相対パスを一致させるとキャッシュヒットします。
  • ただし、以下の理由で Unityの 生成した Xcode プロジェクトのビルドでは CCACHE_BASE_DIR の設定にほとんど意味がないため、この記事で紹介している ccache_wrapper では設定していません。
    • ccache は、あるソースコードのコンパイル結果がキャッシュヒットするかどうかの判定の一項目に「インクルードしているpchファイルの内容に変化がないか」を利用している。
    • clang のデフォルトの仕様として pch ファイルに「絶対パス でインクルードしているソースコードのパス」が含まれているため、CCCAHE_BASEDIR を設定したとしても pch のコンパイル結果が変わってしまいキャッシュミスしてしまう。
  • ccache のマニュアルの base_dir に詳しく書いてあります。

clang のコンパイルオプション

ccache_wrapper では -Xclang -fno-pch-timestamp のコンパイルオプションを追加しています。このオプションをつけると「pchファイルにincludeしたファイルのmtimeが含まれない」ようになります。
このオプションは以下の理由で必要です。

  • ccache は、あるソースコードのコンパイル結果がキャッシュヒットするかどうかの判定の一項目に「インクルードしているpchファイルの内容に変化がないか」を利用している。
  • しかし、 clang のデフォルトの仕様として pch ファイルには「pchがinclude しているヘッダファイルの mtime」が書き込まれるため、mtime が変わると pch ファイルの内容が変化してしまう。
  • Unity が生成する Xcode プロジェクトは pch が include する ヘッダファイルの mtime が毎回変わってしまう仕様。
    • Classes/Preprocessor.h の mtime が毎回変わる。こいつは Unity 側のビルド設定によって内容が変わるので、毎回テンプレートから生成しているためと思われる。
  • よって、 Unity で Xcode プロジェクトを生成し直してビルドするとほとんどキャッシュヒットしない という結果になる。

ccacheを導入してUnityのiOSビルドを高速化出来ないか検証してみた の記事で Xcodeプロジェクトを再生成するとキャッシュヒットしなくなっていたのは、このclang/ccacheの仕様が原因だったと思われます。

未検証の項目

  • Unity 側のソースコード(C#スクリプト) に変更があった場合のキャッシュヒット率への影響 は未検証です。
  • Module Enable = YES 場合のデバッグシンボルへの影響。キャッシュヒットした場合 dSYM の生成時になんか warning がたくさん出ている。
  • 元記事の方では LDPLUSPLUS の User-Defined Setting を Xcode プロジェクトに追加しているが、なぜ追加しているのかわかっていない🤔
    • コメント で mao さん (元記事の執筆者) から「cocos2d-xの設定を参考にしていた名残り」であり「Unity向けには不要」とのコメントをいただきました。ありがとうございます🙏

GitHub Actions でビルドを行う際のTips

キャッシュのサイズ上限を調整する & 圧縮設定を変更する

actions/cache@v2 はリポジトリあたり 5GB までしかキャッシュを持つことができないため、jobs.<job_id>.env に以下の環境変数の設定を行うとよいです。

  • CCACHE_MAX_SIZE を設定する。
    • ccache の利用するディスクサイズの最大値を設定することができます。
  • CCACHE_COMPRESSLEVEL を設定する。
    • zstandard の圧縮レベルを設定することができます。デフォルトが 1 ですが最大 20 まであげることができ、値が大きくなるほど圧縮率が高くなります (その分圧縮にかかる時間も増える)

ccache のバイナリをリポジトリに含める

GitHub ホストランナーを利用している場合、毎回 homebrew から ccache をダウンロード&ビルドするのも時間がかかります。actions/cache@v2 でインストールしたバイナリをキャッシュしてもよいですが、ccache のバイナリは大したサイズではないのでリポジトリに含めてしまうのも手です。

その場合は install_name_toolccache が依存している動的リンクライブラリの参照パスを書き換えた上で、依存している動的リンクライブラリも一緒にリポジトリに含める必要があります。

$ brew isntall ccache
...

$ cp /usr/local/bin/ccache .
...

$ otool -L ccache
/usr/local/bin/ccache:
        /usr/local/opt/zstd/lib/libzstd.1.dylib (compatibility version 1.0.0, current version 1.4.9)
        /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 902.1.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)

$ ls -al /usr/local/opt/zstd/lib/libzstd.1.dylib
lrwxr-xr-x  1 kohei.noda  admin  19 Mar  3 07:20 /usr/local/opt/zstd/lib/libzstd.1.dylib@ -> libzstd.1.4.9.dylib

$ cp /usr/local/opt/zstd/lib/libzstd.1.4.9.dylib .

$ install_name_tool -change /usr/local/opt/zstd/lib/libzstd.1.dylib @loader_path/libzstd.1.4.9.dylib ccache