コンテンツにスキップ
Unity JobシステムとBurst完全ガイド:正しい使い方は?

Unity JobシステムとBurst完全ガイド:正しい使い方は?

2024-12-09 16:56

もし単純にマルチスレッドが目的であれば、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をサポートしており、新しいブラウザは既にサポートしています(iOS 17では設定で有効にする必要がありますが、19プレビューではデフォルトで有効になっています)。
  • 設定のWebAssembly 2023でマルチスレッドを有効にするとJobをサポートでき、ブラウザのサポートもより包括的です。しかし、公式には実験的機能ですらなく、使用不可とされており、Unity6 LTSで有効にするとクラッシュします。wasmマルチスレッドについては彼らは何年も悩んでおり、現在DOTSが比較的重要視されていること、さらに .Net 8がwasmマルチスレッドをサポートしていることから、Unity7が .Net 8をサポートするのを待っているのかもしれません。

パフォーマンス

設計意図と適用性を理解しやすくするため、パフォーマンス指標を提供します。先に記事を読んでから、後でこのパフォーマンステストに戻ってきても構いません。

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ループを書く必要はなく、各要素に対して1回Executeが実行されます。シェーダーに似ています。

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

IJobとIJobParallelForのパフォーマンスの違いについては、以前のパフォーマンステストを参照してください。

並列制限

IJobParallelFor内では、i要素にしか書き込めません。また、どのメンバーArrayに書き込むのかを知らないため、すべてのArrayは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();

注意:AsDeferredJobArrayまたはAsArrayが返すものはすべてビュー、つまり元データのビューです。Disposeする必要があるのは依然としてソースデータです。

二次元配列の並列モード

IJobParallelForはArrayの単一要素ごとにしか並列化できません。しかし実際には、二次元配列の各行を並列化する方が有用であり、それによってループのベクトル化を有効にでき、パフォーマンスが向上します。この操作を実行するには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をスケジューリングし、Lateで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アロケーターを使用する必要があることに注意してください。なぜなら、必ずしも1フレーム内に完了するとは限らないからです。

Burst

BurstはLLVMベースの「高性能C#」と呼ばれるC#のサブセットであり、ほぼCコードです。通常、Monoよりも10倍から100倍高速です。もちろん、これはMonoが遅いことも示しています。

BurstはJobの実行速度をさらに向上させることができます。上記の例では、以下の1行を追加するだけです:

 [BurstCompile]
 public struct MyJob : IJobParallelFor {
     ...
 }

IJobParallelForのパフォーマンステストでは、この1行を追加するだけで、実行時間が5.16msから0.21msに向上し、Jobの実行速度がついにForループを超えました。

注:上記のパフォーマンステストはすべて10ワーカーでの結論です。ワーカー数を微調整するとパフォーマンス結果が異なる場合があります。

ベクトル化

ベクトル化とは、複数の計算を1つの命令にパッケージ化することです。例えば、float3の計算は自然にベクトル化されます。ベクトル化にはUnity.Mathematicsライブラリの型とメソッドを使用するのが最適です。そうでないと失敗する可能性があります。

ベクトル化計算を行っていない場合でも、ループのベクトル化を行うことができます。以前のパフォーマンステストでは、これによりさらに0.09msに向上しました。詳細は以前の二次元配列の章を参照してください。ループのベクトル化とは、並列化可能なForループ計算を1つの命令セットで完了させることであり、Burstが自動的に判断して最適化します。

Jobが正しくベクトル化されているかどうかを確認する方法

Burst Inspectorツールを開きます(Jobsメニュー内) |2x

あなたの関数を選択し、Assemblyにavx命令コードがあるか、およびIR Optimisationに警告がないかを確認します。正常にベクトル化されていない場合は、以下のように表示されます:

---------------------------
Remark Type: Analysis
Message:     test.cs:30:0: loop not vectorized: call instruction cannot be vectorized
Pass:        loop-vectorize
Remark:      CantVectorizeInstructionReturnType

一般的なものは以下の通りです:

  • loop not vectorized: call instruction cannot be vectorized ベクトル化できない外部関数を呼び出していることを指します。
  • loop not vectorized: instruction return type cannot be vectorized 一般的にこれは既に最適化された関数を呼び出しているため、2回目のベクトル化ができないことを意味し、正常です。

JobとUnityデータの変換

JobとBurstを使用する際に最も苦痛な点の1つは、様々なデータをNativeArrayに変換することです。

例えば、Vector3をfloat3に変更する場合、同じサイズであれば直接キャストできます。例:

var floats = new NativeArray<float3>(100, Allocator.TempJob);
NativeArray<Vector3> vertices = floats.Reinterpret<Vector3>();
Vector3[] verticesArray = vertices.ToArray();
floats.Dispose();

構造体にReinterpretすることもできます。例えば、3つのfloat1を1つのvector3に変換する場合:

var floats = new NativeArray<float>(new float[] {1,2,3}, Allocator.TempJob);
NativeArray<Vector3> aaa = floats.Reinterpret<Vector3>(sizeof(float));
Debug.Log(string.Join("\n", aaa.Select(v => v.ToString())));
floats.Dispose();
(1.00, 2.00, 3.00)

NativeArray<int>からNativeArray<ushort>へのキャストのような場合は、自分でJobを作成して変換する必要があります。

WebGLプラットフォームで自動的にバッチ実行されるJobSystem

JobSystemのコードはWebGLプラットフォームではメインスレッドで実行されるため、IJobParallelForで大量のタスクを実行するとゲームが直接フリーズします。

AdaptScheduleインターフェースを自作し、現在がWebGL環境かマルチスレッド環境かを自動的に判断できます。WebGLではworker数に応じて、1フレームずつ実行します。各ステップでyield return Awaitableを行い、メインスレッドに息継ぎの機会を与えます。

結語

Burstは実際にはコード高速化を実現するための妥協的な方法であり、これにより何でもBurst互換にしたくなる傾向が生まれ、最終的にコードは醜くなり、コンパイルも遅くなります。DOTSライブラリにもこのような痕跡が広がっています。Unityはエントロピー増大とともに本来ますます肥大化し、コンパイルはますます遅くなっています。DOTSをより大規模に適用したい場合、この問題は解決されなければなりません。

Unity7は .Net 8+とCoreCLRをサポートする予定であり、これによりEditorのコンパイル速度が向上し、 .Netの多くの新機能を利用できるようになり、Cコードとのコミュニケーションコストが減少します。少し期待して待ちましょう。おそらく将来、多くの場所でBurstを頻繁に使用する必要がなくなるかもしれません。

2月編集: CoreCLRを担当していたディレクターは意見の相違により辞任しました。皆さん、解散です。

最終編集
hugo-builder
hugo-builder · · AI Translated 2... · 47c675c
他の貢献者
...