導入
JobSystemを聞いたことがあるだろうか?
聞いたことがなければ一旦Unity公式の以下の動画でどんなものか確認してほしい
今日から速い!Unity の Burst + C# Job System
この記事では、JobSystemの原理などの詳しい事は置いておいて、とりあえずJobを作って動かしてみる事を体験しよう
動作環境
Unity 6000.0.23f1
Burst 1.8.18
サンプルコード
早速このコンポーネントを実行してみてほしい
コンポーネントをアタッチした状態でゲームを実行するとベンチマークがデバッグログに出力される
またコンテキストメニューの「Benchmark」ボタンからもベンチマークを実行できる
※同期処理のため、_arrayRangeを大きくし過ぎるとフリーズするので注意
using System.Diagnostics;
using System.Linq;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
public class JobSystemExample : MonoBehaviour
{
[SerializeField]
private int _arrayRange = 100000;
private void Start()
{
JobSytemBenchmark();
}
[ContextMenu("Benchmark")]
private void JobSytemBenchmark()
{
long normalWatch = 0;
long job1Watch = 0;
long job2Watch = 0;
long job3Watch = 0;
long job4Watch = 0;
int[] numbers = Enumerable.Range(0, _arrayRange).ToArray();
bool[] result = new bool[numbers.Length];
//通常の処理
Stopwatch stopwatch = Stopwatch.StartNew();
for (int i = 0; i < numbers.Length; i++)
{
result[i] = IsPrime(numbers[i]);
}
stopwatch.Stop();
normalWatch = stopwatch.ElapsedMilliseconds;
// NativeArrayを使用してジョブの結果を格納
using (NativeArray<int> numbersArray = new(numbers, Allocator.TempJob))
{
using (NativeArray<bool> resultArray = new NativeArray<bool>(numbers.Length, Allocator.TempJob))
{
stopwatch.Restart();
//ジョブ1
var job1 = new JobOne() { numbers = numbersArray, results = resultArray };
var handle1 = job1.Schedule();
handle1.Complete();
stopwatch.Stop();
job1Watch = stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
//ジョブ2
var job2 = new JobTwo() { numbers = numbersArray, results = resultArray };
var handle2 = job2.Schedule();
handle2.Complete();
stopwatch.Stop();
job2Watch = stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
//ジョブ3
var job3 = new JobThree() { numbers = numbersArray, results = resultArray };
var handle3 = job3.Schedule(numbersArray.Length, 0);
handle3.Complete();
stopwatch.Stop();
job3Watch = stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
//ジョブ4
var job4 = new JobFour() { numbers = numbersArray, results = resultArray };
var handle4 = job4.Schedule(numbersArray.Length, 0);
handle4.Complete();
stopwatch.Stop();
job4Watch = stopwatch.ElapsedMilliseconds;
UnityEngine.Debug.Log($"Array Range is <color=yellow>{_arrayRange}</color>\n" +
$"normal : <b>{normalWatch}</b> ms\n" +
$"IJob : <b>{job1Watch}</b> ms\n" +
$"IJob Burst : <b>{job2Watch}</b> ms\n" +
$"IJobParallelFor : <b>{job3Watch}</b> ms\n" +
$"IJobParallelFor Burst : <b>{job4Watch}</b> ms");
}
}
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
private struct JobOne : IJob
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute()
{
for (int i = 0; i < numbers.Length; i++)
results[i] = IsPrime(numbers[i]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
[BurstCompile]
private struct JobTwo : IJob
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute()
{
for (int i = 0; i < numbers.Length; i++)
results[i] = IsPrime(numbers[i]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
private struct JobThree : IJobParallelFor
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute(int index)
{
results[index] = IsPrime(numbers[index]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
[BurstCompile]
private struct JobFour : IJobParallelFor
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute(int index)
{
results[index] = IsPrime(numbers[index]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
}
サンプルコードの解説
このコンポーネントは_arrayRange個の数値の配列を素数判定する時間を計測している
今回の例では、1000000個の素数判定を実行した処理速度を測っている
通常の処理
IsPrimeメソッドに数値の配列の要素一つ一つを渡して実行し、素数判定の結果をresultに記録している
これは馴染みのあるfor文を用いた繰り返し処理で、実行したところ私の環境では72msほどの時間がかかった
for (int i = 0; i < numbers.Length; i++)
{
result[i] = IsPrime(numbers[i]);
}
ジョブ1
JobSystemを用いて上記と通常の処理と同じことを実行する
コードの説明を省いて行っている事だけを説明すると、ジョブに数値の配列を渡して実行を開始し、処理が終了するまで待機している
これはJobSystemを使用しているが、わざわざ通常の処理と同じ事をするためにジョブに配列を渡しているため、むしろ時間がかかってしまう
上記と同じく1000000個の素数判定をしたところ、124msほど掛かってしまった
var job1 = new JobOne() { numbers = numbersArray, results = resultArray };
var handle1 = job1.Schedule();
handle1.Complete();
private struct JobOne : IJob
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute()
{
for (int i = 0; i < numbers.Length; i++)
results[i] = IsPrime(numbers[i]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
ジョブ2
JobSystemにBurstCompileを適用して、処理を高速化する
BurstCompileとは、とりあえず処理を高速化する機能という認識を持っておこう
適用すると先ほどまでの3倍の速度、45msほどで処理が完了する
var job2 = new JobTwo() { numbers = numbersArray, results = resultArray };
var handle2 = job2.Schedule();
handle2.Complete();
[BurstCompile]
private struct JobTwo : IJob
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute()
{
for (int i = 0; i < numbers.Length; i++)
results[i] = IsPrime(numbers[i]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
ジョブ3
先ほどまでは一つのワーカー(仕事人)が配列を一つづつ処理していたが、今回はワーカーを複数使って、同時並行で処理をしている
なので単純に考えて、ワーカーの数だけ処理速度が倍になる
実行してみると30msほどで処理が完了した
var job3 = new JobThree() { numbers = numbersArray, results = resultArray };
var handle3 = job3.Schedule(numbersArray.Length, 0);
handle3.Complete();
private struct JobThree : IJobParallelFor
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute(int index)
{
results[index] = IsPrime(numbers[index]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
ジョブ4
ジョブ3にBurstを適用したもの
同時並行で処理を行っている上に、Burstによって処理速度が上がっているため、最高速で処理が行われる
実行時間は脅威の9msである
var job4 = new JobFour() { numbers = numbersArray, results = resultArray };
var handle4 = job4.Schedule(numbersArray.Length, 0);
handle4.Complete();
[BurstCompile]
private struct JobFour : IJobParallelFor
{
public NativeArray<int> numbers;
public NativeArray<bool> results;
public void Execute(int index)
{
results[index] = IsPrime(numbers[index]);
}
static private bool IsPrime(int number)
{
if (number < 2) return false;
if (number == 2) return true;
if (number % 2 == 0) return false;
for (int i = 3; i * i <= number; i += 2)
{
if (number % i == 0) return false;
}
return true;
}
}
ここまで見てきて、コードの内容は良く分からなかったと思うが、少なくともジョブ4の高速性を理解してくれたと思う
ということで、ジョブ4のIJobParallelForとBurstCompileについての使い方のみを解説する
ジョブの使い方
制約
今回のJobSystemとBurstCompileを使用するには、以下の制約をクリアしなければならない
C#前提。C#のポインタは使える。
仮想メソッドを持つことはできない。
GCも使えない。
参照型は無し。プリミティブ型と構造体のみ。
NativeArray構造体である。
連続したメモリレイアウト前提。
データの入出力がはっきりしてること。
SIMD auto -vectorization
Preciton control (Low, Med, High)
参考 - Burstの前提条件とは?
ジョブの作り方
ジョブはstructとして作る
「IJobParallelFor」というinterfaceを継承する
このインターフェースは「Execute(int index)」というメソッドを実装する
Executeメソッドの引数はジョブの番号で、例えば32個のジョブを並行処理するときは、引数に0~31が渡され32回実行される
private struct ExampleJob : IJobParallelFor
{
public void Execute(int index)
{
//ここにジョブの処理を実装する
}
}
ジョブの実行方法
まず先ほど作ったジョブのstructをインスタンス化させる
次にインスタンスにScheduleメソッドを使ってジョブの処理を始める
Completeでジョブの終了を待機する
var ExampleJob = new ExampleJob();
var handle = ExampleJob.Schedule(32, 0);
handle.Complete();
ジョブに配列を渡して処理する
もしサンプルコードのように配列の要素に一気に処理を走らせたい時は「NativeArray」を使用する
private struct ExampleJob : IJobParallelFor
{
public NativeArray<int> numbers;
public void Execute(int index)
{
//ここにジョブの処理を実装する
numbers[index] += 1;
}
}
NativeArrayは特殊な配列で、usingステートメントを使用する
Scheduleメソッドの第一引数はジョブの数なので、配列のLengthを指定すると良い
int[] numbersArray = new int[5] { 1, 2, 3, 4, 5 };
using (NativeArray<int> numbersNativeArray = new(numbersArray, Allocator.TempJob))
{
var ExampleJob = new ExampleJob() { numbers = numbersNativeArray };
var handle = ExampleJob.Schedule(numbersNativeArray.Length, 0);
handle.Complete();
}
BurstCompileを適用する
ジョブの上にBurstCompileアトリビュートを追加するだけ
[BurstCompile]
private struct ExampleJob : IJobParallelFor
{
public NativeArray<int> numbers;
public void Execute(int index)
{
//ここにジョブの処理を実装する
numbers[index] += 1;
}
}
終わり
このような手順でIJobParallelForとBurstCompileを使った高速な並列処理が行える
説明を端折って例を多めにしたので、もし説明不足があれば追記しようと思う