Que recherchez-vous ?
Engine & platform

Portage d'Unity vers CoreCLR

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Oct 27, 2023|10 Min
Portage d'Unity vers CoreCLR
Cette page a été traduite automatiquement pour faciliter votre expérience. Nous ne pouvons pas garantir l'exactitude ou la fiabilité du contenu traduit. Si vous avez des doutes quant à la qualité de cette traduction, reportez-vous à la version anglaise de la page web.

Nous continuons à travailler dur pour apporter la dernière technologie .NET aux utilisateurs d'Unity. En tant que membre de l'équipe à la tête de cet effort, je suis heureux de partager avec vous les progrès accomplis. Une partie du travail consiste à faire fonctionner le code Unity existant avec le moteur d'exécution JIT CoreCLR de .NET, y compris un collecteur d'ordures (GC) très performant, plus avancé et plus efficace.

Ce billet de blog couvre les changements récents que nous avons apportés pour permettre au GC CoreCLR de travailler main dans la main avec le code natif du moteur Unity. Nous commencerons par un niveau élevé, puis nous entrerons dans les détails techniques.

Quelques mots sur les collecteurs d'ordures

L'allocation de mémoire effectuée dans le langage C# est gérée par un ramasse-miettes. Chaque fois qu'une allocation de mémoire est nécessaire, le code qui alloue cette mémoire peut l'ignorer lorsqu'elle n'est plus utilisée. Le GC viendra utilement plus tard recycler cette mémoire pour que d'autres codes puissent l'utiliser.

Unity utilise actuellement la GC de Boehm, qui est une GC conservatrice, sans mouvement. Il parcourt toutes les piles de threads (y compris le code géré et le code natif) à la recherche d'objets gérés à collecter et, une fois qu'il alloue un objet géré, l'emplacement de cet objet ne sera jamais déplacé dans la mémoire.

.NET utilise le GC CoreCLR, qui est un GC précis et mobile. Il suit les objets alloués uniquement dans le code géré et les déplace dans la mémoire pour améliorer les performances. Cela permet au GC CoreCLR de travailler avec beaucoup moins de surcharge et de fournir à votre jeu de meilleures caractéristiques de performance.

Les deux CG sont excellents dans ce qu'ils font, mais ils imposent des exigences différentes au code qui les utilise. Le moteur Unity et le code de l'éditeur ont été développés sur la base des exigences du GC de Boehm. Pour utiliser le GC de CoreCLR, nous devons donc apporter un certain nombre de modifications au code Unity, notamment aux outils de marshaling personnalisés qu'Unity a écrits - le générateur de bindings et le générateur de proxy.

Que fait un ramasse-miettes ?

Vous pouvez considérer le code géré comme une maison en ville, où il y a un café au coin de la rue et une épicerie au bout de la rue. Appelons-le "Managed Code Landia". Pour les promoteurs, c'est un endroit où il fait bon vivre. Mais parfois, nous voulons nous rendre dans les "Native Code Wildlands", où le code C++ se trouve dans son habitat naturel.

Dessin illustrant la manière dont un éboueur métaphorique gère le code entre "Managed Code Landia" et "Native Code Wildlands". Chaque élément est entouré d'un rectangle rouge en pointillés et un éboueur se déplace entre les deux.

Lorsque vous voyagez entre les deux, vous pouvez apporter un peu de mémoire gérée, puisque le chemin de fer de triage autorise une valise à main. Dans les Terres sauvages, vous voudrez peut-être ramasser un souvenir et le ramener à la maison.

Il est pratique que le GC suive consciencieusement et recycle toute mémoire que vous n'utilisez plus, quel que soit l'endroit où elle se trouve. Mais le GC a beaucoup de travail à faire. Tous ces fils et piles d'appels s'additionnent rapidement. Plusieurs voyages dans les terres sauvages du Code Natif plus tard, le GC passe le plus clair de son temps à vous poursuivre.

Pouvons-nous travailler ensemble ?

La majeure partie du travail de portage du moteur Unity vers CoreCLR consiste à faire fonctionner le code du moteur avec le GC, main dans la main.

Dessin illustrant la manière dont un éboueur métaphorique gère le code entre "Managed Code Landia" et "Native Code Wildlands". Dans ce document, Managed Code Landia est entouré d'un carré vert en pointillés et un ramasse-miettes est représenté à l'intérieur de la boîte verte.

La GC et le chemin de fer de triage ont conclu un accord pour qu'aucune mémoire gérée ne pénètre dans les zones sauvages du code indigène. Ainsi, le GC a beaucoup moins de travail à accomplir, ce qui se traduit par une efficacité accrue. Le GC du CoreCLR fonctionne dans ce mode, sachant précisément quels objets existent et ne s'occupant que du code géré. Cela lui permet également de déplacer des objets dans la mémoire pour plus d'efficacité.

Comment fixer des limites ?

Les diagrammes amusants et les emoji sont mignons, mais nous devons réellement mettre en œuvre une base de code de production qui a évolué pendant plus d'une décennie, avec des milliers d'allers-retours entre le code géré et le code natif, et vice-versa.

Si l'on considère cette question du point de vue de la conception des systèmes, il faut trouver les limites. L'Unity a deux limites internes importantes :

Appels d'un code géré vers un code natif (similaire à p/invoke), par l'intermédiaire d'un outil appelé Bindings Generator.

Appels d'un code natif vers un code géré (similaire à l'invocation d'exécution de Mono), par l'intermédiaire d'un outil appelé générateur de mandataires (Proxy Generator).

Ces deux outils génèrent du code C++ et IL qui fait office de chemin de fer, en faisant circuler la mémoire entre nos deux mondes. Depuis un an, les développeurs d'Unity modifient ces deux générateurs de code pour s'assurer qu'ils ne permettent pas aux objets alloués par GC de fuir à travers la frontière, et fournissent des diagnostics utiles lorsque cela se produit. Nous avons également trouvé du code qui tente de franchir lui-même la frontière entre les systèmes gérés et les systèmes natifs, et nous le déplaçons vers l'un de ces générateurs de code.

Bien sûr, tout cela se passe pendant que des centaines d'autres développeurs d'Unity modifient activement le code du moteur, livrant de nouvelles fonctionnalités et des corrections de bugs aux utilisateurs. Nous cherchons à modifier la fusée pendant qu'elle est en vol. Pour mieux comprendre comment nous avons pu effectuer cette transition de manière progressive, nous allons nous pencher sur un aspect de la frontière entre les technologies gérées et les technologies natives : System.Object.

Un System.Object sous n'importe quel autre nom

Toute mémoire allouée par le GC dans .NET doit être liée à un objet de type System.Object. C'est la classe de base de tous les types .NET, et c'est donc souvent le point central de la mémoire qui passe dans le code natif. Le code C++ de Unity Engine utilise l'abstraction ScriptingObjectPtr pour représenter un System.Object :

typedef MonoObject* ScriptingBackendNativeObjectPtr;

class ScriptingObjectPtr {
    public:
        ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
    protected:
        ScriptingBackendNativeObjectPtr m_Target;
};

C'est ainsi que la mémoire gérée se retrouve dans le code natif : ScriptingBackendNativeObjectPtr est un pointeur vers la mémoire allouée par le GC. Le GC actuel d'Unity parcourt toutes les piles d'appels dans le code natif, en recherchant de manière conservatrice la mémoire qui pourrait être un ScriptingObjectPtr. Si nous pouvons modifier ces instances pour qu'elles ne soient plus des pointeurs vers la mémoire allouée par le GC, nous pourrons alors réduire la charge du GC et éventuellement passer au GC CoreCLR, plus rapide.

La compagnie des trois

Au lieu d'avoir une seule représentation pour ScriptingObjectPtr, nous avons besoin qu'il ait l'une des trois représentations possibles :

Pointeur alloué par le GC (la représentation actuelle)

Référence de la pile gérée

System.Runtime.InteropServices.GCHandle

Le pointeur alloué par le GC est une étape temporaire vers la suppression de toutes les utilisations non sécurisées par le GC. Il permet au ScriptingObjectPtr de continuer à fonctionner comme il le fait actuellement. L'intention est de supprimer ce cas d'utilisation une fois que tout le code d'Unity sera sûr pour le GC de CoreCLR.

La référence à la pile gérée est un moyen efficace de représenter une indirection vers un objet géré dans le cas où une valeur est transmise d'un objet géré à un objet natif. L'adresse d'une variable pointeur allouée par le GC est transmise au code natif (plutôt que le pointeur alloué par le GC lui-même). Cette méthode est sûre pour le GC car l'adresse locale elle-même n'est pas déplacée par le GC et l'objet géré est maintenu en vie sur une pile d'appels dans le code géré. Cette approche s'inspire d'une technique similaire utilisée dans le moteur d'exécution CoreCLR.

Le GCHandle sert d'indirection forte à un objet géré, garantissant que l'objet n'est pas collecté par le GC. S'il vous arrive de laisser un souvenir dans le code de gestion Landia pendant vos vacances dans les Terres sauvages, le GC sait que vous voulez le préserver jusqu'à votre retour. Ce cas est similaire au cas de référence de la pile gérée, mais nécessite une gestion explicite de la durée de vie. La construction et la destruction d'un GCHandle entraînent des frais généraux supplémentaires. Cette surcharge signifie que nous ne voulons utiliser cette représentation que lorsqu'elle est absolument nécessaire.

Cette fonction est mise en œuvre à l'aide d'un nouveau type, ScriptingReferenceWrapper, qui remplace ScriptingBackendNativeObjectPtr.

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

J'ai supprimé les nombreux constructeurs ou opérateurs d'affectation ici - ils sont utilisés pour assurer une bonne gestion de la durée de vie de la ressource interne.

Notez la taille de ce type - il consiste en une seule valeur uintptr_t, qui a la même taille qu'un pointeur, ce qui signifie que ScriptingReferenceWrapper a la même taille que ScriptingBackendNativeObjectPtr. Nous pouvons alors effectuer un remplacement 1:1 sans code, ScriptingObjectPtr connaissant la différence.

La clé ici est l'exigence d'alignement de 4 octets mentionnée dans le commentaire du code. L'allocation de mémoire effectuée dans le langage C# est gérée par un collecteur d'ordures. Nous pouvons alors réutiliser deux bits de cette valeur pour indiquer laquelle des trois représentations est utilisée. Les méthodes GetGCUnsafePtr et FromRawPtr fournissent alors une interopérabilité temporaire pour la représentation des pointeurs alloués par le GC pendant que nous effectuons la transition du code Unity.

Franchir la ligne d'arrivée

Dans un monde idéal, l'abstraction ScriptingObjectPtr serait inutile - la mémoire gérée n'apparaîtrait jamais dans le code natif. Mais il y a des endroits où il est utile de permettre cela, donc nous prévoyons d'achever le travail de sécurité du GC dans le moteur, en préservant les cas de référence de pile gérée et de GCHandle et en supprimant entièrement les cas de pointeurs alloués par le GC.

C'est là qu'intervient l'accord entre le GC et les générateurs de code. Maintenant que les trois sous-systèmes peuvent comprendre les représentations possibles de ScriptingObjectPtr, notre équipe remplace progressivement les utilisations dans le code du moteur. Nous pouvons supprimer ScriptingObjectPtr là où il n'est pas nécessaire, et utiliser la représentation la plus efficace là où il l'est. Tant que chaque usage est modifié d'un bout à l'autre, les différentes représentations peuvent cohabiter et la fusée continue de voler.

Avec un moteur GC-safe, nous pouvons activer le GC CoreCLR et nous assurer qu'il n'a besoin de chercher de la mémoire à recycler que dans Managed Code Landia, ce qui signifie qu'il fera beaucoup moins de travail et laissera plus de temps à chaque trame pour l'exécution de votre code.

Pour en savoir plus sur la transition d'Unity vers CoreCLR, rendez-vous sur les forums ou à Unite 2023 où nous parlerons plus en détail de la feuille de route du produit Unity. Vous pouvez également me contacter directement sur X à @petersonjm1. Ne manquez pas les nouveaux blogs techniques d'autres développeurs Unity dans le cadre de la série permanenteTech from the Trenches.