DOTS-Stapel: C ++ & C #

Bild

Dies ist eine kurze Einführung in unseren neuen datenorientierten Technologie-Stack ( DOTS ). Wir werden einige Erkenntnisse teilen, um Ihnen zu helfen, zu verstehen, wie und warum Unity heute so geworden ist, und Ihnen auch zu sagen, in welche Richtung wir uns entwickeln wollen. In Zukunft planen wir, neue Artikel im DOTS-Blog im Unity-Blog zu veröffentlichen.

Sprechen wir über C ++. Dies ist die Sprache, in der die moderne Einheit geschrieben ist.
Eines der komplexesten Probleme, mit denen sich ein Spieleentwickler auf die eine oder andere Weise befassen muss, ist Folgendes: Der Programmierer muss eine ausführbare Datei mit Anweisungen bereitstellen, die für den Zielprozessor klar sind, und wenn der Prozessor diese Anweisungen ausführt, sollte das Spiel gestartet werden.

In dem Teil des Codes, der leistungsabhängig ist, wissen wir im Voraus, wie die endgültigen Anweisungen lauten sollten. Wir brauchen nur einen einfachen Weg, der es uns ermöglicht, unsere Logik konsistent zu beschreiben und dann zu überprüfen und sicherzustellen, dass die Anweisungen, die wir benötigen, generiert werden.

Wir glauben, dass die C ++ - Sprache für diese Aufgabe nicht allzu gut ist. Ich möchte beispielsweise, dass meine Schleife vektorisiert wird, aber es kann eine Million Gründe geben, warum der Compiler sie nicht vektorisieren kann. Entweder wird es heute vektorisiert, und morgen nicht, aufgrund einer scheinbar geringfügigen Veränderung. Es ist schwer sicherzustellen, dass alle meine C / C ++ - Compiler meinen Code sogar vektorisieren.

Wir haben uns entschlossen, eine eigene „recht bequeme Methode zum Generieren von Maschinencode“ zu entwickeln, die alle unsere Wünsche erfüllt. Es wäre möglich, viel Zeit zu investieren, um die gesamte Sequenz des C ++ - Entwurfs leicht in die von uns benötigte Richtung zu biegen, aber wir entschieden, dass es viel vernünftiger wäre, unsere Stärke in die Entwicklung einer Werkzeugkette zu investieren, die alle Entwurfsprobleme, mit denen wir konfrontiert sind, vollständig löst. Wir würden es unter Berücksichtigung genau der Aufgaben entwickeln, die der Spieleentwickler lösen muss.

Welche Faktoren priorisieren wir?

  • Leistung = richtig. Ich sollte sagen können: „Wenn diese Schleife aus irgendeinem Grund nicht vektorisiert ist, muss es sich um einen Compilerfehler handeln und nicht um eine Situation aus der Kategorie„ Oh, der Code begann nur achtmal langsamer zu arbeiten, gibt aber immer noch wahre Werte, Geschäft etwas! "
  • Plattformübergreifend. Der Eingabecode, den ich schreibe, sollte unabhängig von der Zielplattform genau gleich bleiben - sei es iOS oder Xbox.
  • Wir sollten eine ordentliche Iterationsschleife haben, in der ich den für jede Architektur generierten Maschinencode leicht sehen kann, wenn ich meinen Quellcode ändere. Der Maschinencode „Viewer“ sollte eine große Hilfe bei der Schulung / Erklärung sein, wenn Sie verstehen müssen, was all diese Maschinenanweisungen bewirken.
  • Sicherheit In der Regel legen Spieleentwickler in ihrer Prioritätenliste keinen hohen Stellenwert für die Sicherheit ein, aber wir glauben, dass eines der coolsten Merkmale von Unity darin besteht, dass es wirklich sehr schwierig ist, das Gedächtnis darin zu beschädigen. Es sollte einen solchen Modus geben, in dem wir Code ausführen - und wir beheben eindeutig einen Fehler, durch den ein großer Buchstabe eine Meldung darüber anzeigt, was hier passiert ist: Zum Beispiel habe ich beim Lesen / Schreiben die Grenzen überschritten oder versucht, Null zu dereferenzieren.

Nachdem wir herausgefunden haben, was für uns wichtig ist, fahren wir mit der nächsten Frage fort: In welcher Sprache ist es besser, Programme zu schreiben, aus denen dann ein solcher Maschinencode generiert wird? Angenommen, wir haben folgende Optionen:

  • Eigene Sprache
  • Einige Anpassungen / Teilmengen von C oder C ++
  • Teilmenge von c #

Was, C #? Für unsere inneren Schleifen, deren Leistung besonders kritisch ist? Ja C # ist eine ganz natürliche Wahl, mit der es im Kontext von Unity viele sehr schöne Dinge gibt:

  • Dies ist die Sprache, mit der unsere Benutzer bereits heute arbeiten.
  • Es verfügt über eine hervorragende IDE, sowohl zum Bearbeiten / Refactoring als auch zum Debuggen.
  • Es gibt bereits einen Compiler, der C # in eine Zwischen-IL konvertiert (wir sprechen über den Roslyn- Compiler für C # von Microsoft), und Sie können ihn einfach verwenden, anstatt Ihren eigenen zu schreiben. Wir haben umfangreiche Erfahrungen mit der Konvertierung einer Zwischensprache in IL. Daher müssen wir nur die Codegenerierung und Nachbearbeitung eines bestimmten Programms durchführen.
  • C # hat keine C ++ - Probleme (Hölle mit Headern, PIMPL-Mustern, langer Kompilierungszeit)

Ich selbst schreibe sehr gerne Code in C #. Traditionelles C # ist jedoch nicht die beste Sprache in Bezug auf die Leistung. Das C # -Entwicklungsteam, die Teams, die in den letzten Jahren für die Standardbibliothek und die Laufzeit verantwortlich waren, haben in diesem Bereich enorme Fortschritte erzielt. Während der Arbeit mit C # ist es jedoch unmöglich, genau zu steuern, wo sich Ihre Daten im Speicher befinden. Und genau dieses Problem müssen wir lösen, um die Produktivität zu steigern.

Darüber hinaus ist die Standardbibliothek dieser Sprache nach "Objekten auf dem Heap" und "Objekten mit Zeigern auf andere Objekte" organisiert.

Gleichzeitig können Sie bei der Arbeit mit einem Codefragment, bei dem die Leistung von entscheidender Bedeutung ist, fast vollständig auf eine Standardbibliothek verzichten (Abschied von Linq, StringFormatter, List, Dictionary), Auswahloperationen (= keine Klassen, nur Strukturen), Reflektion, Deaktivierung von Garbage Collector und Virtual verbieten ruft auf und fügt einige neue Container hinzu, die verwendet werden dürfen (NativeArray und Firma). In diesem Fall sehen die übrigen Elemente der C # -Sprache bereits sehr gut aus. Beispiele finden Sie im Aras-Blog, in dem ein provisorisches Pfad-Tracer-Projekt beschrieben wird.

Eine solche Teilmenge hilft uns dabei, alle Aufgaben zu bewältigen, die für die Arbeit mit heißen Zyklen relevant sind. Da dies eine vollständige Teilmenge von C # ist, können Sie damit wie mit normalem C # arbeiten. Beim Versuch, ins Ausland zu gehen, können Fehler auftreten, wir erhalten hervorragende Fehlermeldungen, wir unterstützen den Debugger und die Kompilierungsgeschwindigkeit ist so hoch, dass Sie sie bei der Arbeit mit C ++ bereits vergessen haben. Wir bezeichnen diese Teilmenge häufig als High Performance C # oder HPC #.

Burst Compiler: Was heute?


Wir haben einen Codegenerator / Compiler namens Burst geschrieben. Es ist in Unity Version 2018.1 und höher als Paket im "Vorschau" -Modus verfügbar. Es bleibt noch viel Arbeit mit ihm zu tun, aber wir freuen uns heute über ihn.

Manchmal schaffen wir es, schneller als in C ++ zu arbeiten, oft - immer noch langsamer als in C ++. Die zweite Kategorie umfasst Leistungsfehler, die wir unserer Überzeugung nach bewältigen können.

Ein einfacher Leistungsvergleich reicht jedoch nicht aus. Nicht weniger wichtig ist, was getan werden muss, um eine solche Leistung zu erzielen. Beispiel: Wir haben den Culling-Code aus unserem aktuellen C ++ - Renderer genommen und nach Burst portiert. Die Leistung hat sich nicht geändert, aber in der C ++ - Version mussten wir einen unglaublichen Spagat machen, um unsere C ++ - Compiler zur Vektorisierung zu bewegen. Die Version mit Burst war etwa viermal kompakter.

Ehrlich gesagt, hat die ganze Geschichte mit "Sie sollten Ihren für die Leistung in C # kritischen Code neu schreiben" auf den ersten Blick niemanden im internen Unity-Team angesprochen. Für die meisten von uns klang es wie "näher an der Hardware!" Bei der Arbeit mit C ++. Aber jetzt hat sich die Situation geändert. Mit C # steuern wir den gesamten Prozess vollständig vom Kompilieren des Quellcodes bis zum Generieren von Maschinencode. Wenn uns keine Details gefallen, nehmen wir sie einfach und korrigieren sie.

Wir werden langsam aber sicher den gesamten leistungskritischen Code von C ++ auf HPC # portieren. In dieser Sprache ist es einfacher, die von uns benötigte Leistung zu erzielen, schwieriger, einen Fehler zu schreiben und einfacher zu arbeiten.

Hier ist ein Screenshot von Burst Inspector, in dem Sie leicht sehen können, welche Montageanweisungen für Ihre verschiedenen Hot Loops generiert wurden:

Bild

Unity hat viele verschiedene Benutzer. Einige von ihnen können den gesamten Satz von arm64-Anweisungen aus dem Speicher abrufen, während andere einfach ohne Begeisterung erstellen, auch ohne einen Doktortitel in Informatik.
Alle Benutzer gewinnen, wenn der Bruchteil der Frame-Zeit, die für die Ausführung von Engine-Code aufgewendet wird, beschleunigt wird (normalerweise 90% +). Der Anteil der Arbeit mit dem ausführbaren Code des Asset Store-Pakets nimmt erheblich zu, da die Autoren des Asset Store-Pakets HPC # übernehmen.

Fortgeschrittene Benutzer profitieren auch von der Tatsache, dass sie ihren eigenen Hochleistungscode auf HPC # schreiben können.

Punktoptimierung


In C ++ ist es sehr schwierig, den Compiler dazu zu bringen, unterschiedliche Kompromissentscheidungen zur Optimierung des Codes in verschiedenen Teilen Ihres Projekts zu treffen. Die detaillierteste Optimierung, auf die Sie zählen können, ist eine dateiweise Angabe des Optimierungsgrades.

Burst ist so konzipiert, dass Sie die einzige Methode dieses Programms als Eingabe akzeptieren können, nämlich: den Einstiegspunkt in die Hot-Loop. Burst kompiliert diese Funktion sowie alles, was sie aufruft (solche aufgerufenen Elemente müssen garantiert im Voraus bekannt sein: Wir erlauben keine virtuellen Funktionen oder Funktionszeiger).

Da Burst nur einen relativ kleinen Teil des Programms ausführt, setzen wir die Optimierungsstufe auf 11. Burst bettet fast jede Anrufstelle ein. Entfernen Sie if-Checks, die sonst nicht gelöscht würden, da wir im eingebetteten Formular umfassendere Informationen zu den Funktionsargumenten erhalten.

Wie hilft es, häufige Threading-Probleme zu lösen?


C ++ (sowie C #) helfen Entwicklern nicht besonders beim Schreiben von thread-sicherem Code.

Selbst heute, mehr als ein Jahrzehnt nachdem ein typischer Spielprozessor mit zwei oder mehr Kernen ausgestattet wurde, ist es sehr schwierig, Programme zu schreiben, die mehrere Kerne effizient nutzen.

Datenrennen, Nichtdeterminismus und Deadlocks sind die Hauptherausforderungen, die das Schreiben von Multithread-Code so schwierig machen. In diesem Zusammenhang benötigen wir Funktionen aus der Kategorie "Stellen Sie sicher, dass diese Funktion und alles, was sie aufruft, niemals den globalen Status lesen oder schreiben". Wir möchten, dass alle Verstöße gegen diese Regel zu Compilerfehlern führen und nicht "Regeln bleiben, an die sich hoffentlich alle Programmierer halten". Burst löst einen Kompilierungsfehler aus.

Wir empfehlen Unity-Benutzern dringend (und wir behalten den gleichen Wert in ihrem Kreis), Code zu schreiben, damit alle darin geplanten Datentransformationen in Aufgaben unterteilt werden. Jede Aufgabe ist „funktional“ und als Nebeneffekt kostenlos. Es gibt explizit schreibgeschützte Puffer und Lese- / Schreibpuffer an, mit denen es arbeiten muss. Jeder Versuch, auf andere Daten zuzugreifen, führt zu einem Kompilierungsfehler.
Der Taskplaner stellt sicher, dass während der Ausführung Ihrer Aufgabe niemand in Ihren schreibgeschützten Puffer schreibt. Und wir garantieren, dass für die Dauer der Aufgabe niemand aus Ihrem Puffer liest, der zum Lesen und Schreiben bestimmt ist.

Wenn Sie eine Aufgabe zuweisen, die gegen diese Regeln verstößt, wird ein Kompilierungsfehler angezeigt. Nicht nur bei einem so unglücklichen Ereignis wie den Bedingungen des Rennens. In der Fehlermeldung wird erläutert, dass Sie versuchen, eine Aufgabe zuzuweisen, die aus Puffer A gelesen werden soll. Zuvor haben Sie jedoch eine Aufgabe zugewiesen, die in A geschrieben wird. Wenn Sie dies wirklich tun möchten, muss die vorherige Aufgabe als Abhängigkeit angegeben werden .

Wir glauben, dass ein solcher Sicherheitsmechanismus dazu beiträgt, viele Fehler zu erkennen, bevor sie behoben werden, und daher die effiziente Verwendung aller Kerne gewährleistet. Es wird unmöglich, Rennbedingungen oder Deadlocks zu provozieren. Die Ergebnisse sind garantiert deterministisch, unabhängig davon, wie viele Threads Sie haben oder wie oft ein Thread aufgrund eines anderen Prozesses unterbrochen wird.

Beherrsche den ganzen Stapel


Wenn wir all diesen Komponenten auf den Grund gehen können, können wir auch sicherstellen, dass sie sich gegenseitig bewusst sind. Ein häufiger Grund für einen Vektorisierungsfehler ist beispielsweise folgender: Der Compiler kann nicht garantieren, dass zwei Zeiger nicht auf denselben Speicherpunkt zeigen (Aliasing). Wir wissen, dass sich zwei NativeArray auf keinen Fall so überschneiden werden, da sie eine Sammlungsbibliothek geschrieben haben, und wir können dieses Wissen in Burst verwenden, sodass wir uns nicht weigern, nur aus Angst zu optimieren, dass zwei Zeiger auf einen gerichtet werden könnten das gleiche Stück Erinnerung.

Ebenso haben wir die Unity.Mathematics- Mathematikbibliothek geschrieben. Burst sie ist "gründlich" bekannt Burst (in der Zukunft) kann in Fällen wie math.sin () darauf hinweisen, dass die Optimierung nicht mehr möglich ist. Da für Burst math.sin () nicht nur eine gewöhnliche C # -Methode ist, die kompiliert werden muss, sondern auch die trigonometrischen Eigenschaften von sin (), wird sin (x) == x für kleine x-Werte verstanden (die Burst unabhängig beweisen kann ), wird verstehen, dass es durch die Erweiterung in der Taylor-Reihe ersetzt werden kann, was teilweise die Genauigkeit beeinträchtigt. In Zukunft plant Burst auch die Implementierung eines plattformübergreifenden und designbezogenen Determinismus mit einem Gleitkomma - wir glauben, dass solche Ziele erreichbar sind.

Die Unterschiede zwischen dem Code der Spiel-Engine und dem Code des Spiels sind verschwommen


Wenn wir Unity-Laufzeitcode in HPC # schreiben, werden die Spiel-Engine und das Spiel als solche alle in derselben Sprache geschrieben. Wir können die Laufzeitsysteme, die wir in HPC # konvertiert haben, als Quellcode verteilen. Jeder kann von ihnen lernen, sie verbessern, sie für sich selbst anpassen. Wir werden ein Spielfeld auf einem bestimmten Niveau haben, und nichts wird unsere Benutzer daran hindern, ein besseres Partikelsystem, eine bessere Spielphysik oder einen besseren Renderer zu schreiben als wir. Indem wir unsere internen Entwicklungsprozesse näher an die Benutzerentwicklungsprozesse heranführen, können wir uns auch in den Schuhen des Benutzers besser fühlen. Daher werden wir alle Anstrengungen unternehmen, um einen einzigen Workflow zu erstellen, anstatt zwei verschiedene.

Source: https://habr.com/ru/post/de444526/


All Articles