O que você está procurando?
Engine & platform

Portando o Unity para o CoreCLR

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Oct 27, 2023|10 Min
Portando o Unity para o CoreCLR
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

Ainda estamos trabalhando duro para levar a mais recente tecnologia .NET aos usuários da Unity. Como um dos membros da equipe que lidera esse esforço, estou animado para compartilhar com os senhores o progresso. Parte do trabalho envolve fazer com que o código Unity existente funcione com o tempo de execução do .NET CoreCLR JIT, incluindo um coletor de lixo (GC) de alto desempenho, mais avançado e mais eficiente.

Esta postagem do blog aborda as alterações recentes que fizemos para permitir que o GC do CoreCLR trabalhe lado a lado com o código nativo do Unity Engine. Começaremos com um nível elevado e depois entraremos em detalhes mais técnicos.

Um pouco sobre coletores de lixo

A alocação de memória feita na linguagem C# é gerenciada por um coletor de lixo. Sempre que a alocação de memória for necessária, o código que aloca essa memória poderá ignorá-la quando ela não for mais usada. O GC virá mais tarde e reciclará essa memória para ser usada por outros códigos.

Atualmente, a Unity usa o GC Boehm, que é um GC conservador e não móvel. Ele examinará todas as pilhas de threads (incluindo código gerenciado e nativo) em busca de objetos gerenciados para coletar e, depois de alocar um objeto gerenciado, o local desse objeto nunca será movido na memória.

O .NET usa o GC CoreCLR, que é um GC preciso e móvel. Ele rastreia os objetos alocados somente no código gerenciado e os moverá na memória para melhorar o desempenho. Isso permite que o GC do CoreCLR trabalhe com muito menos sobrecarga e forneça ao seu jogo melhores características de desempenho.

Ambos os GCs são excelentes no que fazem, mas impõem requisitos diferentes ao código que os utiliza. O mecanismo Unity e o código do editor foram desenvolvidos com base nos requisitos do GC Boehm, portanto, para usar o GC CoreCLR, precisamos fazer várias alterações no código Unity, incluindo as ferramentas de marshaling personalizadas que a Unity escreveu - o gerador de ligações e o gerador de proxy.

Afinal, o que faz um coletor de lixo?

O senhor pode pensar no código gerenciado como uma casa na cidade, onde há uma cafeteria na esquina e um supermercado no final da rua. Vamos chamá-lo de "Managed Code Landia". Para os desenvolvedores, esse é um ótimo lugar para se viver. Mas, às vezes, queremos ir para as "Native Code Wildlands", onde o código C++ pode ser encontrado em seu habitat natural.

Desenho que mostra como um coletor de lixo metafórico gerencia o código entre "Managed Code Landia" e "Native Code Wildlands". Cada elemento está contido em um retângulo vermelho pontilhado e um coletor de lixo é mostrado viajando entre os dois.

Ao viajar entre as duas, o senhor pode levar alguma memória gerenciada, já que a ferrovia marshaling permite uma mala de mão. Em Wildlands, o senhor pode querer pegar uma lembrança e levá-la para casa.

É conveniente que o GC siga obedientemente e recicle qualquer memória que o senhor não esteja mais usando, não importa onde ela esteja. Mas o GC tem muito trabalho a fazer. Todos esses threads e pilhas de chamadas se acumulam rapidamente. Muitas viagens ao Native Code Wildlands depois, e o GC passa a maior parte do tempo perseguindo o senhor.

Podemos trabalhar juntos?

A maior parte do trabalho para portar o mecanismo Unity para o CoreCLR consiste em fazer com que o código do mecanismo funcione com o GC, lado a lado.

Desenho que mostra como um coletor de lixo metafórico gerencia o código entre "Managed Code Landia" e "Native Code Wildlands". Nele, o Managed Code Landia está dentro de um quadrado verde pontilhado e um coletor de lixo é mostrado dentro da caixa verde.

O GC e a marshaling railroad fizeram um acordo para não permitir que nenhuma memória gerenciada atravesse para as Native Code Wildlands. Com isso, o GC tem muito menos trabalho a fazer, o que leva a uma maior eficiência. O GC do CoreCLR opera nesse modo, sabendo exatamente quais objetos existem e lidando apenas com o código gerenciado. Isso também permite que ele mova objetos na memória para aumentar a eficiência.

Como estabelecemos limites?

Diagramas divertidos e emojis são bonitos, mas precisamos implementar de fato em uma base de código de produção que evoluiu por mais de uma década, com milhares de idas e vindas do código gerenciado para o código nativo e vice-versa.

Pensando nisso de uma perspectiva de design de sistemas, precisamos encontrar os limites. A Unity tem dois limites internos importantes:

Chamadas de código gerenciado para código nativo (semelhante ao p/invoke), por meio de uma ferramenta chamada Bindings Generator

Chamadas de código nativo para código gerenciado (semelhante à invocação em tempo de execução do Mono), por meio de uma ferramenta chamada Proxy Generator

Essas duas ferramentas geram código C++ e IL para atuar como uma ferrovia, embaralhando a memória entre nossos dois mundos. No ano passado, os desenvolvedores da Unity modificaram esses dois geradores de código para garantir que eles não permitissem que objetos alocados pelo GC vazassem para além do limite e fornecessem diagnósticos úteis quando isso acontecesse. Também encontramos códigos que tentam atravessar a própria fronteira gerenciada/nativa e, em vez disso, estamos transferindo-os para um desses geradores de código.

É claro que tudo isso está acontecendo enquanto centenas de outros desenvolvedores da Unity estão ativamente alterando o código do mecanismo, oferecendo novos recursos e correções de bugs aos usuários. Queremos modificar o foguete enquanto ele estiver em voo. Para entender melhor como conseguimos fazer essa transição de forma incremental, vamos nos aprofundar em um aspecto desse limite gerenciado/nativo: System.Object.

Um System.Object com qualquer outro nome

Qualquer memória alocada pelo GC no .NET deve estar vinculada a um objeto do tipo System.Object. É a classe base para todos os tipos do .NET, portanto, muitas vezes é o ponto focal da memória que passa para o código nativo. O código C++ do Unity Engine usa a abstração ScriptingObjectPtr para representar um System.Object:

typedef MonoObject* ScriptingBackendNativeObjectPtr;

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

É assim que essa memória gerenciada acaba no código nativo: ScriptingBackendNativeObjectPtr é um ponteiro para a memória alocada por GC. O GC atual da Unity percorre todas as pilhas de chamadas em código nativo, procurando conservadoramente por memória que possa ser um ScriptingObjectPtr. Se pudermos alterar essas instâncias para que não sejam mais ponteiros para a memória alocada pelo GC, poderemos reduzir a carga sobre o GC e, eventualmente, mudar para o GC mais rápido do CoreCLR.

Companhia de três

Em vez de ter apenas uma representação para ScriptingObjectPtr, precisamos que ele tenha uma das três representações possíveis:

Ponteiro alocado pelo GC (a representação atual)

Referência de pilha gerenciada

System.Runtime.InteropServices.GCHandle

O ponteiro alocado pelo GC é uma etapa temporária para remover todos os usos inseguros do GC. Isso permite que o ScriptingObjectPtr continue funcionando como está atualmente. A intenção é remover esse caso de uso quando todo o código do Unity estiver seguro para o GC do CoreCLR.

A referência de pilha gerenciada é uma maneira eficiente de representar uma indireção para um objeto gerenciado no caso em que um valor é passado do gerenciado para o nativo. O endereço de uma variável de ponteiro alocado pelo GC é passado para o código nativo (em vez do próprio ponteiro alocado pelo GC). Isso é seguro para o GC porque o endereço local em si não é movido pelo GC e o objeto gerenciado é mantido vivo em uma pilha de chamadas no código gerenciado. Essa abordagem é inspirada em uma técnica semelhante usada no tempo de execução do CoreCLR.

O GCHandle serve como uma forte indireção para um objeto gerenciado, garantindo que o objeto não seja coletado pelo GC. Se o senhor deixar alguma memória no Managed Code Landia enquanto estiver de férias nas Wildlands, o GC saberá que deseja preservá-la até o seu retorno. Isso é semelhante ao caso de referência da pilha gerenciada, mas requer um gerenciamento explícito do tempo de vida. Há uma sobrecarga adicional devido à construção e destruição de um GCHandle. Essa sobrecarga significa que queremos usar essa representação somente quando for absolutamente necessário.

Isso é implementado usando um novo tipo, ScriptingReferenceWrapper, que substitui o 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;
};

Removi os muitos construtores ou operadores de atribuição aqui - eles são usados para impor o gerenciamento adequado do tempo de vida do recurso interno.

Observe o tamanho desse tipo - ele consiste em apenas um valor uintptr_t, que tem o mesmo tamanho de um ponteiro, o que significa que ScriptingReferenceWrapper tem o mesmo tamanho de ScriptingBackendNativeObjectPtr. Então, podemos fazer uma substituição 1:1 sem código, com o ScriptingObjectPtr sabendo a diferença.

A chave aqui é o requisito de alinhamento de 4 bytes mencionado no comentário do código. A alocação de memória feita na linguagem C# é gerenciada por um coletor de lixo. Com isso em mãos, podemos reutilizar dois bits desse valor para indicar qual das três representações é usada. Os métodos GetGCUnsafePtr e FromRawPtr fornecem interoperabilidade temporária para a representação do ponteiro alocado pelo GC enquanto fazemos a transição do código Unity.

Cruzando a linha de chegada

Em um mundo ideal, a abstração ScriptingObjectPtr seria desnecessária - a memória gerenciada nunca apareceria no código nativo. Mas há lugares em que permitir isso é útil, por isso esperamos concluir o trabalho de segurança do GC no mecanismo, preservando a referência de pilha gerenciada e os casos de GCHandle e removendo totalmente os casos de ponteiro alocado pelo GC.

É aqui que o acordo entre o GC e os geradores de código entra em ação. Agora que os três subsistemas podem entender as possíveis representações de ScriptingObjectPtr, nossa equipe está substituindo os usos no código do mecanismo de forma incremental. Podemos remover o ScriptingObjectPtr onde ele não for necessário e usar a representação mais eficiente onde ele for necessário. Desde que cada uso seja alterado de ponta a ponta, as diferentes representações podem viver lado a lado e o foguete continua a voar.

Com um mecanismo totalmente seguro para GC, podemos ativar o GC do CoreCLR e garantir que ele só precise procurar memória para reciclar no Managed Code Landia, o que significa que ele fará muito menos trabalho e deixará mais tempo em cada quadro para o seu código ser executado.

Para saber mais sobre a transição da Unity para o CoreCLR, visite-nos nos fóruns ou sintonize na Unite 2023, onde falaremos mais sobre o roteiro de produtos da Unity. O senhor também pode se conectar comigo diretamente no X em @petersonjm1. Não deixe de acompanhar os novos blogs técnicos de outros desenvolvedores da Unity como parte da sérieTech from the Trenches.