はじめに
こんにちは、ナカムロ.です。 メリークリスマスです。
こちらは「Applibot Advent Calendar 2022」 最終日の記事です。荷が重いです。
これまでゲームのパフォーマンスチューニングを各プロジェクトに入って実施してきましたが、Unity Performance Tuning Bibleをリリースしたことで、今まで手が回っていなかったゲーム外の部分のチューニングにも取り組む機会が出来ました。今回は大規模Unityプロジェクトのアセットビルドを高速化した話をエッセイ調にまとめたいと思います。
※特定のプロジェクトについて語っている記事ではありませんのでご注意ください。
概要
「大規模Unityプロジェクト」の定義は難しいですが、本記事で述べているプロジェクトは以下を前提とします。
- プロジェクト人数が100人以上
- Unityプロジェクトの総サイズがLibraryディレクトリ内および.gitディレクトリを含めて200GB以上
- 総アセットバンドルサイズ5GB以上
- 総アセットバンドル数20,000個以上
- アセットジャンルの内訳としては基本的な3Dゲームのプロジェクトと大きくは変わらず、極端にAudioやMovieが占めているなどではない
さらに、1アセットを1アセットバンドルにするものを依存度が低いと定義したとき、大多数のアセットバンドルに複数のファイルを同梱かつアセットバンドル同士の依存が多数存在している状態の 複雑なプロジェクト である前提とします。2022年現在、Unityを使用するプロジェクトとしては大規模と言って差し支えないでしょう。
Scriptable Build Pipeline(SBP)
Scriptable Build Pipeline(SBP)はUnityが提供しているパッケージで、デフォルトのUnityではブラックボックスだったビルドのパイプラインをカスタマイズすることができます。
今回のプロジェクトでは、使用しているツールの都合上、アセットバンドルを用いる必要がありました。加えて、Addressablesのようにアセットロードを行いたい意図がありました。さらに、導入時点で既に存在していた膨大な量のアセットに対してアセットバンドル名を事前に付与することなく(metaファイルの書き換えに伴う再インポートを回避したいため)、これらの名前解決をビルド時に実施する構造です。
導入当初は速度もそこまで問題はなく、大きな懸念はない状態で月日は流れました。
日々増えていくアセット数、長くなるアセットビルド時間
大規模プロジェクトではアセットデータは個数もファイルサイズも日々増加の一途を辿ります。ごく一般的に使われるようなCI環境をJenkinsで構築しており、アセットビルドとアプリビルドを1日1回のものや3時間に1回、SCMポーリングなど使用者や各用途に合わせて自動で回しています。しかし、徐々に前のビルドが終わる前に次のキューが溜まってしまうことも多くなってきました。
そうなると当然「アセットビルドに掛かる時間を速くできないか」という相談が出てきます。幸いにしてSBPが導入されていることもあり、何に時間が掛かっているか計測しやすい環境にありました。
時間の掛かっている場所の調査
アセットバンドルをビルドするためにJenkins上で実施されるプロセスは大まかに次のようになります。
- Gitチェックアウト
- Unity起動
- インポート
- アセットバンドルビルド
- アセットバンドルアップロード
大きく分けて「インポート」と「アセットバンドルビルド」のフェーズが時間が掛かっていることが分かりました。「インポート」では、フルインポートすると 24〜48時間以上 、「アセットバンドルビルド」では 24時間以上 掛かることがありました。差分ビルドで行っているため、2回目以降インポートは差分が少ない場合は数分で終わることも多いですが、アセットバンドルビルドは毎回早いときでも1〜2時間以上は掛かっていました。
インポートが遅い問題
インポートでは、アセットビルドマシンのみならず、各開発者の環境でも実施されます。Unity Acceleratorを利用してインポート済みのアセットをダウンロードできる仕組みが用意されているため、積極的に利用すると良いでしょう。
しかし、今回のプロジェクトでは既に導入されていました。
さらに調査を進めた結果、プロジェクト独自で書かれていた一部のインポーター(AssetPostprocessor
)が以下のような状態になっていることが分かりました。
public class HogeModelAssetPostProcessor : AssetPostprocessor
{
private void OnPostprocessModel(GameObject gameObject)
{
using var so = new SerializedObject(assetImporter);
// 中略
// so.FindPropertyなどで拾ってSerializedPropertyを書き換えたり...
so.ApplyModifiedProperties();
assetImporter.SaveAndReimport();
}
}
特にアセットに変更がなく、SaveAndReimport
する必要がない場合でも実施されているものがありました。こちらについては、以下のように修正するだけで高速化が出来ました。
public class HogeModelAssetPostProcessor : AssetPostprocessor
{
private void OnPostprocessModel(GameObject gameObject)
{
using var so = new SerializedObject(assetImporter);
// 中略
// so.FindPropertyなどで拾ってSerializedPropertyを書き換えたり...
// 変更があった場合のみSaveAndReimportを実施すれば良い
if (so.ApplyModifiedProperties())
{
assetImporter.SaveAndReimport();
}
}
}
SerializedObject.ApplyModifiedPropertiesやSerializedObject.ApplyModifiedPropertiesWithoutUndoは変更があったときにtrue
が返ります。不必要なSaveAndReimport
が呼ばれていないかチェックしてみてください。
アセットバンドルビルドが長い問題
アセットバンドルビルドについては、SBPの内部で実際にアセットバンドルを生成している処理に時間が掛かっていることが分かりました。
// つまり、この中の処理
var manifest = CompatibilityBuildPipeline.BuildAssetBundles(outputPath, assetBundleMap, assetBundleOptions, targetPlatform);
そこで、SBPをローカルパッケージ化しカスタマイズしてログを埋め込み、アセットバンドル1つずつをビルドする時間を計測しました。その結果、時間経過とともに1つずつのアセットバンドル生成に掛かる時間(生成開始から生成終了、次のアセットに進行するまでの時間)が遅くなっていくことが分かりました。
また、Editor.logを見てみると次のようなログが大量に出力されていることに気が付きました。
Unloading 7 Unused Serialized files (Serialized files now loaded: 0)
System memory in use before: 3.17 GB.
System memory in use after: 3.18 GB.
Unloading 6 unused Assets to reduce memory usage. Loaded Objects now: 37184.
Total: 1261.957734 ms (FindLiveObjects: 4.538474 ms CreateObjectMapping: 4.231322 ms MarkObjects: 1253.099344 ms DeleteObjects: 0.087022 ms)
...
どこかで一度は見たことがあるものと思いますが、こちらはResources.UnloadUnusedAssets
を呼び出したときに出るログです。つまり、アセットバンドルビルド中に何度もUnloadUnusedAssets
が呼び出されていることになります。
ビルド後半に行くほど遅くなっていくことから、不必要に呼ばれてしまっている可能性を考えました。経緯は長くなるので省略しますが、最終的に次のようなメソッドに辿り着きました。
internal static extern void SetGarbageCollectionMemoryIncreaseThreshold(int mbThreshold);
閾値を超えたらGCを走らせてくれそうな仕組みになっていることが予想できます。しかし、ご覧のようにinternal
なのでリフレクションを使用して叩きます。
(1回しか叩かないのでこのメソッドの最適化は考慮していませんのであしからず...)
/// <summary>
/// SetGarbageCollectionMemoryIncreaseThresholdをリフレクションで呼び出す
/// </summary>
private static void SetGarbageCollectionMemoryIncreaseThreshold(int mbThreshold)
{
var type = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(t => t.Name == "ContentBuildInterface")
.Select(t => t).First();
var method = type.GetMethod("SetGarbageCollectionMemoryIncreaseThreshold",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
// 閾値を設定する
object[] args = {mbThreshold};
method?.Invoke(null, args);
Debug.Log($"SetGarbageCollectionMemoryIncreaseThreshold " + mbThreshold);
}
引数はデフォルトで2,048MB(2GB)だったため、ビルド実施前に8,192MB(8GB)を指定して叩いて再度ビルドしてみました。すると、20,000回近く呼ばれていたUnloadUnusedAssetsが数回までに減りました。ビルドに掛かる時間も今まで早くても 1〜2時間以上掛かっていたものが、 20〜30分 にまで早くなりかつビルド対象になるアセット数が増えても ビルド完了までの時間が大幅に伸びることがなくなり、長くても1時間以内には終わるように なりました。また、ビルド成果物としても特に問題がない状態であることが分かりました。
ただし、この方法で高速化できるケースは条件が限られますので、ご注意ください。
- SBPを利用していること
- ビルド中にEditorの使用メモリが2GBを超えてUnloadUnusedAssetsが大量に呼ばれていること
- ビルドマシンに空きメモリが潤沢に用意されていること
大規模プロジェクトで同じような現象を抱えている場合は大幅に改善できる可能性があります。
まとめ
大規模Unityプロジェクトではインポートやビルド時間を高速化することで、×人数分の作業可能時間やイテレーション回数を増やすことが出来ます。特に歴史が長いプロジェクトは、初期に組んだ仕組みのまま運用されていることも珍しくありません。これを機会に一度見直してみると思わぬ 「お宝」 が埋まっているかもしれません。
本記事が役立つような大規模Unityプロジェクトがこの世にどれくらいあるかは想像もつきませんが、ささやかなクリスマスプレゼントになることを祈り、この記事および「Applibot Advent Calendar 2022」 を締めたいと思います。ありがとうございました。