はじめに
先日のUnity 2019.1でBurst Compilerが正式に採用されました。
そこで、これを使うための方法を簡単に紹介します。
ECSとかって言うのは…
範囲が広がりすぎるので、今回は触れません!
Unityでゲームのパフォーマンスを上げようという試みData-Oriented Technology Stack、略してDOTSはJob System、Burst Compiler、ECSの3本柱からなりますが、現状ECSの仕様が安定していない感じです。過去記事を参考に試してみるものの、関数がレガシーになっていて実行できない始末
DOTS(Data-Oriented Technology Stack)
- Job System・・・マルチコアで動かすためのもの
- Burst Compiler・・・コンパイルを最適化するためのもの
- ECS・・・キャッシュ効率を上げるためのもの
詳しくは → DOTS – Unity の新しいマルチスレッド対応の Data-Oriented Technology Stack
また、ECSを混ぜるとJob System、Burst Compilerもろともよく分からなくなるので、この二つに絞っています。
Job System
近年、CPU単体での性能は頭打ちで、これからは複数のCPU(コア)を乗せようというメニーコアの時代だそうです。そして、その複数のCPU(コア)に対して、仕事を割り当てるためのものが、このJob Systemです。以後CPUはコアと呼びます。
割り当て方と言っても、色々あると思います。例えば4つコアがあれば、1つのコアに対してだけ割り当てて残りの3つのコアはサボらせるのか、それとも、4つのコアに対して均等に割り当てるのか。以降でスクリプトを交えて説明します。
Burst Compiler
スクリプト内でJob Systemを使っているところで [BurstCompile] というのを記述するだけです。どこなのか、スクリプトを交えて説明します。
Burst Compilerの導入
デフォルトではBurst Compilerは入っていないので入れます。ちなみにJob Systemはもとから入っているみたいです。
上部の「Window」→「Package Manager」→「Burstを探しInstallをクリック」→「Burstの右側にチェックが入っていることを確認」
次に、Job Systemのコアに対するジョブ(仕事)の割り当て方別に説明していきます。
1つのコアに対してジョブを割りあてる
従来はメインスレッドだけでなんらかの処理していました。それを1つのコアに肩代わりしてもらいます。
int型の配列にインデックス番号を格納することをジョブにします。例えば、int型配列arrayがあれば、array[0]に0を、array[1]に1を格納していく感じです。
JobSystem.csを作成し、適当なGameObjectにアタッチします。
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 | using UnityEngine; using Unity.Collections; using Unity.Jobs; using Unity.Burst; public class JobSystem : MonoBehaviour { NativeArray<int> array; //int型のポインタ(配列の先頭を指す) const int arrayNum = 1000000; //配列の要素数 JobHandle handle; //スケジューリングを格納するためのもの [BurstCompile] //burstの設定 struct CounterJob : IJob //ジョブの定義 { [WriteOnly] public NativeArray<int> counterArray; //int型のポインタ(外部(メインスレッド)とやり取りできる) void IJob.Execute() //処理 { for (int i = 0; i < counterArray.Length; i++) counterArray[i] = i; } } void OnEnable() { array = new NativeArray<int>(arrayNum, Allocator.Persistent); //配列を作る } void OnDisable() { array.Dispose(); //配列の破棄 } void Update() { CounterJob job = new CounterJob() //ジョブの生成と初期化 { counterArray = array //ポインタを渡している }; handle = job.Schedule(); //ジョブのスケジューリング handle.Complete(); //ジョブを実行し、完了を待つ } } |
using 冒頭
2~4行目が新しく必要になります。
8~11行目:メンバー変数の用意
NativeArrayが特殊なので後述
ジョブの数だけJobHandleを用意しておきます。
NativeArray型
宣言時の中身は空です。C言語に触れていれば、ポインタと言えばわかりやすいです。newしたものを格納してあげない限り、使えません。
コアにジョブを割り当てつつ、メインスレッドとやりとりするためにNativeArrayである必要がある。
26行目:配列の生成
1 | new NativeArray<int>(arrayNum, Allocator.Persistent); |
第1引数が、配列の要素数。第2引数が、いつまで寿命がもつのかを指定。寿命に応じて破棄処理をしないと実行中にエラーがでます。
NativeArrayのnewは、動的にメモリを確保し、従来のC#によるガーベージコレクションの対象にならないため、手動で破棄する必要があります。破棄しなければ、メモリが不足し処理落ちするため、そのための寿命設定と言えます。
寿命
- Allocator.Persistent
- Allocator.TempJob
- (Allocator.Temp)
PersistentとTempJobの2種類を覚えときましょう。
Allocator.Persistent
寿命が無限、今回のようにクラスのメンバー変数にして、OnEnableでnewして、OnDisableで破棄する場合これ。
Allocator.TempJob
寿命が数フレーム、関数内で毎回、毎フレームnewする場合は、こまめに破棄する必要があるためこれを使う。
※Allocator.Tempは、なぜかジョブに含めることができず、使い物にならない。.Schedule()でエラー)
31行目:配列の破棄
忘れずに破棄
14~22行目:構造体でジョブの定義
1 | struct CounterJob : IJob |
ジョブの定義は構造体によって行います。構造体でJob系からインターフェース実装します(継承みたいに「:IJob」とする)。
また、ジョブの定義内では、基本的にUnity独自のクラスや関数が使えず、色々と制約があります。初めのうちは、記述通りに進めていくほうが吉です。
Job系インターフェース
- IJob
- IJobParallelFor
- IJobParallelForTransform
IJob
1つのコアに対してジョブを割り当てる。今回これ
IJobParallelFor
複数のコアに対してジョブを割り当てる。次の項目で使います。
IJobParallelForTransform
複数のコアに対してジョブを割り当てつつ、Unityで使われているTransformをジョブ内で使えるようにするためのもの。今回はスルー、詳しくは以下url
【Unity】C# Job Systemを自分なりに解説してみる – テラシュールブログ
16行目:入力用の配列
ジョブ内では、基本NativeArrayを使います。これで、外部(メインスレッド)とやり取りします。初期化できるようにpublicにします。
権限の付与
- [WriteOnly]・・・書き込みだけ
- [ReadOnly]・・・読み込みだけ
ジョブの実行内容に応じて権限を付与します。最適化されるため、丁寧に付与しましょう。後々、複数のジョブを動かす時、エラー発生がしにくくなります。
17~21行目:ジョブの実行内容
1 | void IJob.Execute(){実行内容} |
インターフェース実装しているので、これを記述しないと、どのみちエラーになります。実行内容は、配列のインデックスと同じ値を代入。
13行目:Burst Compilerの設定
Burst Compilerの設定はジョブの定義に対してつけます。また、Burst Compilerで動かすジョブは定義内でDebug.Log()を使わないようにする必要があります!
36~39行目:ジョブの生成 Update関数内
Update関数に移ります。後々プロファイラで、負荷が見やすいように毎フレーム呼んでいます。
newで構造体を生成します。と同時に{ }内でNativeArrayのポインタ(アドレス)を渡します。ポインタを渡すとは、C#でいう refみたいなもの。
40行目:スケジューリング
実行するジョブを予約しておきます。
41行目:コアにジョブを割り当てて実行&待機
問題点
1 | handle.Complete(); |
この関数はジョブの実行と待機の役割を同時に担っているのが問題です。他記事でも、初めに、この形でよく取り上げられているので、あえてこれを最初にもってきました。
コアにジョブを任せるにも関わらず、Complete()が呼ばれるまでジョブが実行されず、実行したとしてもメインスレッドごと待機してしまいます。
解決策
実行と待機の役割を分離します。Update()内を以下のように変更。
1 2 3 4 5 6 7 8 9 10 11 | void Update() { handle.Complete(); //ジョブの完了を待つ CounterJob job = new CounterJob() //ジョブの生成と初期化 { counterArray = array //ポインタを渡している }; handle = job.Schedule(); //ジョブのスケジューリング JobHandle.ScheduleBatchedJobs(); //ジョブを実行する } |
変更点
- 実行だけの役割を担うJobHandle.ScheduleBatchedJobs()が加わった
- handle.Complete()の位置が頭に変わった
位置関係に注意して見ましょう。ジョブを最後に実行して、最初に先ほどのComplete()を持ってきます。これによって、メインスレッドで他オブジェクトのUpdate()やLateUpdate()などが呼ばれている間(なおオブジェクト間のUpdate()の実行順は保証されない)にコアでジョブを実行してくれます。
arrayに対する、Debug.Log()を差し込むときは、このScheduleBatchedJobs()とComplete()の間でないとエラーです。
JobHandle.ScheduleBatchedJobs()は、handleは指定できず、それまでにスケジューリングしたジョブは全て実行することに注意。
プロファイラ(Profiler)を見てみよう
上部の「Window」→「Analysis」→「Profilerをクリック」
実行してみます。
赤枠で囲っているところが、実行しているジョブです。クリックしてみると、1.45msかかっていることがわかります。Jobというタブ以下にWorker1~6とあり、これらのどれかにジョブが自動的に割り当てられます。
Burstでなければ…
参考までにBurst Compilerの設定を外すと、46.5ms、Burstにより約30倍の性能が出ていたことがわかります。
複数のジョブと依存関係
複数のジョブがあり、同じNativeArrayを初期値として流し込む場合は注意が必要です。arrayを全て0にするResetJobというジョブを新たに追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | struct CounterJob : IJob //ジョブの定義 { [WriteOnly] public NativeArray<int> counterArray; //int型のポインタ(外部(メインスレッド)とやり取りできる) void IJob.Execute() //処理 { for (int i = 0; i < counterArray.Length; i++) counterArray[i] = i; } } struct ResetJob : IJob //ジョブの定義 { [WriteOnly] public NativeArray<int> counterArray; //int型のポインタ(外部(メインスレッド)とやり取りできる) void IJob.Execute() //処理 { for (int i = 0; i < counterArray.Length; i++) counterArray[i] = 0; } } |
ジョブが増えたので、計2つのJobHandleを用意
1 | JobHandle handle,resetHandle; |
arrayをリセットして、カウントするResetJob→CounterJobの順の場合
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | void Update() { handle.Complete(); //ジョブの完了を待つ resetHandle.Complete(); CounterJob job = new CounterJob() //ジョブの生成と初期化 { counterArray = array //ポインタを渡している }; ResetJob resetJob = new ResetJob() //ジョブの生成と初期化 { counterArray = array //同じポインタを渡している }; }; resetHandle = resetJob.Schedule(); //ジョブのスケジューリング handle = job.Schedule(resetHandle); //依存関係を加えたスケジューリング JobHandle.ScheduleBatchedJobs(); //ジョブを実行する } |
ジョブの実行順にスケジューリングしていき、後に続くスケジューリングの()内で格納したハンドルを渡します。
これでResetJobが完了するまでCounterJobは待ってくれます。
複数のコアに対してジョブを割りあてる
同様に進めていきます。先ほど少し触れた、Job系インターフェースのうちのIJobParallelForに差し替えましょう。
Executeの引数変更
Executeの引数がJob系インターフェースに応じて変わります。
1 2 3 4 5 6 7 8 9 | [BurstCompile] struct CounterJob : IJobParallelFor //ジョブの定義 { public NativeArray<int> counterArray; void IJobParallelFor.Execute(int index) //処理 { counterArray[index] = index; } } |
引数のindexは、向こうから自動的にわたってきます。今回であれば、配列の要素数分回すので、0~arrayNumまでの数字がindexとわたってくるのだと考えましょう。
複数のコアにジョブを割り当てる場合に、burstはなくてもDebug.Log()は使えないみたいです。止まりました
indexの最大値の設定
1 2 3 4 5 6 7 8 9 10 | void Update() { handle.Complete(); //ジョブの完了を待つ CounterJob job = new CounterJob() //ジョブの生成と初期化 { counterArray = array //ポインタを渡している }; handle = job.Schedule(array.Length,10000); //ジョブのスケジューリング JobHandle.ScheduleBatchedJobs(); //ジョブを実行する } |
スケジューリング時の、第1引数に先ほどのExecuteに供給されるindexの最大値、第2引数でバッチサイズを設定します。バッチサイズとは、複数のコアに割り当てる際のindexの塊を表します。
仮にコア数が3で、今回のように10000であれば、
0~10000がindexとして流れるジョブをコア1に、
10001~20000がindexとして流れるジョブをコア2に、
20001~30000がindexとして流れるジョブをコア3に、
30001~40000がindexとして流れるジョブをコア1に、
のような感じで、indexの最大値に至るまで、繰り返しでコアにジョブが割り当てられます。
バッチサイズは極端に小さすぎたり、大きすぎると逆に非効率なため、適度に調整しましょう。
プロファイラ
全てのWorkerにジョブが割り当てられていることがわかります。
まとめ
Burst Compilerはジョブに対するものだった
手軽に性能が向上するので極力つけましょう。
※burstは、Debug.Log()の他にも色々制約があるので、詳しく知りたい方は参考urlを
Job System
NativeArray型
ジョブを使う上でのデータのやりとりはこの型で!
1対1対応でnewで生成したら必ずDisposeで破棄するように!
Job系インターフェース
コアへのジョブの割り当て方や、ジョブの実行内容に応じて適切なものを選択しよう。
参考