Unity 2022.2 におけるジョブシステムのパフォーマンススケーリングの改善 - パート 1:背景と API

2022.2 と 2021.3.14f1 では、Unity ジョブシステムのスケジュールコストとパフォーマンススケーリングが改善されました。この 2 パートに渡る記事では、並列プログラミングとジョブシステムについて簡単に振り返り、ジョブシステムのオーバーヘッドについて説明し、Unity によるオーバーヘッド軽減のアプローチを紹介します。
パート 1 では、並列プログラミングとジョブシステム API の背景情報について説明します。既に並列プログラミングについてご存知の場合は、こちらは読み飛ばしてパート 2 に進んでいただいても構いません。
Unity 2017.3 では、内部 C++ Unity ジョブシステム用のパブリック C# API が追加され、非同期で実行される「ジョブ」と呼ばれる小さな関数をユーザーが書くことが可能になりました。ただの関数ではなくジョブを使う意図は、簡単かつ安全で効率的に、コードをメインスレッドではなくジョブの「ワーカー」スレッドで、可能な限り並列して実行できるようにする API を提供することです。これにより、メインスレッドがゲームのシミュレーションを完了するのに必要なウォールタイムを短縮できます。CPU 処理にジョブシステムを使用することでパフォーマンスを大幅に改善することができ、ゲームが動作するハードウェアの性能が向上するにつれて、ゲームのパフォーマンスも自然にスケールさせることができます。
計算を有限のリソースとして捉えると、1 つの CPU コアが一定時間内にできる計算の量は限られています。例えば、シングルスレッドのゲームで、シミュレーションの Update() にかかる時間が 16 ミリ秒以下である必要がある場合に、現状では 24 ミリ秒かかっているとします。これでは CPU の負担が大きすぎるため、必要な時間も多くなります。16 ミリ秒という目標値を達成するための選択肢は 2 つしかありません。CPU の処理速度を上げる(あまり良い選択肢ではないが、ゲームの最低スペックを上げるなど)か、処理量を減らすかです。
void Update()
{
// <lots of simulation logic...>
}

最終的には、8 ミリ秒の計算処理をなくす必要があります。一般的に、これはアルゴリズムの改善、サブシステムの処理を複数のフレームに分散させること、開発中に蓄積される可能性のある冗長な処理を取り除くことなどを意味しています。それでもパフォーマンスターゲットに届かない場合は、コンテンツやゲームプレイを削ってゲームシミュレーションをより簡素にする必要があるかもしれません。例えば、一度にスポーンできる敵の数を減らすなどです。ただし、これは理想的な解決策とは言い難いでしょう。
では、処理そのものを減らすのではなく、その処理を別の CPU コアで実行できるようにするのはどうでしょう。現在、ほとんどの CPU はマルチコアです。つまり、利用可能なシングルスレッドの計算能力は、 CPU が持つコアの数によって倍増します。もし、現在 Update() 関数で行われているすべての処理を安全に 2 つのCPU コアに分割できる魔法のような方法があるとすれば、24 ミリ秒のUpdate() 処理を 2 つのチャンクに分割して 12 ミリ秒で同時に実行できるようになります。こうすれば、所要時間が目標の 16 ミリ秒を大きく下回ることになります。さらに、処理を 4 つの並列チャンクに分割して 4 つのコアで実行できれば、Update() にかかる時間はわずか 6 ミリ秒になります。
このように処理を分割し、利用可能なすべてのコアで実行することは、パフォーマンススケーリングとして知られています。コアをさらに増やせば、理想としてはより多くの処理を並列して実行でき、コードを変更することなく Update() のウォールタイムを短縮できます。
void Update()
{
// Some magic has split our logic into 4 equal parts
// that can run in parallel. Wowee!
PartialUpdateA();
PartialUpdateB();
PartialUpdateC();
PartialUpdateD();
}

残念ながら、これは幻想に過ぎません。なんの手助けもなしに、Update() 関数を分割し、別々のコアで実行することはできません。コアが 128 個ある CPU に変えたとしても、両方の CPU のクロックレートが同じであれば、上記の 24 ミリ秒の Update() を実行するには、やはり 24 ミリ秒かかります。なんという可能性の無駄遣いでしょう。では、利用可能な CPU コアをすべて活用し、並列性を高めるアプリケーションを書くにはどうすればいいのでしょうか。
考えられるアプローチの 1 つに、マルチスレッドがあります。つまり、プログラムでスレッドを作成し、オペレーティングシステムがスケジュールした関数を実行するのです。CPU にコアが複数個あれば、複数のスレッドをそれぞれのコアで同時に実行できます。利用可能なコアよりもスレッドの数が多い場合、オペレーティングシステムは、どのスレッドがどのコアで、どれくらいの時間実行されてから別のスレッドに切り替えられるかを決定します。このプロセスはコンテキストスイッチと呼ばれています。
しかし、マルチスレッドプログラミングには問題もつきものです。上記の魔法のようなシナリオでは、Update() 関数は、均等に 4 つの部分更新に分割されていました。しかし実際は、このように簡単にできることはまずないでしょう。スレッドは同時に実行されるため、同じデータを同時に読み書きする場合、お互いの計算が破壊されないように注意が必要です。
そのために、通常はミューテックスやセマフォといったロック同期プリミティブを使用して、スレッド間の共有状態へのアクセスを制御します。大抵これらのプリミティブは、特定のコードセクションがどれだけの並列性を持てるかを制限するために(通常は、まったく並列性を持たないことを選択する)、他のスレッドを 「ロック」します。そうすることで、ロックホルダーが処理を終了して待機しているスレッドに対してセクションの「ロックを解除」するまで、そのスレッドがセクションを実行できなくなります。こうするとスレッドが常に並列して実行されているわけではなくなるため、複数のスレッドを使用することで得られるパフォーマンスは低下しますが、プログラムが正しく保たれることは保証できます。
また、データの依存関係があるため、更新の一部を並列して実行するのは意味がないでしょう。例えば、ほとんどすべてのゲームは、コントローラーから入力を読み取り、その入力を入力バッファに格納し、入力バッファを読み取ってから値に基づいて反応する必要があります。
void PartialUpdateA()
{
// Write to m_InputBuffer with the controller state
ReadControllerState(out m_InputBuffer);
}
void PartialUpdateB()
{
// Read m_InputBuffer and start a player
// jump animation if the jump button was pressed
if(m_InputBuffer.IsJumpPressed())
PlayerJump();
}
キャラクターがジャンプすべきかどうかを決定するために入力バッファを読むコードが、そのフレームの更新のために入力バッファに書き込むコードと同時に実行されては意味がありません。m_InputBuffer への読み書きが安全であることを確認するためにミューテックスを使ったとしても、ジャンプボタンが過去のフレームではなく今のフレームで押されたことを確認できるよう、常にまず m_InputBuffer が書き込まれてから m_InputBuffer の読み込みコードが実行される必要があります。このようなデータ依存は一般的で普通のことですが、実現できる並列性の量を減らすことになります。
マルチスレッドプログラムを書くには、様々なアプローチがあります。プラットフォーム固有の API を使用して直接スレッドの作成と管理を行うこともできますし、マルチスレッドプログラミングの複雑な部分を管理するのに役立つさまざまな API を使用して、抽象化を行うこともできます。
ジョブシステムは、そのような抽象化のひとつです。シングルスレッドコードの一部を論理ブロックに分割し、該当部分のコードが必要とするデータを分離し、誰がそのデータに同時にアクセスできるかを制御し、できるだけ多くのコードブロックを並列して実行することで、必要に応じて CPU で利用可能なすべての計算能力を利用しようとする手段を提供します。
現在、任意の関数を自動的に分割することはできないため、Unity はユーザーが関数を小さな論理ブロックに変換するためのジョブ API を提供しています。その後、ジョブシステムがこれらのブロックを並列して実行させます。
ジョブシステムは、核となるいくつかの要素で構成されています。
- ジョブ
- ジョブハンドル
- ジョブスケジューラ
public struct MyJob : IJob
{
public NativeArray<int> Data;
public void Execute()
{
// Do some work using our Data member
}
}
前述のように、ジョブは関数といくつかのデータに過ぎません。しかし、このカプセル化は、ジョブがどの特定のデータから読み込んだり、どのデータに書き込んだりするかの範囲を狭めることができるので便利です。
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();
ジョブインスタンスが作成されると、ジョブシステムでスケジュールされる必要があります。これは、C# の拡張メカニズムを介してすべてのジョブタイプに追加された、.Schedule() メソッドで行われます。スケジュールされたジョブの識別や追跡を行うために、ジョブハンドルが提供されます。
ジョブハンドルはスケジュールされたジョブの識別に使われるので、ジョブ同士の依存関係を設定するのにも使えます。ジョブの依存関係を設定すると、スケジュールされたジョブがその依存関係が完了するまで確実に実行されないようになります。直接的な結果として、有向非巡回ジョブグラフが作成され、異なるジョブの並列実行が許可されるタイミングを知ることも可能になります。
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();
// WritingJob writes to someNativeArray so make sure it runs
// after MyJob is done (since it uses someNativeArray as well).
// That is, declare writingJob to have a dependency on myJob by
// passing in the JobHandle for MyJob to writingJob.Schedule
var writingJob = new WritingJob() { Data = someNativeArray };
var writingJobHandle = writingJob.Schedule(jobHandle);
最後に、ジョブがスケジュールされると、ジョブスケジューラはスケジュールされたジョブを追跡し(スケジュールされたジョブインスタンスにジョブハンドルがマッピングされる)、ジョブの実行が可能な限り早く開始されるようにします。ジョブシステムの設計と使用パターンが明白でない方法で競合し、マルチスレッドプログラミングのパフォーマンス向上を食い潰すオーバーヘッドコストにつながる可能性があるため、これが行われる方法は非常に重要です。ユーザーが C# Job System を採用し始めるにつれ、ジョブシステムのオーバーヘッドが予想よりも高くなるシナリオを目にするようになったことが、 2022.2 Tech Stream における Unity の内部ジョブシステム実装の改善につながりました。
パート 2 では、C# Job System のオーバーヘッドがどこから来るのか、そして Unity 2022.2 においてそれがどのように削減されたのかを探ります。
ご質問がある場合、またはさらなる詳細を知りたい場合は、C# Job System フォーラムをご覧ください。Unity Discord から、私(ユーザー名:@Antifreeze#2763)に直接ご連絡いただくことも可能です。現在連載中の Tech from the Trenches シリーズの他の Unity 開発者による新しい技術ブログもぜひご覧ください。