UnityのC# Job SystemとBurst Compilerを使ってみる

今回はUnityのC# Job Systemを使ってマルチスレッドコードを書いて、今までメインスレッドだけで行っていた処理を別のスレッドに分散して処理を軽くしてみます。

またBurst Compilerを使ってプラットフォームに最適な変換を出来るようにします。

C# Job SystemとBurst CompilerはDOTSの一部です。

UnityのDOTSってなんだろう?
UnityのDOTSとは何か?について見ていきます。DOTSはUnityでデータ指向型でゲームを作る時に必要な機能の集合体です。

今回確認したUnityのバージョンはUnity2020.1.15f1です。

スポンサーリンク

C# Job Systemとは

C# Job Systemはマルチスレッドコードを安全に書くことを可能とするUnityの機能です。

通常のマルチスレッドコードを書く場合は複数のスレッドが同じ変数を参照する場合に注意が必要です。

例えば変数Aに1という値が入っているとして、

スレッド1が変数Aを参照→スレッド2が変数Aを参照→スレッド1が変数Aの値に2を足して書き換え→スレッド2が変数Aの値に3を足して書き換え

とすると本来スレッド2はスレッド1がAに2を足した後の値に3を足したいところですが上の場合はその前の1に3を足して書き換えるという事が起きてしまいます(レースコンディションの問題)。

また、変数Aと変数Bがあり各スレッドが変数Aと変数Bを使って何らかの処理をしたいとします。

その時

スレッド1が変数Aをロックし他のスレッドが使えないようにする。
スレッド2が変数Bをロックし他のスレッドが使えないようにする。

とした場合スレッド1とスレッド2がお互いにロックした変数を取得出来ない為(デッドロック)永遠に処理が行えません(デッドロックの問題)。

こういったことを考える必要がある為、マルチスレッドコードを使うのは大変です。(´Д`)

というかわたくしには無理です。(´Д`)

C# Job Systemを使ってマルチスレッドコードを書けばこれらの問題をUnityエディターの実行時にエラーとして出してくれるので安全にスクリプトを書くことが出来ます。

シンプルなジョブを作成し実行してみる

まずはシンプルなジョブを作成し、それをUnityで実行してジョブを走らせてみましょう。

ジョブの作成

まずはAssetsフォルダ内で右クリックからCreate→C# Scriptを選択し名前をMyJobとし、通常のC#スクリプトを作成します。

usingディレクティブでUnity.CollectionsとUnity.Jobsを指定します。

ジョブはIJobインタフェースを実装(継承)し、クラスではなく構造体にする必要があるのでclassをstructとします。

numは足していく数字でresultは結果を入れます。

C# Job Systemの安全機能によってジョブの結果が共有出来ない為、Nativeコンテナと呼ばれる共有メモリを使って結果を保存します。

これには

NativeArray
NativeList
NativeHashMap
NativeMultiHashMap
NativeQueue

等があります。

今回の場合はNativeArrayを使ってint型の共有メモリであるresultを使っています。

IJobインタフェースを実装するにはExecuteメソッドを書く必要があります。

実装するにはIJobの部分にマウスを持っていきクイック操作からインタフェースの実装を選択すると自動でExecuteメソッドが書かれます。

IJobインタフェースの実装のクイック操作

Executeメソッドの中身にはfor文を使ってresultの0番目にnumの数字を足していきます。

これでジョブが出来ました。

ジョブを実行する

ジョブの定義が出来たので次はそのジョブを作成し実行出来るようにしてみます。

新しくMyJobBehaviorスクリプトを作成します。

Startメソッドで足す数字であるnumberOfAdd、結果の共有メモリresultMemoryを宣言します。

これはジョブで作った変数と同じ名前にしても構わないですが、混乱すると思って名前を変更しました。

NativeArrayの第1引数は要素数で第2引数はメモリの割り当てのタイプを指定します。

Allocator.Tempは1フレーム以下でメモリを解放する必要があり、ジョブにNativeコンテナを渡すとエラーが発生します。
Allocator.TempJobは4フレーム以下でメモリを開放する必要があります。
Allocator.Persistentは持続的にメモリを使用します。

割り当ての速さは上から順番に速くなっています。

通常はAllocator.TempJobを使うようです。

numberOfAddとresultMemoryの後でジョブを作成します。

ジョブの作成と同時に初期化子を使ってジョブの設定値を入れています。

その後myJob.Schedule()でジョブをスケジューリングをします。

戻り値をJobHandle型のmyJobHandleに入れます。

myJobHandle.Completeでジョブの終了を待ちます。

Completeメソッドを呼ぶことでスケジュールされたジョブが実行されます。

ジョブのresultMemory[0]をresultNumに入れて、その後コンソールに結果を出力しています。

最後にresultMemory.Dispose()で共有メモリの開放を行います。

シーンのMain Camera等のゲームオブジェクトにMyJobBehaviorスクリプトを取り付け実行するとコンソールに200が表示されます。

他のジョブに依存する処理を実行する

先ほどの例ではひとつのジョブを実行し終了を待って結果を表示しました。

次は二つのジョブを実行し、最初のジョブの結果を次のジョブで使用するような処理を実行してみます。

myJob1は先ほどと同じですが、myJob2はresultにresultMemoryを使っておりmyJob1とメモリを共有しています。

なのでmyJob2.Scheduleの引数にmyJobHandle1を入れてmyJob1の実行が終了してからmyJob2を実行するようにします。

先ほど実行したMyJobBehaviorを無効にしMainCamera等にMyJobBehavior2を取り付けて実行するとコンソールに400が表示されます。

これはmyJob1で実行した結果のresultMemoryをmyJob2でも使用している為、200にさらに200を足すという処理が実行される為です。

並列実行をするジョブの作成

先ほどのジョブは1つのジョブしか実行出来ませんでした。

次は並列にジョブを実行してみたいと思います。

並列ジョブの作成

まずは並列ジョブを作成します。

IJobをIJobParallelForに変更します。

IJobParallelForの部分にマウスを移動しクイック動作でインタフェースの実装を選択します。

今回は二つの配列の要素を順番に足していきresultに入れています。

最適化の為に書き込みを行わないNativeコンテナには[ReadOnly]アトリビュートを付けます。

並列にジョブを実行する

次に並列にジョブを実行させる為のスクリプトMyParallelJobBehaviorを作成します。

dataNumにデータの数を設定します。

今回の場合は二つの入力の配列と結果を入れるNativeコンテナを用意します。

それぞれ100の要素数を持たせ、データの数分の繰り返しを行って二つの入力配列の初期値を設定します。

その後MyParallelJobを作成します。

myParallelJob.Scheduleメソッドの第1引数でデータの数、第2引数でバッチ数を設定します。

バッチ数は何個のジョブをひと塊とするかの数値です。

ジョブを実行したら結果を合計した数値をtotalに入れます。

動作結果を確認する為コンソールに結果の0番目、1番目と全体の合計を表示します。

最後にDisposeを使ってメモリを解放しています。

MyParallelJobBehaviorスクリプトを何らかのゲームオブジェクトに取り付けて実行するとコンソールに

2
4
10100

が表示されます。

Transformの並列操作を行ってみる

ジョブの並列処理が出来ました。

次はIJobParallelForTransformを実装してジョブを作成し、Transformの操作を並列に行ってみます。

IJobParallelForTransformを使ったジョブの定義

最初にジョブを定義します。

IJobParallelForTransformを実装します。

speedは移動の速さでdeltaTimeは前フレームからの経過時間をジョブを生成する時に入れます。

ジョブ内ではTime.deltaTimeは使えない為、ジョブ生成時に入れます。

Executeメソッドの第2引数にTransformAccess型のtransformを受け取り、それぞれの位置を下向きに徐々に移動させます。

次にmath.mulを使って現在の角度の正規化した値に、Y軸をspeed * deltaTimeかけた分回転した角度をかけたものを自身の角度に入れています。

math関連はUnity.Mathematicsパッケージにあります。

Unity.Mathematicsはジョブ内だけでなく通常のスクリプト内で使用出来ます。

これでゲームオブジェクトがクルクルと回転します。

最後にY軸の位置が0以下になったら上に移動し同じようにまた下に降りる動作を繰り返すようにします。

ジョブを生成して実行する

次にジョブを生成して実行するスクリプトMyParallelJobTransformBehaviorを作成します。

prefabはインスタンス化するプレハブを設定します。

numberToInstantiateは一度にインスタンス化する数を設定します。

totalは今までインスタンス化したゲームオブジェクトのトータルの数。

totalTextはtotalの数を表示するUIのテキストを設定します。

transformsはスケジューリングする時に渡す共有メモリです。インスタンス化したゲームオブジェクトのTransformを入れます。

jobHandleはジョブのハンドルが入ります。

OnDisableメソッドではゲームオブジェクトが非アクティブになった時にジョブの終了とメモリの開放を行います。

Updateメソッドでは最初にジョブの終了を待ちます。

スペースキーを押した時にInstantiateGameObjectメソッドを呼んでプレハブをインスタンス化します。

その後MyParallelForTransformのジョブを作成します。

その後スケジューリングをする時に引数にtransformsを渡します。

インスペクタでPrefabに適当にプレハブを設定したり、Total TextにUIのテキストを設定し実行してください。

ゲームオブジェクトが生成され下に移動していくのが確認出来るはずです。

CPUのスレッドの数を確認

CPUのスレッド数を確認するにはWindows10の場合はWindowsのタスクバーを右クリックしてタスクマネージャーを開きパフォーマンスタブを開きます。

Windows10のタスクマネージャーを開く

論理プロセッサ数の所がスレッド数になります。

CPUのスレッド数の確認

わたくしの場合は論理プロセッサが4となっているのでメインスレッドが1、ワーカースレッドが3となります。

実行速度を確認してみる

IJobParallelForTransformを使った並列処理が出来るようになったので、ここで普通にゲームオブジェクト毎に移動スクリプトを設定し動かす今までのシングルスレッドのやり方と、どのぐらい実行速度が変わるかを確認してみましょう。

IJobParallelForTransformを使ったものは出来ているので、後はいつも通りにゲームオブジェクトに移動スクリプトを取り付け、MonoBehaviourを継承したスクリプトを作り、プレハブをインスタンス化するようにします。

プレハブのゲームオブジェクトに取り付けるNormalMoveスクリプト

次はプレハブをインスタンス化するスクリプトです。

MainCamera等のゲームオブジェクトに取り付けます。

こちらはスペースキーを押した時にプレハブをインスタンス化しているだけです。

NormalInstantiateGameObjectのprefabに設定するプレハブにはNormalMoveスクリプトを取り付け、

C#JobSystemサンプルの従来のやり方のプレハブの設定

MyParallelJobTransformBehaviorのprefabのプレハブには何のスクリプトも取り付けません。

C#JobSystemのサンプルのジョブシステム用のプレハブの設定

それではそれぞれ実行しFPSを確認してみます。

FPSの確認にはスタンダードアセットにあるFPSCounterスクリプトをUIのTextに設定し、ゲームオブジェクトを5000インスタンス化して確認しました。

まずは従来のメインスレッドだけで処理したNormalInstantiateGameObjectの場合の結果です。

FPSはだいたい16~20ぐらいになりました。

従来のやり方でのFPS

UnityメニューのWindow→Analysis→Profilerを開きます。

シングルスレッドで動作しているのでワーカースレッドでは処理をしていません。

従来のやり方のジョブの動作確認

次にC# Job Systemを使ったMyParallelJobTransformBehaviorの場合です。

FPSはだいたい22~24ぐらいになりました。

C#JobSystemでのFPS確認

Profilerで確認するとワーカースレッド(バックグラウンドで実行されるスレッド)でMyParallelForTransformジョブが実行されているのがわかります。

C#JobSystemでのジョブの動作確認

わたくしの環境だとそれほど違いは出ていませんが、メインスレッドのみで実行する処理もあると思うので並列にジョブを実行してくれるのはいいかもしれません。

処理をもっと速くしたい場合はECS(Entity Component System)を使ってデータ指向型でゲーム制作をしていくといいのかもしれません(ECSはまだプレビューパッケージです)。

実際にECSを使うと同様の機能でさらに処理が速くなります。(^^)/

ECSに関してはまた別記事でやります。

Burst Compilerを使ってみる

Burst Compilerは最適化されたコードに変換するコンパイラでC# Job Systemと効率的に連携するように設計されているようです。

バーストでサポートされている型は以下のUnityマニュアルを参照してください。

C#/.NET Language Support | Burst | 1.5.6-preview.1

バーストコンパイルを有効にするにはUnityメニューのJobs→Burst→Enable Compilationにチェックを入れます(デフォルトでチェックされている)。

バーストコンパイルを有効にする

あとはジョブの構造体の前に[BurstCompile]というアトリビュートを付けて実行するだけです。

先ほど作ったMyParallelJobTransformスクリプトの場合は以下のようになります。

詳細は参考サイトの最後にあるUnityマニュアルのBurst Compilerのページを参照してください。(^_^;)

バーストコンパイルをした後に再度実行すると以下のようにFPSが24~26ぐらいになりました。

ジョブをバーストコンパイルした結果

またGameビューのStatsを見るとGraphicsのFPSも上がっています。

ただ、バーストコンパイルしてなんでもかんでも速くなるというわけではないみたいです。

参考サイト

UnityマニュアルーC# Job Systemー

C# Job Systemを使ったUnity流マルチスレッドプログラミング

UnityマニュアルーBurst Compilerー

タイトルとURLをコピーしました