今回はUnity 2018.1の目玉機能であるC# Job Systemについて紹介します。
- C# Job Systemは並列処理である
- 実際に使ってみた
- まずはRaycastCommandでJob Systemの流れを把握する
- C# Job Systemで並列処理 : IJobParallelFor
- Transformにアクセスできる IJobParallelForTransform
- 一つのジョブを発行するIJob
- 感想
- 関連情報
C# Job Systemは並列処理である
C# Job System(C# Jobs)が何かを一言で表すならば、並列処理する機能です。
昨今のモバイルを始めとしたゲーム実行環境は、殆ど複数のコアを持つCPUを採用しています。このコアにいかにミッチリと仕事をさせるのが並列処理です。
例えば一つあたり6msかかる何らかの処理があった場合、一つのコアで処理を行うと処理が完了するまでに24msの時間がかかります。
この時、仕事が割り振られていないCPUはアイドル状態(暇)となります。
ここで仕事を複数コアに割り当てた場合、4人が分担して仕事を行えば全体としての時間は変わっていませんが、理想的には6msで仕事が完了します。
これは理想的な話であって、実際にはタスクの発生コストやジョブのスケジューリングで、タスクの割り振りが偏ったりコストが上昇したりしますが、大体こんな感じです。
最近のUnityは、多くのコンポーネントの処理を並列処理で作業していたので、かなりコアを使用して処理してきたのですが、やっとこさ自作するC# のスクリプトもコレの恩恵に預かれるようになりました。
並列処理と非同期処理(バックグラウンド処理)の異なる立ち位置
少し間違えやすいのが並列処理と非同期処理の立ち位置です。
非同期処理は、並列処理のように「コアを全部使って計算する」というよりは、「入力待ちを最小限にする」為の機能です。
例えば、何らかのとても長い時間のかかる処理(IOや通信等)があったとします。
それをCPU1で待機してしまうと、入力等のCPU1で処理すべき項目の反映に時間がかかるようになり、レスポンスが低下します。
非同期処理では、この何らかの処理を別スレッドにお願いします。
これによりCPU1は即座に応答が可能になります。
これは、Unity 2018.1から実験的が取れた.NET 4.x系に使われてるAsync・Await、AssetBundleの解凍やWebRequestの機能がそうです。
非同期処理も並列処理も、コアを使うという点で同じで、非同期処理も並列処理のように組む事は可能です。並列処理も、処理の完了待ちのタイミングを「明らかに完了した場所」に設定しておくことで、非同期処理っぽく出来ます。
特にbig.LITTLEのように、複数コアが偏った性能をしている事もありますし、メインスレッドに仕事が割り振られない事もあります。なので、並列処理ですが非同期的な待機も意識しておく必要がありそうです。
要素を並列して処理する
並列処理は、実行の順番や実行タイミングを把握せず一気に処理を行います。各スレッドに処理を詰められるだけ詰めるので、コアの性能を十分に発揮できる訳です。
逆を言うと、要素のロックやタイミングの同期、データの受け渡し等を残すと並列処理的にボトルネックになるので、避ける必要があります。
Unity のC# Jobsの場合はその設計上、ロックやタイミングの同期、データの受け渡し等は出来ません。その辺りが分かりやすい所でもあり、使いにくい所でもあります。
C# Job Systemの特徴
他の並列処理アプローチとの比較としてC# Job systemの特徴は、こんな感じみたいです。
- オーバーヘッドが非常に低い(主にIL2CPP想定)
- Burstコンパイラ(C# Job System専用コンパイラ)向けに最適化される
BurstコンパイラはGC無い前提で最適化 - 既に回ってるスレッドにジョブを発行するので、
コンテキストスィッチの頻度が減らせる(モバイルだとHyper Threading Technologyがほぼ無いので) - コンピュートシェーダーよりは作るのが楽(K氏曰く)
ただ、このC# Job Systemは値型しか使えない制約があります。
例えばintやfloatといったモノからstructのような構造体です。
この制約のお陰で、例えばRaycastが接触した相手の種類によって動作を変える…みたいな事をJobSystemでやろうとしたとき、ColliderやgameObjectが取得できないので、もっと他の事を考える必要があります。(将来的には全コンポーネントにIDが割り振られたり、コンポーネントの中身を注入されたりしそうですが、現状は特にこれといって)
元々、C# Job System自体が「メモリレイアウトを最適化した新しいコンポーネントシステム(ECS)」で動かすことを前提にしている節があるため、そちらの仕様にかなり引っ張られてる印象があります。
実際に使ってみた
色々と試してみましたが、全体を通してあまり変更点がない上のような挙動を行うプログラムを作りました。
大体800個くらいのCubeが下にRayを飛ばして、距離が一定以下なら上に跳ね返る…といった簡単なものです。流れは下の通り。
- 下にRaycastを飛ばす
- Raycastの距離に応じて、Cubeの加速度を調整
- Cubeの位置を加速度の情報を使用して移動させる
この処理を、下のように変更します
- 下にRayCastを飛ばす(並列処理で)
- Raycastの距離に応じて、Cubeの加速度を調整(並列処理で)
- Cubeの位置(Transform)を、加速度の情報を元に移動させる(並列処理で)
最初はメインスレッドで2msの処理時間をかけていましたが、最終的にはメインスレッドで0.9msちかくまで短くなりました。
まずはRaycastCommandでJob Systemの流れを把握する
早速C# Jobsを色々と試してみます。
といっても、最初からC# Jobsを作るのではなく、事前に用意されているRaycastCommandを使用して、使い方を何となく考えます。
まず紹介する事は、Native ArrayとJobsystemを使う流れです。
Jobsystemを使う流れ
JobSystemを使う流れを見てみます。
JobSystemは大体下の流れで作られています。
- NativeArrayで入力・出力のバッファを用意する
- 入力バッファの中身を埋める
- Sceduleを実行して、JobSystemにジョブを発行
- Completeで終了まで待つ
- 結果を出力バッファから受け取る
NativeArrayはアンマネージドのバッファをC#で使う
バッファはNativeArrayで用意します。
NativeArrayは、Unityが用意した配列用のAPIです。
これはC#の安全な配列と異なりアンマネージドコードのバッファをマネージドコードに公開しています。そのため幾つかのリスクと制約がありますが、高速っぽいです。
C#のArrayは配列と言いつつ連続したメモリ配置になっていなかったと思うので、NativeArrayで連続したアクセスを行っても高速で動作することを保証しようとしたのだと思われます。
特徴は下の4つです。
- Allocator(メモリの割当タイプ)を自分で決める必要がある。
Temp=割当と開放が速いらしい。要1フレームで開放
Persistent=割当と開放が遅いらしい・永続的な割当。要・Destroy等で開放 - 使い終わったら開放しないとメモリリークを起こす
(リークした場合、警告が出る) - 使えるのは構造体のみ(参照型は含められない)
- 要素を増やせない
入出力バッファは1フレームで破棄するようならAllocator.Temp等が使えますが、何だかんだ言って、事前に用意したほうが高速です。
RaycastCommand
準備が終わったので、Raycastを撃ちまくってる部分をRaycastCommandを使用した物に変更します。
C-sharp-Job-System-Sample/BounceCube1.cs at master · tsubaki/C-sharp-Job-System-Sample · GitHub
RaycastCommandは、RaycastをJob System上で実行する機能です。
開始点と向きを入力としてRaycastCommandにセット、結果がRaycastHitの配列に出力されます。
スケジュールの発行はRaycastCommand.ScheduleBatchで、ここに入力と出力のバッファを詰めればOKです。後はCompleteで終了待ちして、結果を使って何らかの処理(今回は地面に接触したらバウンド)を実行したりします。
結果はProfiler.Timelineで確認
Jobsの実行状況は、ProfilerのTimelineで実行結果を確認できます。
逆を言えばProfilerのTimeline以外では把握しにくいです。CPUのグラフにWorker Threadの項目は乗りません。
C# Job Systemで並列処理 : IJobParallelFor
RaycastCommandで大体の流れは見えたので、次にC# Job Systemを使って制御をやってみます。
とは言え、RaycastCommandを使えたなら難しいことは余りないです。やることは単純で、IJobParallelForを継承した構造体を定義し、行う処理を定義してやるだけです。
使い方も、挙動も、RaycastCommandと殆ど変わりません。
違いは自分で入力・出力・処理を定義できるかどうかです。
IJobParallelForを継承した構造体を定義
JobParallelForを使用してみます。
RaycastCommandで取得した地面との距離の情報を使って、跳ね返るかどうかの判断と加速度の設定する部分を、C# Job Systemに差し替えます。
C-sharp-Job-System-Sample/BounceCube2.cs at master · tsubaki/C-sharp-Job-System-Sample · GitHub
まずはIJobParallelForを定義します。
IJobParallelForを継承した構造体に、Executeメソッドを記述すればOKです。Executeメソッドの中身が、各ジョブで実行される内容になります。
現在実行中の要素がindexに格納されるので、その番号の要素をフィールドのNativeArrayから取得し、何らかの計算を行い、そしてNativeArrayに返してやります。
NativeArrayとRead/Write属性
NativeArrayのフィールドにはReadOnlyタグとWriteOnlyタグという属性を定義できます。その名の通りReadOnlyは読み取り専用、WriteOnlyは書込専用です。
現在はout 修飾子くらいの意味しか見出していませんが、将来的にはC# Job System専用コンパイラがなんとかするのかもしれません。
ジョブの発行とジョブの依存関係
IJobParallelForを継承した構造体の準備が完了したので次にジョブの発行をします。
まずはジョブを発行するIJobParallelForを継承した構造体のインスタンスを作ります。
new時にNativeArrayのバッファを渡しておくと色々と楽です。
あとはジョブを発行して一気に加速度のバッファの中身を更新していきます。
ここで注目すべきは、処理の順番です。これはScheduleの3番目の引数でジョブの依存関係を設定して解決しています。
例えば、今回追加したジョブはRaycastCommandがチェックした地面との距離に応じて動作が変化する機能です。その為、ジョブの実行タイミングによっては想像していない動作が起こるかもしれません。
その為、ジョブの内容に依存関係がある場合は依存性をもたせることで、同時に二つのジョブが一つの要素にアクセスするのを防ぎます。
なお、二つのジョブが同時に要素にアクセスするとC# Job Systemはエラーを出します。その辺り気にしなくてもよいのは楽で良いです。
Transformにアクセスできる IJobParallelForTransform
加速度の設定まで出来たので、次はTransformの移動もジョブ側でやってみます。
IjobParallelForTransformを使用したサンプルはこちら。
C-sharp-Job-System-Sample/BounceCube3.cs at master · tsubaki/C-sharp-Job-System-Sample · GitHub
通常のコンポーネントは参照型なのでJob System側からコントロール出来ないのですが、唯一Transformだけは専用の構造体からコントロール出来るようになっています。
呼出部分は、TransformAccessArrayが有ること以外は殆ど違いはありません。
ただし、観測している限り、少し挙動が違うかもしれません。IJobParallelForTransformは他の並列処理系と異なり、ジョブの実行回数を指定する項目がありません。また、かなりの量のジョブを実行しても一つのコアしか使わない所を察するに、バックグラウンドで動作はするが並列ではない可能性があります。
ジョブを待つタイミングについて
C# Job Systemは並列処理のシステムです。基本的に並列で実行できているならば、それはある程度の目標が達成できたと言えます。
ただし、ジョブを何度も発行→Complete()を行っていると、メインスレッドへ同期する為の時間が伸びますし、ジョブが散発的に発行されます。
またメインでCompleteまで待つと、全体の処理が終わるまでメインスレッドが遊ぶ事があります。
なので、ジョブを即座にCompleteせず発行するだけして後で回収…という形が望ましいかもしれません。特に、上の方でも書いていますがbig.LITTLE構成のコアは、メインスレッドは速攻で終わるがWorkerThreadは速攻では終わらないというケースもあります。
Updateでジョブ発行、LateUpdateや次フレームの開始時にCompleteといった構成する等々、即座にCompleteではなくジョブが動く余力を残してやることで、メインスレッドを馬車馬の如く働かせれます。
ただし、「次のフレームの最初にComplete」をやる場合には、バッファのアクセスに注意する必要があります。
- Alloc.Tempは数フレームまたぐとメモリリークと疑われる
- NativeArrayのDisposeをやる場合には、NativeArrayに誰も触っていない状態にしないといけない(handle.CompleteをDispose前に実行)
またNativeArrayがフィールドに移動するので、コードが少しゴチャゴチャします。まぁこの辺りはコルーチン的な方法でスッキリします。
なおIJobParallelForTransformや他のジョブは、他の処理とまとめて一気に処理しようという気配が見えます。例えばIJおbParallelForTransformはPostLateUpdate時にUpdateRenderBoundingVolumeを始めとした描画に関連しそうな処理を一気に実行しようとしますし、RaycastもどこかにRaycastを呼び出していると、そのタイミングで一緒に処理しようとします。
一つのジョブを発行するIJob
最後に追加で、沢山の計算結果を使用して一つの結論を出す…をC# Jobsでやってみます。
C-sharp-Job-System-Sample/BounceCube4.cs at master · tsubaki/C-sharp-Job-System-Sample · GitHub
下のGifは、Cubeが地面に接触しているかを大量のRay の情報を元に判定して、接触してなければ「UNTOUCH」とUIに出しています。
IJobはC# Job Systemの制約を持ったスレッドみたいな感じです。結果を一つしか返さないような物に使えるんじゃないかなと個人的には考えています。
例えば、最長の長さ・最短の長さ・現在地面に立っているのかどうか等々。
複数実行すると並列的にタスクを詰めていきますが、タスク発行それ自体にもコストはあるので、できれば纏めたほうが良いかもしれません。
ジョブの発行は外と同じような感じです。
発行すると、他のWorker Threadに差し込まれます。
感想
幾つかクセのある所もありますが、なれると成る程使いやすいです。
関連情報
C# Job systemの割と分かりやすい動画です。
C# Job Systemのクックブックです。
メッシュの変形やCubeの移動、ポイントクラウドや当たり判定など、幾つかのデモが割りと分かりやすいコードで紹介されています。
C# Job System学ぶならオススメ
github.comC# Job systemのパフォーマンス比較検証用に良いプロジェクトです。
内容はゴチャゴチャしてるので、学習用途ならJob system cookbookの方が良いかもしれません。
github.comECSとC# Job SystemとBurstコンパイラについてです。