Unity auf CoreCLR portieren

Wir arbeiten weiterhin hart daran, Unity-Benutzern die neueste .NET-Technologie zur Verfügung zu stellen. Als eines der Teammitglieder, das diese Bemühungen anführt, freue ich mich, Ihnen weitere Fortschritte mitteilen zu können. Ein Teil der Arbeit besteht darin, bestehenden Unity-Code mit der .NET CoreCLR JIT-Laufzeitumgebung zu verbinden, einschließlich eines hochleistungsfähigen, fortschrittlicheren und effizienteren Garbage Collectors (GC).
Dieser Blog-Beitrag behandelt die jüngsten Änderungen, die wir vorgenommen haben, damit die CoreCLR GC Hand in Hand mit dem nativen Code der Unity-Engine arbeiten kann. Wir beginnen mit einem Überblick und gehen dann auf die technischen Details ein.
Die Speicherzuweisung in der Sprache C# wird von einem Garbage Collector verwaltet. Wann immer eine Speicherzuweisung erforderlich ist, kann der Code, der diesen Speicher zuweist, den Speicher ignorieren, wenn er nicht mehr verwendet wird. Der GC wird später hilfreich vorbeikommen und diesen Speicher für anderen Code recyceln, der ihn nutzen kann.
Unity verwendet derzeit den Boehm GC, einen konservativen, nicht-beweglichen GC. Es scannt alle Thread-Stapel (einschließlich verwaltetem und nativem Code) auf der Suche nach verwalteten Objekten, die gesammelt werden sollen, und sobald es ein verwaltetes Objekt zugewiesen hat, wird der Speicherort dieses Objekts nicht mehr verändert.
.NET verwendet die CoreCLR GC, die eine präzise, bewegliche GC ist. Es verfolgt zugewiesene Objekte nur in verwaltetem Code und verschiebt sie im Speicher, um die Leistung zu verbessern. Dadurch kann der CoreCLR GC mit viel weniger Overhead arbeiten und Ihr Spiel mit besseren Leistungsmerkmalen ausstatten.
Beide GCs sind hervorragend in dem, was sie tun, aber sie stellen unterschiedliche Anforderungen an den Code, der sie verwendet. Die Unity-Engine und der Editor-Code wurden auf der Grundlage der Anforderungen der Boehm GC entwickelt. Um die CoreCLR GC zu verwenden, müssen wir also eine Reihe von Änderungen am Unity-Code vornehmen, einschließlich der benutzerdefinierten Marshaling-Tools, die Unity geschrieben hat - den Bindings Generator und den Proxy Generator.
Sie können sich Managed Code wie ein Haus in der Stadt vorstellen, in dem es ein Café um die Ecke und einen Lebensmittelladen die Straße hinunter gibt. Nennen wir es "Managed Code Landia". Für Entwickler ist dies ein großartiger Ort zum Leben. Aber manchmal möchten wir uns in die "Native Code Wildlands" zurückziehen, wo C++ Code in seinem natürlichen Lebensraum zu finden ist.

Wenn Sie zwischen den beiden reisen, können Sie etwas verwalteten Speicher mitnehmen, da die Rangierbahn einen Handgepäckkoffer erlaubt. Drüben in den Wildlands sollten Sie sich vielleicht ein Souvenir mit nach Hause nehmen.
Es ist praktisch, dass der GC pflichtbewusst jedem Speicher, den Sie nicht mehr benötigen, folgt und ihn recycelt, ganz gleich, wo er sich befindet. Aber der GC hat noch eine Menge Arbeit vor sich. All diese Threads und Aufrufstapel summieren sich schnell. Viele Ausflüge in die Native Code Wildlands später, verbringt der GC die meiste Zeit damit, Sie zu jagen.
Der größte Teil der Arbeit bei der Portierung der Unity-Engine auf CoreCLR besteht darin, den Code der Engine mit der GC Hand in Hand arbeiten zu lassen.

GC und die Marshaling Railroad haben sich darauf geeinigt, keine verwalteten Speicher in die Native Code Wildlands übergehen zu lassen. Damit hat der GC viel weniger Arbeit, was zu einer höheren Effizienz führt. Die CoreCLR GC arbeitet in diesem Modus, da sie genau weiß, welche Objekte vorhanden sind und sich nur mit verwaltetem Code befasst. Dies ermöglicht auch das Verschieben von Objekten im Speicher für mehr Effizienz.
Lustige Diagramme und Emoji sind zwar nett, aber wir müssen eine Produktionscodebasis implementieren, die sich über mehr als ein Jahrzehnt entwickelt hat, mit Tausenden von Umwegen von verwaltetem zu nativem Code und zurück.
Wenn wir dies aus der Perspektive des Systemdesigns betrachten, müssen wir die Grenzen finden. Unity hat zwei wichtige interne Grenzen:
Aufrufe von verwaltetem Code zu nativem Code (ähnlich wie p/invoke), durch ein Tool namens Bindings Generator
Aufrufe von nativem Code zu verwaltetem Code (ähnlich wie der Laufzeitaufruf von Mono) über ein Tool namens Proxy Generator
Beide Tools generieren C++- und IL-Code, der wie eine Schiene funktioniert, die den Speicher zwischen unseren beiden Welten hin und her schiebt. Im vergangenen Jahr haben die Entwickler von Unity diese beiden Code-Generatoren modifiziert, um sicherzustellen, dass GC-zugewiesene Objekte nicht über die Grenze hinweg auslaufen können, und um nützliche Diagnosen bereitzustellen, wenn dies doch geschieht. Wir haben auch Code gefunden, der versucht, den Weg über die Grenze zwischen verwaltetem und nativem Code selbst zu wagen, und wir verschieben ihn stattdessen in einen dieser Codegeneratoren.
Natürlich geschieht dies alles, während Hunderte von anderen Entwicklern bei Unity aktiv den Code der Engine ändern und den Benutzern neue Funktionen und Fehlerbehebungen zur Verfügung stellen. Wir wollen die Rakete während des Flugs modifizieren. Um besser zu verstehen, wie es uns gelungen ist, diesen Übergang schrittweise zu vollziehen, lassen Sie uns einen Aspekt dieser verwalteten/nativen Grenze näher beleuchten: System.Object.
Jeder von der GC in .NET zugewiesene Speicher muss an ein Objekt vom Typ System.Object gebunden sein. Sie ist die Basisklasse für alle .NET-Typen und daher oft der Brennpunkt für Speicher, der in nativen Code übergeht. Unity Engine C++ Code verwendet die ScriptingObjectPtr Abstraktion, um ein System.Object zu repräsentieren:
typedef MonoObject* ScriptingBackendNativeObjectPtr;
class ScriptingObjectPtr {
public:
ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
protected:
ScriptingBackendNativeObjectPtr m_Target;
};
Auf diese Weise landet der verwaltete Speicher im nativen Code: ScriptingBackendNativeObjectPtr ist ein Zeiger auf GC-zugewiesenen Speicher. Die aktuelle GC von Unity durchläuft alle Aufrufstapel im nativen Code und sucht konservativ nach Speicher, der ein ScriptingObjectPtr sein könnte. Wenn wir diese Instanzen so ändern können, dass sie keine Zeiger mehr auf den von der GC zugewiesenen Speicher sind, können wir die Belastung der GC verringern und schließlich zur schnelleren CoreCLR GC wechseln.
Anstatt nur eine Darstellung für ScriptingObjectPtr zu haben, muss es eine von drei möglichen Darstellungen haben:
GC-allokierter Zeiger (die aktuelle Darstellung)
Verwaltete Stack-Referenz
System.Runtime.InteropServices.GCHandle
Der von GC zugewiesene Zeiger ist ein vorläufiger Schritt zur Beseitigung aller GC-unsicheren Verwendungen. Damit kann das ScriptingObjectPtr weiterhin wie bisher funktionieren. Es ist beabsichtigt, diesen Anwendungsfall zu entfernen, sobald der gesamte Unity-Code für die CoreCLR GC sicher ist.
Die verwaltete Stack-Referenz ist ein effizienter Weg, um eine Umleitung auf ein verwaltetes Objekt darzustellen, wenn ein Wert von verwaltet zu nativ übergeben wird. Die Adresse einer von GC zugewiesenen Zeigervariablen wird an nativen Code übergeben (und nicht der von GC zugewiesene Zeiger selbst). Dies ist GC-sicher, da die lokale Adresse selbst nicht von der GC verschoben wird und das verwaltete Objekt auf einem Aufrufstapel im verwalteten Code am Leben gehalten wird. Dieser Ansatz ist inspiriert von einer ähnlichen Technik, die in der CoreCLR-Laufzeitumgebung verwendet wird.
Der GCHandle dient als starke indirekte Verbindung zu einem verwalteten Objekt und stellt sicher, dass das Objekt nicht von der GC gesammelt wird. Wenn Sie während Ihres Urlaubs in den Wildlands einen Speicher im Managed Code Landia hinterlassen, weiß der GC, dass Sie ihn bis zu Ihrer Rückkehr aufbewahren möchten. Dies ähnelt dem Fall der verwalteten Stack-Referenz, erfordert aber eine explizite Verwaltung der Lebensdauer. Es entstehen zusätzliche Kosten durch den Aufbau und die Zerstörung eines GCHandles. Dieser Overhead bedeutet, dass wir diese Darstellung nur dort verwenden wollen, wo sie unbedingt erforderlich ist.
Dies wird durch einen neuen Typ, ScriptingReferenceWrapper, implementiert, der ScriptingBackendNativeObjectPtr ersetzt.
struct ScriptingReferenceWrapper
{
// Various constructors elided for brevity
void* GetGCUnsafePtr() const;
static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
// Assumption: all pointers are 8 byte aligned.
// This leaves 2 bits for tracking.
// One bit is already in use by GCHandle
// Bits
// 0 - reserved for GC Handles.
// 1 - 0 - object reference
// - 1 - gc handle
// 2 - 0 - this is a managed object pointer
// - 1 - this is a GCHandle or object reference
// 0b00 - object pointer
// 0b01 - object reference
// 0b1_ - gc handle; lowest bit is implementation specific
bool IsPointer() const {
return (((uintptr_t)value) & 0b11) == 0b00; }
bool IsRef() const {
return (((uintptr_t)value) & 0b11) == 0b01; }
bool IsHandle() const {
return (((uintptr_t)value) & 0b10) == 0b10; }
uintptr_t value;
};
Ich habe hier die vielen Konstruktoren oder Zuweisungsoperatoren entfernt - sie werden verwendet, um eine angemessene Verwaltung der Lebensdauer der internen Ressource zu erzwingen.
Beachten Sie die Größe dieses Typs - er besteht nur aus einem uintptr_t-Wert, der die gleiche Größe wie ein Zeiger hat, d.h. ScriptingReferenceWrapper hat die gleiche Größe wie ScriptingBackendNativeObjectPtr. Dann können wir eine 1:1-Ersetzung ohne Code vornehmen, da ScriptingObjectPtr den Unterschied kennt.
Der Schlüssel dazu ist die im Code-Kommentar erwähnte 4-Byte-Ausrichtung. Die Speicherzuweisung in der Sprache C# wird von einem Garbage Collector verwaltet. Damit können wir zwei Bits dieses Wertes wiederverwenden, um anzugeben, welche der drei Darstellungen verwendet wird. Die Methoden GetGCUnsafePtr und FromRawPtr bieten dann eine vorübergehende Interoperabilität für die von GC zugewiesene Zeigerdarstellung, während wir den Unity-Code umstellen.
In einer idealen Welt wäre die ScriptingObjectPtr-Abstraktion überflüssig - verwalteter Speicher würde nie in nativem Code auftauchen. Es gibt jedoch Stellen, an denen dies sinnvoll ist. Wir erwarten daher, dass wir die GC-Sicherheitsarbeit in der Engine abschließen, verwaltete Stack-Referenzen und GCHandle-Fälle beibehalten und GC-allokierte Zeiger vollständig entfernen.
Hier kommt die Vereinbarung zwischen dem GC und den Codegeneratoren ins Spiel. Da nun alle drei Subsysteme die möglichen Darstellungen von ScriptingObjectPtr verstehen, ersetzt unser Team die Verwendungen im Code der Engine schrittweise. Wir können ScriptingObjectPtr dort entfernen, wo es nicht benötigt wird, und dort, wo es benötigt wird, die effizienteste Darstellung verwenden. Solange jede Verwendung von Ende zu Ende geändert wird, können die verschiedenen Darstellungen alle nebeneinander leben und die Rakete fliegt weiter.
Mit einer vollständig GC-sicheren Engine können wir die CoreCLR GC aktivieren und sicherstellen, dass sie nur in Managed Code Landia nach Speicher zum Recyceln suchen muss, was bedeutet, dass sie viel weniger Arbeit hat und mehr Zeit pro Frame für die Ausführung Ihres Codes bleibt.
Wenn Sie mehr über den Übergang von Unity zu CoreCLR erfahren möchten, besuchen Sie uns in den Foren oder schalten Sie zur Unite 2023 ein, wo wir mehr über die Produkt-Roadmap von Unity sprechen werden. Sie können sich auch direkt mit mir auf X unter @petersonjm1 verbinden. Achten Sie auf neue technische Blogs von anderen Unity-Entwicklern im Rahmen der fortlaufenden SerieTech from the Trenches.