今回はUnityのC# Job Systemを使ってマルチスレッドコードを書いて、今までメインスレッドだけで行っていた処理を別のスレッドに分散して処理を軽くしてみます。
またBurst Compilerを使ってプラットフォームに最適な変換を出来るようにします。
C# Job SystemとBurst CompilerはDOTSの一部です。
今回確認した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#スクリプトを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Jobs; using UnityEngine; public struct MyJob : IJob { // 足す数字 public int num; // 結果のNativeArray public NativeArray<int> result; public void Execute() { // 100回足す for (int i = 0; i < 100; i++) { result[0] += num; } } } |
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メソッドが書かれます。
Executeメソッドの中身にはfor文を使ってresultの0番目にnumの数字を足していきます。
これでジョブが出来ました。
ジョブを実行する
ジョブの定義が出来たので次はそのジョブを作成し実行出来るようにしてみます。
新しくMyJobBehaviorスクリプトを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Jobs; using UnityEngine; public class MyJobBehavior : MonoBehaviour { // Start is called before the first frame update void Start() { // 足す数字 var numberOfAdd = 2; // NativeArrayの割り当て var resultMemory = new NativeArray<int>(1, Allocator.TempJob); // Jobの作成と初期化子を使ってジョブに変数を設定 var myJob = new MyJob { num = numberOfAdd, result = resultMemory }; // ジョブのスケジューリング JobHandle myJobHandle = myJob.Schedule(); // ジョブの終了を待つ myJobHandle.Complete(); // ジョブの結果を変数に入れる float resultNum = resultMemory[0]; // コンソールに結果を表示 Debug.Log(resultNum); // NativeArrayをメモリから解放する resultMemory.Dispose(); } } |
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が表示されます。
他のジョブに依存する処理を実行する
先ほどの例ではひとつのジョブを実行し終了を待って結果を表示しました。
次は二つのジョブを実行し、最初のジョブの結果を次のジョブで使用するような処理を実行してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Jobs; using UnityEngine; public class MyJobBehavior2 : MonoBehaviour { // Start is called before the first frame update void Start() { // 足す数字 var numberOfAdd = 2; // NativeArrayの割り当て var resultMemory = new NativeArray<int>(1, Allocator.TempJob); // Jobの作成と初期化子を使ってジョブに変数を設定 var myJob1 = new MyJob { num = numberOfAdd, result = resultMemory }; var myJob2 = new MyJob { num = numberOfAdd, result = resultMemory }; // ジョブのスケジューリング JobHandle myJobHandle1 = myJob1.Schedule(); JobHandle myJobHandle2 = myJob2.Schedule(myJobHandle1); // ジョブの終了を待つ myJobHandle2.Complete(); // ジョブの結果を変数に入れる float resultNum = resultMemory[0]; // コンソールに結果を表示 Debug.Log(resultNum); // NativeArrayをメモリから解放する resultMemory.Dispose(); } } |
myJob1は先ほどと同じですが、myJob2はresultにresultMemoryを使っておりmyJob1とメモリを共有しています。
なのでmyJob2.Scheduleの引数にmyJobHandle1を入れてmyJob1の実行が終了してからmyJob2を実行するようにします。
先ほど実行したMyJobBehaviorを無効にしMainCamera等にMyJobBehavior2を取り付けて実行するとコンソールに400が表示されます。
これはmyJob1で実行した結果のresultMemoryをmyJob2でも使用している為、200にさらに200を足すという処理が実行される為です。
並列実行をするジョブの作成
先ほどのジョブは1つのジョブしか実行出来ませんでした。
次は並列にジョブを実行してみたいと思います。
並列ジョブの作成
まずは並列ジョブを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Jobs; using UnityEngine; public struct MyParallelJob : IJobParallelFor { [ReadOnly] public NativeArray<int> input1; [ReadOnly] public NativeArray<int> input2; public NativeArray<int> result; public void Execute(int i) { result[i] = input1[i] + input2[i]; } } |
IJobをIJobParallelForに変更します。
IJobParallelForの部分にマウスを移動しクイック動作でインタフェースの実装を選択します。
今回は二つの配列の要素を順番に足していきresultに入れています。
最適化の為に書き込みを行わないNativeコンテナには[ReadOnly]アトリビュートを付けます。
並列にジョブを実行する
次に並列にジョブを実行させる為のスクリプトMyParallelJobBehaviorを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Jobs; using UnityEngine; public class MyParallelJobBehavior : MonoBehaviour { private int dataNum = 100; // Start is called before the first frame update void Start() { NativeArray<int> a = new NativeArray<int>(dataNum, Allocator.TempJob); NativeArray<int> b = new NativeArray<int>(dataNum, Allocator.TempJob); NativeArray<int> result = new NativeArray<int>(dataNum, Allocator.TempJob); for (int i = 0; i < dataNum; i++) { a[i] = i + 1; b[i] = i + 1; } MyParallelJob myParallelJob = new MyParallelJob() { input1 = a, input2 = b, result = result }; JobHandle handle = myParallelJob.Schedule(result.Length, 32); // ジョブが完了するのを待機します handle.Complete(); int total = 0; foreach (var tempResult in result) { total += tempResult; } // 結果表示 Debug.Log(result[0]); Debug.Log(result[1]); Debug.Log(total); // 配列に割り当てられたメモリを開放します a.Dispose(); b.Dispose(); result.Dispose(); } } |
dataNumにデータの数を設定します。
今回の場合は二つの入力の配列と結果を入れるNativeコンテナを用意します。
それぞれ100の要素数を持たせ、データの数分の繰り返しを行って二つの入力配列の初期値を設定します。
その後MyParallelJobを作成します。
myParallelJob.Scheduleメソッドの第1引数でデータの数、第2引数でバッチ数を設定します。
バッチ数は何個のジョブをひと塊とするかの数値です。
ジョブを実行したら結果を合計した数値をtotalに入れます。
動作結果を確認する為コンソールに結果の0番目、1番目と全体の合計を表示します。
最後にDisposeを使ってメモリを解放しています。
MyParallelJobBehaviorスクリプトを何らかのゲームオブジェクトに取り付けて実行するとコンソールに
2
4
10100
が表示されます。
Transformの並列操作を行ってみる
ジョブの並列処理が出来ました。
次はIJobParallelForTransformを実装してジョブを作成し、Transformの操作を並列に行ってみます。
IJobParallelForTransformを使ったジョブの定義
最初にジョブを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | using System.Collections; using System.Collections.Generic; using Unity.Collections; using Unity.Mathematics; using UnityEngine; using UnityEngine.Jobs; public struct MyParallelJobTransform : IJobParallelForTransform { // 移動と回転のスピード [ReadOnly] public float speed; // デルタタイム [ReadOnly] public float deltaTime; public void Execute(int index, TransformAccess transform) { transform.position = transform.position + Vector3.down * speed * deltaTime; transform.rotation = math.mul(math.normalize(transform.rotation), quaternion.AxisAngle(math.up(), speed * deltaTime)); if (transform.position.y <= 0f) { transform.position = new float3(transform.position.x, 50f, 0f); } } } |
IJobParallelForTransformを実装します。
speedは移動の速さでdeltaTimeは前フレームからの経過時間をジョブを生成する時に入れます。
ジョブ内ではTime.deltaTimeは使えない為、ジョブ生成時に入れます。
Executeメソッドの第2引数にTransformAccess型のtransformを受け取り、それぞれの位置を下向きに徐々に移動させます。
次にmath.mulを使って現在の角度の正規化した値に、Y軸をspeed * deltaTimeかけた分回転した角度をかけたものを自身の角度に入れています。
math関連はUnity.Mathematicsパッケージにあります。
Unity.Mathematicsはジョブ内だけでなく通常のスクリプト内で使用出来ます。
これでゲームオブジェクトがクルクルと回転します。
最後にY軸の位置が0以下になったら上に移動し同じようにまた下に降りる動作を繰り返すようにします。
ジョブを生成して実行する
次にジョブを生成して実行するスクリプトMyParallelJobTransformBehaviorを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | using System.Collections; using System.Collections.Generic; using Unity.Jobs; using Unity.Mathematics; using Unity.Rendering; using UnityEngine; using UnityEngine.Jobs; using UnityEngine.UI; public class MyParallelJobTransformBehavior : MonoBehaviour { // インスタンス化するプレハブ [SerializeField] private GameObject prefab; // 一度にインスタンス化する数 [SerializeField] private int numberToInstantiate = 100; // トータルでインスタンス化した数 private int total; // インスタンス化した数の表示テキスト [SerializeField] private Text totalText; // ゲームオブジェクトのTransformの共有メモリ private TransformAccessArray transforms; // ジョブハンドル private JobHandle jobHandle; private void OnDisable() { // ゲームオブジェクトが非アクティブになったらジョブの終了を待ってメモリ開放 jobHandle.Complete(); transforms.Dispose(); } // Start is called before the first frame update void Start() { // 実体生成 transforms = new TransformAccessArray(0); } // Update is called once per frame void Update() { // ジョブの終了を待つ jobHandle.Complete(); // スペースキーを押したらインスタンス化 if (Input.GetKeyDown(KeyCode.Space)) { InstantiateGameObject(); } // ジョブの生成 var myParallelJobTransform = new MyParallelJobTransform { // ジョブ内ではTime.deltaTimeを使えないのでジョブを作成した時にTime.deltaTimeを渡す deltaTime = Time.deltaTime, speed = 2f, }; // ジョブのスケジューリング jobHandle = myParallelJobTransform.Schedule(transforms); } // ゲームオブジェクトのインスタンス化 void InstantiateGameObject() { jobHandle.Complete(); // TransformAccessArrayの容量をインスタンス化する数だけ増やす transforms.capacity += numberToInstantiate; // UnityEngine.Random.Rangeで1~100のランダム値を使ってUnity.Mathematics.Randomのシードを作成 Unity.Mathematics.Random rand; rand = new Unity.Mathematics.Random((uint)UnityEngine.Random.Range(1f, 100f)); // numberToInstantiate数分をインスタンス化し、transformsに足す for (int i = 0; i < numberToInstantiate; i++) { var ins = Instantiate(prefab, new float3(rand.NextFloat(-20f, 20f), 50f, 0f), quaternion.identity); transforms.Add(ins.transform); } // 数の表示 total += numberToInstantiate; totalText.text = total.ToString(); } } |
prefabはインスタンス化するプレハブを設定します。
numberToInstantiateは一度にインスタンス化する数を設定します。
totalは今までインスタンス化したゲームオブジェクトのトータルの数。
totalTextはtotalの数を表示するUIのテキストを設定します。
transformsはスケジューリングする時に渡す共有メモリです。インスタンス化したゲームオブジェクトのTransformを入れます。
jobHandleはジョブのハンドルが入ります。
OnDisableメソッドではゲームオブジェクトが非アクティブになった時にジョブの終了とメモリの開放を行います。
Updateメソッドでは最初にジョブの終了を待ちます。
スペースキーを押した時にInstantiateGameObjectメソッドを呼んでプレハブをインスタンス化します。
その後MyParallelForTransformのジョブを作成します。
その後スケジューリングをする時に引数にtransformsを渡します。
インスペクタでPrefabに適当にプレハブを設定したり、Total TextにUIのテキストを設定し実行してください。
ゲームオブジェクトが生成され下に移動していくのが確認出来るはずです。
CPUのスレッドの数を確認
CPUのスレッド数を確認するにはWindows10の場合はWindowsのタスクバーを右クリックしてタスクマネージャーを開きパフォーマンスタブを開きます。
論理プロセッサ数の所がスレッド数になります。
わたくしの場合は論理プロセッサが4となっているのでメインスレッドが1、ワーカースレッドが3となります。
実行速度を確認してみる
IJobParallelForTransformを使った並列処理が出来るようになったので、ここで普通にゲームオブジェクト毎に移動スクリプトを設定し動かす今までのシングルスレッドのやり方と、どのぐらい実行速度が変わるかを確認してみましょう。
IJobParallelForTransformを使ったものは出来ているので、後はいつも通りにゲームオブジェクトに移動スクリプトを取り付け、MonoBehaviourを継承したスクリプトを作り、プレハブをインスタンス化するようにします。
プレハブのゲームオブジェクトに取り付けるNormalMoveスクリプト
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | using System.Collections; using System.Collections.Generic; using Unity.Mathematics; using UnityEngine; public class NormalMove : MonoBehaviour { private float speed = 2f; // Update is called once per frame void Update() { transform.position = transform.position + Vector3.down * speed * Time.deltaTime; transform.rotation = math.mul(math.normalize(transform.rotation), quaternion.AxisAngle(math.up(), speed * Time.deltaTime)); if (transform.position.y <= 0f) { transform.position = new float3(UnityEngine.Random.Range(-20f, 20f), 50f, 0f); } } } |
次はプレハブをインスタンス化するスクリプトです。
MainCamera等のゲームオブジェクトに取り付けます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; public class NormalInstantiateGameObject : MonoBehaviour { [SerializeField] private GameObject prefab; [SerializeField] private int numberToInstantiate = 100; private int total; [SerializeField] private Text totalText; // Update is called once per frame void Update() { if (Input.GetKeyDown(KeyCode.Space)) { InstantiateGameObject(); } } void InstantiateGameObject() { for (int i = 0; i < numberToInstantiate; i++) { Instantiate(prefab, new Vector3(Random.Range(-20f, 20f), 50f, 0f), Quaternion.identity); } total += numberToInstantiate; totalText.text = total.ToString(); } } |
こちらはスペースキーを押した時にプレハブをインスタンス化しているだけです。
NormalInstantiateGameObjectのprefabに設定するプレハブにはNormalMoveスクリプトを取り付け、
MyParallelJobTransformBehaviorのprefabのプレハブには何のスクリプトも取り付けません。
それではそれぞれ実行しFPSを確認してみます。
FPSの確認にはスタンダードアセットにあるFPSCounterスクリプトをUIのTextに設定し、ゲームオブジェクトを5000インスタンス化して確認しました。
まずは従来のメインスレッドだけで処理したNormalInstantiateGameObjectの場合の結果です。
FPSはだいたい16~20ぐらいになりました。
UnityメニューのWindow→Analysis→Profilerを開きます。
シングルスレッドで動作しているのでワーカースレッドでは処理をしていません。
次にC# Job Systemを使ったMyParallelJobTransformBehaviorの場合です。
FPSはだいたい22~24ぐらいになりました。
Profilerで確認するとワーカースレッド(バックグラウンドで実行されるスレッド)でMyParallelForTransformジョブが実行されているのがわかります。
わたくしの環境だとそれほど違いは出ていませんが、メインスレッドのみで実行する処理もあると思うので並列にジョブを実行してくれるのはいいかもしれません。
処理をもっと速くしたい場合はECS(Entity Component System)を使ってデータ指向型でゲーム制作をしていくといいのかもしれません(ECSはまだプレビューパッケージです)。
実際にECSを使うと同様の機能でさらに処理が速くなります。(^^)/
ECSに関してはまた別記事でやります。
Burst Compilerを使ってみる
Burst Compilerは最適化されたコードに変換するコンパイラでC# Job Systemと効率的に連携するように設計されているようです。
バーストでサポートされている型は以下のUnityマニュアルを参照してください。
バーストコンパイルを有効にするにはUnityメニューのJobs→Burst→Enable Compilationにチェックを入れます(デフォルトでチェックされている)。
あとはジョブの構造体の前に[BurstCompile]というアトリビュートを付けて実行するだけです。
先ほど作ったMyParallelJobTransformスクリプトの場合は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | using System.Collections; using System.Collections.Generic; using Unity.Burst; using Unity.Collections; using Unity.Mathematics; using UnityEngine; using UnityEngine.Jobs; [BurstCompile] public struct MyParallelJobTransform : IJobParallelForTransform { // 移動と回転のスピード [ReadOnly] public float speed; // デルタタイム [ReadOnly] public float deltaTime; public void Execute(int index, TransformAccess transform) { transform.position = transform.position + Vector3.down * speed * deltaTime; transform.rotation = math.mul(math.normalize(transform.rotation), quaternion.AxisAngle(math.up(), speed * deltaTime)); if (transform.position.y <= 0f) { transform.position = new float3(transform.position.x, 50f, 0f); } } } |
詳細は参考サイトの最後にあるUnityマニュアルのBurst Compilerのページを参照してください。(^_^;)
バーストコンパイルをした後に再度実行すると以下のようにFPSが24~26ぐらいになりました。
またGameビューのStatsを見るとGraphicsのFPSも上がっています。
ただ、バーストコンパイルしてなんでもかんでも速くなるというわけではないみたいです。