Portar Unity a CoreCLR

Seguimos trabajando arduamente para brindar la última tecnología .NET a los usuarios de Unity . Como miembro del equipo que lidera este esfuerzo, me entusiasma compartir más avances con ustedes. Parte del trabajo implica hacer que el código Unity existente funcione con el entorno de ejecución JIT de .NET CoreCLR, incluido un recolector de elementos no utilizados (GC) de alto rendimiento, más avanzado y más eficiente.
Esta publicación de blog cubre los cambios recientes que hemos realizado para permitir que CoreCLR GC trabaje en conjunto con el código nativo del motor Unity . Comenzaremos desde un nivel alto y luego entraremos en detalles más técnicos.
La asignación de memoria realizada en el lenguaje C# es administrada por un recolector de basura. Cada vez que se requiere asignación de memoria, el código que asigna esa memoria puede ignorarla cuando ya no se utiliza. El GC vendrá más tarde y reciclará esa memoria para que la use otro código.
Actualmente, Unity utiliza el GC Boehm, que es un GC conservador y sin movimiento. Escaneará todas las pilas de subprocesos (incluido el código administrado y nativo) en busca de objetos administrados para recopilar y, una vez que asigne un objeto administrado, la ubicación de ese objeto nunca se moverá en la memoria.
.NET utiliza CoreCLR GC, que es un GC preciso y móvil. Realiza un seguimiento de los objetos asignados solo en el código administrado y los moverá en la memoria para mejorar el rendimiento. Esto permite que CoreCLR GC trabaje con mucha menos sobrecarga y proporcione a su juego mejores características de rendimiento.
Ambos GC son excelentes en lo que hacen, pero imponen requisitos diferentes al código que los utiliza. El motor de Unity y el código del editor se han desarrollado en base a los requisitos de Boehm GC, por lo que para usar CoreCLR GC, debemos realizar una serie de cambios en el código de Unity , incluidas las herramientas de serialización personalizadas que Unity escribió: el generador de enlaces y el generador de proxy.
Puedes pensar en el código administrado como un hogar en la ciudad, donde hay una cafetería a la vuelta de la esquina y una tienda de comestibles en la calle. Lo llamaremos “Código Gestionado Landia”. Para los desarrolladores, este es un gran lugar para vivir. Pero a veces queremos alejarnos a las “tierras salvajes del código nativo”, donde se puede encontrar el código C++ en su hábitat natural.

Al viajar entre los dos, puedes llevar algo de memoria, ya que el ferrocarril permite llevar una maleta de mano. En las Tierras Salvajes, es posible que quieras comprar un recuerdo y llevártelo a casa.
Es conveniente que el GC siga atentamente y recicle cualquier memoria que ya no estés usando, sin importar dónde esté. Pero el GC tiene mucho trabajo por hacer. Todos esos hilos y pilas de llamadas se acumulan rápidamente. Después de muchos viajes a Native Code Wildlands, el GC pasa la mayor parte del tiempo persiguiéndote.
La mayor parte del trabajo para portar el motor Unity a CoreCLR consiste en hacer que el código del motor funcione con el GC, de la mano.

El GC y el ferrocarril de maniobras han llegado a un acuerdo para no permitir que ninguna memoria administrada cruce hacia las Tierras Silvestres del Código Nativo. Con eso en su lugar, el GC tiene mucho menos trabajo que hacer, lo que conduce a una mayor eficiencia. El GC CoreCLR opera en este modo, sabiendo con precisión qué objetos existen y tratando únicamente con código administrado. Esto también le permite mover objetos en la memoria para lograr mayor eficiencia.
Los diagramas divertidos y los emojis son simpáticos, pero debemos implementarlos en una base de código de producción que ha evolucionado durante más de una década, con miles de viajes de ida y vuelta desde código administrado a código nativo y viceversa.
Pensando en esto desde una perspectiva de diseño de sistemas, necesitamos encontrar los límites. La Unity tiene dos límites internos importantes:
Llamadas desde código administrado a código nativo (similar a p/invoke), a través de una herramienta llamada Generador de enlaces
Llamadas desde código nativo a código administrado (similar a la invocación en tiempo de ejecuciónde Mono), a través de una herramienta llamada Proxy Generator
Ambas herramientas generan código C++ e IL para actuar como un ferrocarril, moviendo la memoria entre nuestros dos mundos. Durante el año pasado, los desarrolladores de Unity han estado modificando estos dos generadores de código para garantizar que no permitan que los objetos asignados por GC se filtren a través del límite y proporcionar diagnósticos útiles cuando eso sucede. También hemos estado encontrando código que intenta afrontar el viaje a través del límite entre lo administrado y lo nativo, y lo estamos moviendo a uno de estos generadores de código.
Por supuesto, todo esto sucede mientras cientos de otros desarrolladores en Unity están cambiando activamente el código del motor, entregando nuevas características y correcciones de errores a los usuarios. Estamos buscando modificar el cohete mientras está en vuelo. Para entender mejor cómo hemos podido hacer esta transición de manera incremental, profundicemos en un aspecto de este límite entre lo administrado y lo nativo: Sistema.Objeto.
Cualquier memoria asignada por el GC en .NET debe estar vinculada a un objeto de tipo System.Object. Es la clase base para todos los tipos .NET, por lo que a menudo es el punto focal de la memoria que pasa al código nativo. El código C++ de Unity Engine utiliza la abstracción ScriptingObjectPtr para representar un System.Object:
typedef MonoObject* ScriptingBackendNativeObjectPtr;
class ScriptingObjectPtr {
public:
ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
protected:
ScriptingBackendNativeObjectPtr m_Target;
};
Así es como esa memoria administrada termina en el código nativo: ScriptingBackendNativeObjectPtr es un puntero a la memoria asignada GC. El GC actual de Unity recorre todas las pilas de llamadas en el código nativo, buscando de manera conservadora memoria que podría ser un ScriptingObjectPtr. Si podemos cambiar esas instancias para que ya no sean punteros a la memoria asignada GC, entonces podemos reducir la carga en el GC y eventualmente cambiar al GC CoreCLR más rápido.
En lugar de tener solo una representación para ScriptingObjectPtr, necesitamos que tenga una de tres representaciones posibles:
Puntero asignado por GC(la representación actual)
Referencia de pila administrada
System.Runtime.InteropServices.GCHandle
El puntero asignado por GC es un paso temporal hacia la eliminación de todos los usos no seguros de GC. Permite que ScriptingObjectPtr continúe funcionando como lo hace actualmente. La intención es eliminar este caso de uso una vez que todo el código de Unity sea seguro para CoreCLR GC.
La referencia de pila administrada es una forma eficiente de representar una indirección a un objeto administrado en el caso en que un valor se pasa de administrado a nativo. La dirección de una variable de puntero asignada por GC se pasa al código nativo (en lugar del puntero asignado GC en sí). Esto es seguro para GC porque la dirección local en sí no es movida por el GC y el objeto administrado se mantiene activo en una pila de llamadas en el código administrado. Este enfoque está inspirado en una técnica similar utilizada dentro del entorno de ejecución de CoreCLR.
GCHandle sirve como una fuerte indirección a un objeto administrado, garantizando que el GC no recopile el objeto. Si por casualidad dejas algún recuerdo en Managed Code Landia mientras estás de vacaciones en Wildlands, el GC sabe que querrás conservarlo hasta que regreses. Esto es similar al caso de referencia de pila administrada, pero requiere una gestión explícita del tiempo de vida. Existe una sobrecarga adicional debido a la construcción y destrucción de un GCHandle. Esta sobrecarga significa que queremos usar esta representación solo cuando sea absolutamente necesaria.
Esto se implementa utilizando un nuevo tipo, ScriptingReferenceWrapper, que reemplaza a 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;
};
He eliminado muchos constructores u operadores de asignación aquí: se utilizan para imponer una gestión adecuada de la vida útil del recurso interno.
Tenga en cuenta el tamaño de este tipo: consta de un solo valor uintptr_t, que tiene el mismo tamaño que un puntero, lo que significa que ScriptingReferenceWrapper tiene el mismo tamaño que ScriptingBackendNativeObjectPtr. Luego, podemos hacer un reemplazo 1:1 sin código, con ScriptingObjectPtr sabiendo la diferencia.
La clave aquí es el requisito de alineación de 4 bytes mencionado en el comentario del código. La asignación de memoria realizada en el lenguaje C# es administrada por un recolector de basura. Con esto en su lugar, podemos reutilizar dos bits de ese valor para indicar cuál de las tres representaciones se utiliza. Los métodos GetGCUnsafePtr y FromRawPtr luego proporcionan interoperabilidad temporal para la representación del puntero asignado por GC mientras realizamos la transición del código Unity .
En un mundo ideal, la abstracción ScriptingObjectPtr sería innecesaria: la memoria administrada nunca aparecería en el código nativo. Pero hay lugares donde permitir esto es útil, por lo que esperamos completar el trabajo de seguridad de GC en el motor, preservando la referencia de pila administrada y los casos de GCHandle y eliminando por completo los casos de puntero asignados GC.
Aquí es donde entra en juego el acuerdo entre el GC y los generadores de código. Ahora que los tres subsistemas pueden comprender las posibles representaciones de ScriptingObjectPtr, nuestro equipo está reemplazando los usos en el código del motor de forma incremental. Podemos eliminar ScriptingObjectPtr donde no sea necesario y utilizar la representación más eficiente donde sí lo sea. Mientras cada uso se modifique de principio a fin, las diferentes representaciones pueden coexistir y el cohete continúa volando.
Con un motor totalmente seguro para GC, podemos habilitar el GC CoreCLR y garantizar que solo necesite buscar memoria para reciclar en Managed Code Landia, lo que significa que hará mucho menos trabajo y dejará más tiempo en cada cuadro para que se ejecute su código.
Para obtener más información sobre la transición de Unity a CoreCLR, visítenos en los foros o sintonice Unite 2023, donde hablaremos más sobre la hoja de ruta de productos de Unity. También puedes conectarte conmigo directamente en X en @petersonjm1. Asegúrese de estar atento a los nuevos blogs técnicos de otros desarrolladores de Unity como parte del trabajo en curso.SerieTecnología de las Trincheras .