エンジニアリング記事

クラウドMac CIでMetalシェーダーをオフライン検証する

クラウドMac CIでMetalシェーダーをオフライン検証する

開発機ではグラフィックス機能が正常に動作するのに、CIのアーカイブ処理が終わる直前になってMetalのコンパイルエラーが発生する場合、原因は「マシン性能の不足」ではなく、シェーダーが独立したビルド入力として管理されていないことにある場合がほとんどです。より確実な方法は、クラウドMac上で現在のXcodeツールチェーンを使い、実機用とシミュレータ用のmetallibをオフラインで生成してから、プロジェクト全体のビルドに進むことです。これにより、構文エラー、SDKの選択ミス、関数の欠落を早い段階で検出でき、失敗時のログも短くなります。

シェーダーを独立したゲートに分ける理由

Xcodeはターゲットに追加された.metalファイルを自動的に処理しますが、エラーは依存関係の解決、リソースのコピー、署名処理まで含む長大なログに埋もれがちです。独立したゲートはXcodeビルドの代わりではありません。その前段階で、次の3点を確認するためのものです。

  1. 現在のツールチェーンですべてのシェーダーをコンパイルできるか。
  2. 実機ターゲット用とシミュレータターゲット用のライブラリを個別に生成できるか。
  3. アプリが実際に参照する関数名をライブラリから読み込めるか。

開発機で生成したmetallibを、長期保存する成果物ディレクトリへ直接コピーしないでください。ツールチェーン、SDK、コンパイルオプションが変わった後も古いファイルが残り、現在のソースコードを反映していない可能性があります。

リポジトリには.metalのソースコードとコンパイルスクリプトだけをコミットし、.air.metallibは削除可能なビルドディレクトリに出力することを推奨します。チームでキャッシュを利用する場合、キャッシュキーには少なくともソースのダイジェスト、Xcodeのパス、SDKのバージョン、ターゲットプラットフォームを含めます。

今回のビルドで使うツールチェーンを固定する

クラウドMacには複数のXcodeがインストールされていることがあります。スクリプトでは、GUIで最後に選択されたバージョンに依存せず、DEVELOPER_DIRを明示的に確認する必要があります。パスはノードに実際にインストールされている環境に合わせて指定し、未確認の値を複数のジョブで使い回さないでください。

set -euo pipefail

: "${DEVELOPER_DIR:?DEVELOPER_DIR is required}"

xcodebuild -version
xcrun --sdk iphoneos --show-sdk-version
xcrun --sdk iphonesimulator --show-sdk-version
xcrun --sdk iphoneos --find metal
xcrun --sdk iphoneos metal --version

mkdir -p build/metal/metadata
{
  echo "developer_dir=$DEVELOPER_DIR"
  xcodebuild -version
  echo "iphoneos=$(xcrun --sdk iphoneos --show-sdk-version)"
  echo "iphonesimulator=$(xcrun --sdk iphonesimulator --show-sdk-version)"
  xcrun --sdk iphoneos metal --version
} > build/metal/metadata/toolchain.txt

toolchain.txtは失敗ログと一緒にアーカイブします。これにより、「ソースコードのリグレッション」と「実行ノードで使用するXcodeが切り替わった」という2種類の問題をすばやく切り分けられます。実際に使用されるSDKも結果に影響するため、xcodebuild -versionだけを記録するのでは不十分です。

実機用とシミュレータ用の成果物を個別にコンパイルする

まず各ソースファイルを個別の.airにコンパイルし、その後でライブラリにまとめます。ファイル単位でコンパイルすると、単に一括したリンクエラーが出るのではなく、問題のあるファイルを直接特定できます。

set -euo pipefail

sources=(Shaders/*.metal)
if [ ! -e "${sources[0]}" ]; then
  echo "No Metal source files found" >&2
  exit 1
fi

for sdk in iphoneos iphonesimulator; do
  out="build/metal/$sdk"
  rm -rf "$out"
  mkdir -p "$out/air"

  for src in "${sources[@]}"; do
    name="$(basename "$src" .metal)"
    xcrun --sdk "$sdk" metal -c "$src" \
      -o "$out/air/$name.air"
  done

  xcrun --sdk "$sdk" metallib "$out"/air/*.air \
    -o "$out/AppShaders.metallib"

  test -s "$out/AppShaders.metallib"
  shasum -a 256 "$out/AppShaders.metallib" \
    > "$out/AppShaders.metallib.sha256"
done

2種類の成果物は必ず別々のディレクトリに保存し、後から実行されたジョブが先の成果物を上書きしないようにします。ソースコードでC/C++プリプロセッサマクロを使ってプラットフォームを分岐している場合は、マクロのオプションをスクリプトに集約し、Xcode Build Phaseでも同じ定義を使用してください。

確認対象 実機ターゲット シミュレータターゲット
SDK iphoneos iphonesimulator
中間ファイル 個別の.air 個別の.air
出力ライブラリ 個別にアーカイブ 個別にアーカイブ
最終検証 実機または正式なアーカイブ シミュレータのスモークテスト

ロードテストで関数の契約を検証する

ファイルが空でないことから分かるのは、コンパイラが出力を生成したという事実だけです。アプリが参照する関数が引き続き存在することまでは保証できません。kernel関数やfragment関数の名前を変更した際、Swiftコード内の文字列が更新されていない場合があります。テストターゲットでライブラリを読み込み、必要なエントリーポイントを照会してください。

import Metal
import XCTest

final class ShaderLibraryTests: XCTestCase {
    func testRequiredFunctionsExist() throws {
        let device = try XCTUnwrap(MTLCreateSystemDefaultDevice())
        let url = try XCTUnwrap(
            Bundle(for: Self.self).url(
                forResource: "AppShaders",
                withExtension: "metallib"
            )
        )
        let library = try device.makeLibrary(URL: url)

        for name in ["imageVertex", "imageFragment", "toneMapKernel"] {
            XCTAssertNotNil(
                library.makeFunction(name: name),
                "Missing Metal function: \(name)"
            )
        }
    }
}

関数リストには、アプリが実際にパイプラインを作成するときに使用する名前を指定します。また、テストバンドルにコピーされたものがiphonesimulator用の成果物であり、ワークスペースに偶然残っていた実機用ファイルではないことも確認する必要があります。最低限のスモークテストでは、MTLDeviceの作成、ライブラリの読み込み、関数の照会を行います。テクスチャ形式やスレッドグループの制約が関係する場合は、パイプライン作成テストも追加してください。

失敗時に残すべき証拠

MetalのCI障害を調査する際は、作業ディレクトリ全体よりも、比較可能な少量の情報を残すほうが有用です。

  • DEVELOPER_DIR、Xcode、および2種類のSDKのバージョン。
  • 実際に実行したコンパイルコマンドと標準エラー出力。
  • .metalファイルのソースダイジェスト。
  • 2種類のmetallibのサイズとSHA-256。
  • 読み込みに失敗した関数名、テストターゲット、実行先。
  • 今回のジョブで使用したコミット識別子。

ソースダイジェストはfind Shaders -name '*.metal' -print0 | sort -z | xargs -0 shasum -a 256で生成できます。ツールチェーンのアップグレードでもバイナリが変化する可能性があるため、成果物のハッシュが変わっただけで失敗と判定してはいけません。まずツールチェーンのフィンガープリントを比較し、その後、再コンパイル、ライブラリの読み込み、パイプライン作成の結果をゲートとして使用するのが適切です。

この種のジョブをMacMLabの専有物理ノードで実行する場合でも、同じワークスペースが連続するジョブによって汚染される可能性があります。ビルドのたびに対象の出力ディレクトリを空にし、ジョブ終了後はメタデータ、ログ、最終ライブラリだけをアーカイブしてください。これにより、古い.airが新しいライブラリに混入するのを防ぎ、次回の失敗を再現可能な状態から調査できます。

よくある質問

XcodeがMetalファイルを処理するのに、事前コンパイルは必要ですか?

完全なアーカイブより前に構文、SDK、ターゲットの問題を検出でき、ログも短くなります。Xcode標準のコンパイル工程は残します。

実機とシミュレータで同じmetallibを使用できますか?

共用を前提にせず、iphoneosとiphonesimulatorの各SDKで別々に生成し、それぞれの実行先で読み込みを確認します。

成果物のハッシュが変わったら失敗と判断すべきですか?

いいえ。まずXcodeのパス、SDK、コンパイラのバージョンを比較し、読み込みテストと描画テストで有効性を判断します。

専有物理ノード

開発とビルドのタスクを専有クラウドMacで実行

タスクに応じて、モデル、ノードのリージョン、レンタル期間を選択できます。各インスタンスは専有物理マシンで、仮想マシン環境ではありません。

クラウドMacのプランを選ぶ