跳至内容

Unity JobシステムとBurstの初心者向けガイド:正しい使い方とは?

純粋にマルチスレッドを目的とするなら、Asyncでスレッドを切り替えるかSystem.Threadingを使うのが最も便利で分かりやすい方法です。Burstも直接呼び出せますし、必ずしもJobは必要ありません。詳細は非同期編直接呼び出し編の記事を参照してください。しかし、大量の小さな計算がある場合に初めてJobシステムを検討すべきです。

UnityのJobに関するドキュメントは極めて不完全です。本記事は長期間の使用経験に基づく記録であり、比較的網羅的であるはずです。

Jobの目的と制限

一般的なマルチスレッドの考え方とは異なり、UnityのJobシステムは、マルチスレッドを用いてGPUのような高スループットをシミュレートするシステムです。つまり、タスクの割り当てオーバーヘッドが非常に小さいのです。

Jobは1フレーム内の計算量しか担えず、CUDAやシェーダーの関数と同様に小さなカーネルです。重い計算は依然としてFPSに影響します。なぜなら、Jobは時々メインスレッドで実行されるからです。長時間の計算を実行したい場合は、多くの小さなJobに分割するか、そうでなければ.Netのスレッドが依然として最良です。

では疑問が湧きます。GPUと似ているなら、なぜ直接ComputeShaderを使わないのか?その通りです。Jobの仕事はComputeShaderでも全てでき、しかもより便利です。ただし、以下の場合は例外です:

  1. CPUと頻繁にデータを交換する必要がある場合。GPUはこれを得意としないため、CPUベースのJobに利点があります。
  2. ECS(DOTS)システムはJobベースであり、理由は上記と同じです。

注意:WebGLはJobとComputeShaderをサポートしておらず、Burstによる高速化もサポートしていません。ただし:

  • WebGLはWebGPUに置き換えられつつあり、WebGPUはComputeShaderをサポートしており、最新ブラウザは既に対応済みです(IOS17では設定で有効化する必要がありますが、19のプレビューではデフォルトで有効になっています)。
  • 設定でWebAssembly 2023を有効にするとマルチスレッドが有効になりJobをサポートします。また、ブラウザのサポートもより広範です。しかし、公式には実験的な機能ですらなく、使用不可とされており、Unity6 LTSで有効にするとクラッシュします。wasmのマルチスレッドについては彼らは何年も悩んでいます。加えて、現在DOTSが比較的重要視されており、.Net8がwasmマルチスレッドをサポートしているため、彼らはUnity7が.Net8をサポートするのを待っているのかもしれません。

性能

設計意図と適用性を理解しやすくするために性能指標を提供します。まず記事を読んでから、後で性能テストを見直すこともできます。

8つのプロジェクトをテストしました。まずは3種類のアロケータの性能をテスト:

  • BenchAllocatorTemp: Allocator.Tempによる100,000回の割り当てを実行
  • BenchAllocatorTempJob: 同上、アロケータはAllocator.TempJob
  • BenchAllocatorPersistent: 同上、アロケータはAllocator.Persistent 結果はTempJobが最速、次にPersistentでした。

次に、4種類のJobモードの性能をテスト:

  • BenchBaseLine: Forループで100,000回の単純計算を実行し、参考基準線とする
  • BenchIJob: 100,000個のJobをスケジューリングする時間
  • BenchIJobParallelFor: 並列モードで100,000個のJobをバッチスケジューリングする時間
  • BenchIJobParallelForBurst: 同上、ただしBurstを有効化
  • BenchIJobParallelForBurstLoopVectorization: 10個のJobをスケジューリングし、各JobでForループを使って10,000回計算、Burstベクトル化を有効化

以下は私のPCでのテスト結果です:

|2x

Medianはテストプロジェクトの中央値の所要時間で、単位はミリ秒です。

Jobのスケジューリングオーバーヘッドが小さいことがわかります。これは大量のタスクを実行するために設計されているからです。もちろん、ここでは単純な乗算計算しか行っていないため、Jobによる向上は限定的です。

データ型

まず、BurstはC#のマネージド型をサポートしていません。Cと同じ長さで、memcpy(シリアライゼーション/マーシャリング不要)できる型、つまりblittable型のみ使用できます。これには基本型(intなど)が含まれます(char、string、boolはマネージドになることがあるので使用しないでください)。また、blittable型の1次元Cスタイル配列(new int[5])も含まれます。Jobは必然的にBurstと組み合わせて使用するため、この制限に従います。

Unityはこのために、Job専用のスレッドセーフ型NativeArrayをラップしています。これらの型は、データポインタのみを渡すためコピー不要で、メインスレッドとデータを共有できます。複数のコピーはすべて同じメモリ領域を参照します。派生型としてNativeListNativeQueueNativeHashMapNativeHashSetNativeTextなどがありますが、これらはシングルスレッドでのみ使用できます。

注意:nativeArray[0].x = 1.0fnativeArray[0]++;のようなコードは使えません。値は変わりません。なぜなら、参照ではなく値を返すからです。

スレッドセーフティ

スレッドセーフティはスケジューリングの制限によって実現されます。同じNativeArrayインスタンスに対して書き込みを行うJobは1つしか実行できません。そうでないと例外がスローされます。データがセグメント化によって並列化できる場合は、IJobParallelForを使用してNativeArrayをバッチ処理できます。読み取り専用データの場合は、メンバ変数を定義する際に例えば[ReadOnly] public NativeArray<int> input;のようにマークできます。

Jobが書き込みを行っている間、メインスレッドはNativeArrayを読み取ることができず、エラーが発生します。完了を待つ必要があります。

メモリ割り当て(Allocate)

まず、Native型は使用後に手動でDispose()する必要があります。自動では破棄されません。このためUnityはメモリリーク追跡を追加しました。

Native型をnewする際には、TempTempJobPersistentの3種類のアロケータから選択する必要があります。割り当て速度は速い順に並んでいます。Tempは1フレームのライフサイクル、TempJobは4フレームです。これらは何を意味するのでしょうか?

  • Tempは、現在の関数内で使用し、関数終了前にDispose()することを意味します。したがって、Disposeを忘れるとUnityは次のレンダリング時にすぐにエラーを報告します。ただし、この割り当て速度は実際には遅いです。
  • TempJobは、より緩やかなエラー報告条件です。実際には依然として1フレーム内で使用することを意図していますが、次のフレームでDisposeすることができます。
  • Persistentはエラーを報告しません。自分で注意する必要があります。

以前の性能テストのBenchAllocatorプロジェクトは、これら3つの性能をテストしたものです。Allocator.Tempの所要時間がTempJobの4倍であることがわかります。ドキュメントではTempが最速とされていますが、これはバグか、エディターモードの問題のどちらかです。

シングルスレッドJobの実行

全体の流れは、自分でIJobクラスを書き、メインスレッドでそれをScheduleし、Completeを呼び出してJobの完了をブロッキング待機することです。

public struct MyJob : IJob {
    public NativeArray<float> result;

    public void Execute() {
        for (int j = 0; j < result.Length; j++)
            result[j] = result[j] * result[j];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

しかし問題は、Jobを使用するのは大量のタスクのためであり、このような単一タスクの有用性は限定的です。GPUの並列パターンを参考にする方が有用です。

並列モード(Parallel Job)

上記のコードをIJobからIJobParallelForを継承するように変更するのが並列モードです。

public struct MyJob : IJobParallelFor {
    public NativeArray<float> result;

    public void Execute(int i) {
        result[i] = result[i] * result[i];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule(result.Length, result.Length / 10);
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

並列モードでは自分でForループを書く必要がなく、各要素に対してExecuteが1回実行されます。シェーダーと似ています。

Schedule(result.Length, result.Length / 10)は、配列の0からresult.Lengthの長さの各単位に対してExecuteを実行し、10個のワーカーに割り当てることを意味します。

IJobとIJobParallelForの性能の違いについては、以前の性能テストを参照してください。

並列制限

IJobParallelFor内では、i要素にしか書き込めません。また、どのメンバー配列に書き込むのかを知らないため、すべての配列はi要素にしか書き込めません。ただし、NativeArray[NativeDisableParallelForRestriction]属性を付けてセキュリティチェックをオフにし、自分で書き込み競合がないことを保証すれば可能です。

読み取り専用モードでは、すべてのNativeコンテナに制限はありません。

また、IJobParallelForはループベクトル化を有効にできません。計算がすでにベクトル化されている場合(他のベクトル化された関数を呼び出す場合)を除き、性能は最適とは言えません。

並列処理でのNativeListなどのコンテナの使用

Array以外のコンテナ(NativeListなど)は、並列処理では読み取り専用モードのみです。では、どのように書き込むのでしょうか? 設計上、NativeListはAdd操作とSet操作の2つの作業状態に分かれています。正しい使用パターンは、1つのJobがAdd操作を行い、2つ目のJobがSet操作を行うことです。

Add操作時にはParallelWriterAsParallelWriterを使用できます。使用法は以下の通りです:

    public struct AddListJob : IJobParallelFor {
        public NativeList<float>.ParallelWriter result;

        public void Execute(int i) {
            result.AddNoResize(i);
        }
    }

    public void RunIJobParallelForList() {
        var results = new NativeList<float>(10, Allocator.TempJob);
        var jobData = new AddListJob() {
            result = results.AsParallelWriter(),
        };
        var handle = jobData.Schedule(10, 1);
        handle.Complete();
        Debug.Log(String.Join(",", results.ToArray(Allocator.TempJob)));
        results.Dispose();
    }

この状態では、NativeListは固定容量です。開始前にメモリを事前に確保する必要があり、AddNoResize()しか操作できません。このメソッドはLengthプロパティのアトミックロックによって実装されており、性能オーバーヘッドが大きいです。

次に、NativeListからNativeArrayへのロスレス変換:NativeList.AsDeferredJobArray()を使用します。このメソッドが返すNativeArrayは遅延評価され、Jobが実際に実行される時点でのみ変換が行われるため、2つのJobを実行する前に渡すことができます:

var addJob = new AddListJob { result = results.AsParallelWriter() };
var jobHandle = addJob.Schedule(10, 1);

var setJob = new SetListJob { array = results.AsDeferredJobArray() };
setJob.Schedule(10, 1, jobHandle).Complete();

AsDeferredJobArrayAsArrayが返すものはすべてビュー、つまり元データのビューであることに注意してください。Disposeしなければならないのは依然としてソースデータです。

二次元配列の並列モード

IJobParallelForは配列の単一要素ごとにしか並列化できません。しかし実際には、二次元配列の各行ごとに並列化する方が有用で、それによってループベクトル化を有効にでき、性能も高くなります。この操作にはIJobParallelForBatchを使用できます。

まず、[10*15]のフラットな二次元配列を作成し、IJobParallelForBatch.Schedule(int length, int batchCount)でスケジューリングします。batchCountは各Jobが担当するデータの数を表し、Executelength/batchCount回実行されます。

var results = new NativeArray<float>(10*15, Allocator.TempJob);
var jobData = new MyJob2D {
    result = results
};
var handle = jobData.Schedule(10*15, 15);
handle.Complete();
Debug.Log(String.Join(",", results));
results.Dispose();

次に、MyJob2Dの実装です。

[BurstCompile]
public struct MyJob2D : IJobParallelForBatch {
    public NativeArray<float> result;

    public void Execute(int i, int count) {
        for (int j = i; j < i + count; j++) {
            result[j] = i;
        }
    }
}

実行結果:

0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
9,9,9,9,9,9,9,9,9,9,9,9,9,9,9
UnityEngine.Debug:Log (object)

この方法はBurstによって自動的にループベクトル化が有効になるため、性能テストでは100,000回の計算に0.09msかかり、最速でした。

その他の制限

  • Job内でJobを開始することはできません。

Asyncとの組み合わせ

上記の使用例では、UpdateでJobをスケジューリングし、LateUpdateでCompleteしています。これはUpdateコードを高速化するためです。一度きりのタスクの場合はそれほど面倒なことはせず、Async方式で直接待機し、レンダリングをブロックしない方法が使えます。パッケージ内の拡張メソッドCompleteAsyncを使用できます:

async void GenerateMesh() {
    result = new NativeArray<float>(100000, Allocator.Persistent);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();

    await handle.CompleteAsync();
}

このパターンではPersistentアロケータを使用