跳至内容

Unity Job-System und Burst für Dummies: Wie setzt man sie richtig ein?

Wenn es nur um Multithreading geht, sind Async für Threadwechsel oder System.Threading die bequemsten und klarsten Methoden. Burst kann auch direkt aufgerufen werden, Jobs sind nicht zwingend erforderlich. Siehe die Artikel Asynchroner Teil und Direkter Aufruf Teil. Erst bei einer großen Anzahl kleiner Berechnungen sollten Sie das Job-System in Betracht ziehen.

Die Unity-Job-Dokumentation ist äußerst unvollständig. Dieser Artikel basiert auf langjährigen Erfahrungen und sollte relativ umfassend sein.

Zweck und Einschränkungen von Jobs

Im Gegensatz zu allgemeinen Multithreading-Ansätzen ist das Unity-Job-System ein System, das durch Multithreading den hohen Durchsatz einer GPU simuliert. Das bedeutet, der Overhead für die Aufgabenverteilung ist extrem gering.

Jobs können nur die Rechenlast eines einzelnen Frames tragen, ähnlich wie kleine Kernel in CUDA/Shader. Schwere Berechnungen können dennoch die FPS beeinträchtigen, da Jobs manchmal dem Hauptthread zugewiesen werden. Wenn Sie langwierige Berechnungen ausführen möchten, müssen Sie diese entweder in viele kleine Jobs aufteilen, ansonsten sind .Net-Threads immer noch die beste Wahl.

Dann stellt sich die Frage: Wenn es ähnlich wie eine GPU ist, warum nicht direkt ComputeShader verwenden? Richtig, alles, was Jobs tun, können ComputeShader auch tun, und zwar bequemer. Außer in folgenden Fällen:

  1. Wenn häufiger Datenaustausch mit der CPU erforderlich ist – was die GPU nicht gut kann –, haben CPU-basierte Jobs einen Vorteil.
  2. Das ECS (DOTS)-System basiert auf Jobs, aus demselben Grund.

Beachten Sie: WebGL unterstützt weder Jobs noch ComputeShader, auch keine Burst-Beschleunigung. ABER:

  • WebGL wird durch WebGPU ersetzt. WebGPU unterstützt ComputeShader und wird von neueren Browsern bereits unterstützt (IOS17 muss es in den Einstellungen aktivieren, 19 Preview ist standardmäßig aktiviert).
  • In den Einstellungen kann WebAssembly 2023 Multithreading-Unterstützung für Jobs aktivieren, und die Browserunterstützung ist umfassender. Offiziell heißt es jedoch, es sei nicht einmal experimentell und nicht verwendbar; Unity6 LTS stürzt ab, wenn es aktiviert wird. Wasm-Multithreading beschäftigt sie seit Jahren, und da DOTS derzeit priorisiert wird und .Net8 Wasm-Multithreading unterstützt, warten sie vielleicht auf Unity7 mit .Net8-Unterstützung.

Leistung

Leistungskennzahlen helfen, das Design und die Anwendbarkeit zu verstehen. Sie können den Artikel auch erst lesen und später zu den Benchmarks zurückkehren.

Getestet wurden 8 Projekte. Zuerst die Leistung von 3 Allokatoren:

  • BenchAllocatorTemp: 100.000 Allokationen mit Allocator.Temp
  • BenchAllocatorTempJob: Dasselbe, mit Allocator.TempJob
  • BenchAllocatorPersistent: Dasselbe, mit Allocator.Persistent Ergebnis: TempJob war am schnellsten, gefolgt von Persistent.

Dann die Leistung von 4 Job-Modi:

  • BenchBaseLine: Einfache Berechnung 100.000 Mal mit For als Referenzbasis
  • BenchIJob: Zeit für das Scheduling von 100.000 Jobs
  • BenchIJobParallelFor: Zeit für das Batch-Scheduling von 100.000 Jobs im Parallelmodus
  • BenchIJobParallelForBurst: Dasselbe, aber mit aktiviertem Burst
  • BenchIJobParallelForBurstLoopVectorization: Scheduling von 10 Jobs, jeder berechnet 10.000 Mal mit For, mit Burst-Vektorisierung

Hier die Ergebnisse auf meinem PC:

|2x

Median ist der Median der Testlaufzeit in Millisekunden.

Man sieht, dass der Scheduling-Overhead von Jobs gering ist, sie sind für viele Aufgaben ausgelegt. Da ich hier nur einfache Multiplikationen durchführte, ist die Verbesserung durch Jobs begrenzt.

Datentypen

Zunächst unterstützt Burst keine C#-Managed-Typen, nur blittable-Typen mit C-ähnlicher Länge, die direkt memcpy-kopierbar sind (kein Marshalling nötig). Dazu gehören Basistypen wie int (char, string und bool sind manchmal managed, nicht verwenden) und eindimensionale C-Style-Arrays von blittable-Typen (new int[5]). Da Jobs typischerweise mit Burst kombiniert werden, gilt diese Einschränkung.

Unity hat dafür den threadsicheren Typ NativeArray eingeführt, speziell für Jobs. Diese Typen können Daten mit dem Hauptthread teilen, ohne Kopien zu benötigen, da beim Übergeben nur der Datenzeiger weitergegeben wird und mehrere Kopien auf denselben Speicherbereich verweisen. Abgeleitete Typen sind NativeList, NativeQueue, NativeHashMap, NativeHashSet, NativeText usw., diese können jedoch nur single-threaded verwendet werden.

Achtung: Code wie nativeArray[0].x = 1.0f oder nativeArray[0]++; funktioniert nicht, der Wert ändert sich nicht, da es sich nicht um eine Referenz handelt.

Threadsicherheit

Threadsicherheit wird durch Scheduling-Beschränkungen erreicht. Dieselbe NativeArray-Instanz kann nur von einem Job beschrieben werden, sonst wird eine Exception geworfen. Wenn Daten parallelisiert werden können, indem sie segmentiert werden, kann IJobParallelFor verwendet werden, um NativeArray in Batches zu verarbeiten. Bei schreibgeschützten Daten kann ein Mitglied wie [ReadOnly] public NativeArray<int> input; deklariert werden.

Während ein Job schreibt, darf der Hauptthread nicht von der NativeArray lesen (Fehler). Es muss auf den Abschluss gewartet werden.

Speicherallokation (allocate)

Native-Typen müssen nach Gebrauch manuell mit Dispose() freigegeben werden, sie werden nicht automatisch zerstört. Unity hat dafür eine Speicherleck-Verfolgung hinzugefügt.

Beim Erstellen von Native-Typen muss zwischen drei Allokatoren gewählt werden: Temp, TempJob, Persistent. Die Allokationsgeschwindigkeit nimmt von schnell zu langsam ab. Temp hat eine Lebensdauer von 1 Frame, TempJob von 4 Frames. Was bedeutet das?

  • Temp ist für die Verwendung innerhalb der aktuellen Funktion gedacht, Dispose() sollte vor Funktionsende aufgerufen werden. Vergisst man Dispose, meldet Unity beim nächsten Rendering sofort einen Fehler. Diese Allokation ist jedoch tatsächlich langsam.
  • TempJob hat lockerere Fehlerbedingungen, eigentlich immer noch für 1 Frame gedacht, aber Dispose kann im nächsten Frame erfolgen.
  • Persistent meldet keine Fehler, man muss selbst aufpassen.

Der vorherige Leistungstest BenchAllocator testete die Leistung dieser drei. Man sieht, dass Allocator.Temp viermal langsamer war als TempJob. Die Dokumentation sagt, Temp sei am schnellsten – entweder ein Bug oder ein Editor-Problem.

Single-Thread-Job ausführen

Der Ablauf: Eine eigene IJob-Klasse schreiben, im Hauptthread Schedule aufrufen, dann Complete aufrufen, um auf den Abschluss zu warten.

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();
}

Das Problem ist: Wir verwenden Jobs für viele Aufgaben, ein einzelner Job bringt nicht viel. Das GPU-Parallelmodell ist nützlicher.

Parallelmodus (Parallel Job)

Der obige Code wird zum Parallelmodus, indem IJob durch Vererbung von IJobParallelFor ersetzt wird.

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();
}

Der Parallelmodus benötigt keine eigene For-Schleife, Execute wird für jedes Element einmal aufgerufen, ähnlich wie bei Shadern.

Schedule(result.Length, result.Length / 10) bedeutet: Führe Execute für jedes Element von 0 bis result.Length aus, verteilt auf 10 Worker.

Zu Leistungsunterschieden zwischen IJob und IJobParallelFor siehe vorherige Benchmarks.

Paralleleinschränkungen

In IJobParallelFor kann nur das Element i beschrieben werden, und es ist nicht bekannt, in welches Member-Array geschrieben wird. Daher können alle Arrays nur das Element i beschreiben. Man kann jedoch [NativeDisableParallelForRestriction] zu NativeArray hinzufügen, um die Sicherheitsprüfung zu deaktivieren, und selbst für schreibkonfliktfreien Zugriff sorgen.

Im Nur-Lese-Modus gibt es für alle Native-Container keine Einschränkungen.

Außerdem kann IJobParallelFor keine Loop-Vektorisierung aktivieren, es sei denn, die Berechnung verwendet bereits Vektorisierung (Aufruf anderer vektorisierter Funktionen). Andernfalls ist die Leistung nicht optimal.

Verwendung von NativeList und anderen Containern im Parallelmodus

Container außer Arrays wie NativeList können im Parallelmodus nur gelesen werden. Wie schreibt man dann? Das Design sieht vor, dass NativeList in Add- und Set-Operationen unterteilt ist. Das korrekte Muster ist: Ein Job führt Add-Operationen aus, ein zweiter Job Set-Operationen.

Für Add kann ParallelWriter und AsParallelWriter verwendet werden:

    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();
    }

In diesem Zustand hat NativeList eine feste Kapazität, der Speicher muss vorab alloziert werden, und es kann nur AddNoResize() verwendet werden. Diese Methode verwendet eine atomare Sperre für die Length-Eigenschaft, was die Leistung erheblich beeinträchtigt.

Dann verwenden Sie die verlustfreie Konvertierung von NativeList zu NativeArray: NativeList.AsDeferredJobArray(). Das zurückgegebene NativeArray ist lazy und wird erst bei Job-Ausführung konvertiert, sodass es vor der Ausführung beider Jobs übergeben werden kann:

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();

Beachten Sie: AsDeferredJobArray oder AsArray geben eine View zurück, also eine Ansicht auf die Originaldaten. Die Quelle (results) muss immer noch Dispose aufgerufen werden.

Parallelmodus für zweidimensionale Arrays

IJobParallelFor kann nur einzelne Elemente eines Arrays parallelisieren. Praktischer ist die Parallelisierung jeder Zeile eines 2D-Arrays, was auch Loop-Vektorisierung ermöglicht und die Leistung steigert. Verwenden Sie dazu IJobParallelForBatch.

Zuerst erstellen wir ein geflatetes 2D-Array [10*15], dann planen wir mit IJobParallelForBatch.Schedule(int length, int batchCount). batchCount gibt an, wie viele Daten jeder Job verarbeitet. Execute wird length/batchCount mal aufgerufen.

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();

Hier die Implementierung von 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;
        }
    }
}

Ausgabe:

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)

Diese Methode kann durch Burst automatisch Loop-Vektorisierung aktivieren. Daher war die Zeit für 100.000 Berechnungen im Benchmark 0.09 ms, die schnellste.

Weitere Einschränkungen

  • Sie können keinen Job innerhalb eines Jobs starten.

Kombination mit Async

Die obigen Beispiele planen Jobs in Update und schließen sie in Late ab, um den Update-Code zu beschleunigen. Für einmalige Aufgaben muss es nicht so umständlich sein. Sie können Async verwenden, um direkt zu warten, ohne das Rendering zu blockieren. Verwenden Sie die Erweiterungsmethode CompleteAsync aus dem Paket:

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

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();

    await handle.CompleteAsync();
}

Beachten Sie: In diesem Modus muss der Persistent-Allokator verwendet werden, da der Job nicht unbedingt innerhalb eines Frames abgeschlossen wird.

Burst

Burst basiert auf LLVM und ist eine als “High-Performance C#” bezeichnete Teilmenge von C#, im Grunde C-Code. Es ist typischerweise 10- bis 100-mal schneller als Mono, was auch zeigt, wie langsam Mono ist.

Burst kann die Ausführungsgeschwindigkeit von Jobs weiter steigern. Für das obige Beispiel reicht diese Zeile:

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

Durch diese Zeile verbesserte sich die Ausführungszeit im IJobParallelFor-Benchmark von 5.16 ms auf 0.21 ms. Damit übertrifft die Job-Geschwindigkeit endlich die For-Schleife.

Hinweis: Die obigen Benchmarks basieren auf 10 Workern. Feineinstellungen der Worker-Anzahl können zu anderen Ergebnissen führen.

Vektorisierung

Vektorisierung packt mehrere Berechnungen in eine Anweisung, z.B. sind float3-Berechnungen von Natur aus vektorisiert. Verwenden