DEV Community

Angelo Belchior
Angelo Belchior

Posted on • Updated on

.NET 8, JIT e AOT

Habemus .NET 8.

Eu acredito que esse seja o lançamento mais importante da história do .NET. Aliás eu falei a mesma coisa quando lançaram as versões 5, 6 e 7. Vejam só como as coisas estão evoluindo de maneira fantástica!

Porém, acredito que esse seja, de fato, o maior e mais importante lançamento da história. Até agora, pelo menos...

Afirmo isso porque ele trouxe toneladas de atualizações que vão impactar pontos como performance e estabilidade além de centenas de novas features que vão facilitar muito o desenvolvimento de aplicações Cloud Native (serverless, containers etc.), além de aplicações Desktop, Mobile, IA etc.

Aliás, eu escrevi um post falando sobre uma nova feature referente ao async/await lançada no .NET 8, você pode ler aqui.

Um dos pontos que mais me chama a atenção, e não é de hoje, é o suporte a compilação AOT - Ahead of Time. A Microsoft vem trabalhando nisso há um bom tempo e chegamos agora num ponto bem avançado. Ainda não temos um suporte que beira o estado da arte, mas chegamos num momento em que já é possível vislumbrar um futuro glorioso onde esse tipo de compilação se tornará recorrente para a maioria das aplicações.

Imagem gerada pelo Bing Chat

Acredito que você deva saber que o .NET foi criado com conceitos de máquina virtual, linguagem intermediária (IL), compilação JIT, tudo isso lá no comecinho dos anos 2000. (é curioso saber que tem gente que nasceu depois disso... Tô ficando velho... faz parte... pelo menos eu vi as copas de 1994 e 2002... saudades...)

Pois bem! Praticamente duas décadas depois, a Microsoft começa a investir pesado para que o .NET também suporte o tipo de compilação AOT.

Mas qual é a real necessidade disso? Quais são os impactos de uma mudança tão grande como essa?
Bom, se você já sabe a diferença entre esses dois tipos de compilação, já tem essas respostas. Caso contrário, explicá-lo-ei.


Antes de continuar, #VemCodar com a gente!!

Tá afim de criar APIs robustas com .NET?

Feito de dev para dev, direto das trincheiras, por quem coloca software em produção há mais de 20 anos, o curso "APIs robustas com ASP.NET Core 8, Entity Framework, Docker e Redis" traz de maneira direta, sem enrolação, tudo que você precisa saber para construir suas APIs de tal forma que elas sejam seguras, performáticas, escaláveis e, acima de tudo, dentro de um ambiente de desenvolvimento totalmente configurado utilizando Docker e Docker Compose.

Não fique de fora! Dê um Up na sua carreira!

O treinamento acontecerá nos dias 27/02, 28/02 e 29/02, online, das 19.30hs às 22.30hs

Acesse: https://vemcodar.com.br/


JIT: Just-In-Time Compilation

Vou dar um overview sobre JIT no .NET. Esse processo é muito maior do que o descrito abaixo. Caso queira se aprofundar nesse assunto leia The Book of the Runtime.

O Just-In-Time Compilation (compilação sob demanda numa tradução livre e googleana) é um mecanismo de compilação usado pelo Common Language Runtime (CLR) do .NET. Diferentemente de outras linguagens que compilam o código-fonte para código de máquina antes de sua execução (compilação estática - AOT), o .NET adota uma abordagem de compilação dinâmica. Isso significa que o código-fonte é compilado para código intermediário chamado Common Intermediate Language (CIL), e a tradução final para código de máquina ocorre somente quando o programa é executado.

Existiam vários motivos para que esse tipo de compilação fosse adotado pela Microsoft quando criaram o .NET.
Imaginem que é necessário ter uma compilação para cada tipo de processador, afinal cada um deles tem um conjunto de operações que variam entre as versões e fabricantes. Nessa época tínhamos processadores de 32bit e estávamos começando a entrar na era dos processadores 64bit. Além disso fabricantes como Intel e AMD disputavam (e ainda disputam) as vendas.

Sendo assim, uma das vantagens era a portabilidade: O código intermediário (CIL) é independente da arquitetura de processador, permitindo a execução em qualquer plataforma compatível com o .NET (naquela época, que era conhecida como trevas, só rodava no Windows... mas isso mudou, e agora .NET roda em qualquer coisa, até na sua AirFryer).

O processo de compilação JIT pode realizar otimizações específicas para a máquina onde o sistema está sendo executado, adaptando o código para melhor aproveitar as características do hardware. Na teoria, seu software vai ficando mais rápido conforme vai sendo executado. Eu disse na teoria afinal, não existe milagre que salve software mal escrito. E se você quiser aprender como criar software bem feito, #VemCodar com a gente!

Outra coisa interessante e quem tem alguma relação com a portabilidade, é que, caso o sistema operacional atualize, não é necessário a recompilação da aplicação. O runtime do .NET é atualizado e isso garante que a sua aplicação vai rodar sem a necessidade de alguma manutenção. Ok, antigamente era meio problemático e sempre as vezes precisava reiniciar o computador e vez ou outra registrar umas bibliotecas no finado GAC (nem vou colocar link de referência pois não vale a pena). Hoje melhorou infinitamente isso.

E como é o processo de compilação JIT?

Ao compilar a aplicação, é gerado o CIL. Quando a aplicação é executada o JIT converte o CIL em código de máquina. Essa conversão é baseada no ambiente de execução e o JIT compila o código sob demanda, em vez de compilá-lo como um todo. E isso vai se repetindo conforme o software é utilizado.

É possível fazer esse processo uma vez só. Existe uma forma de, ao abrir a aplicação pela primeira vez, fazer com que o JIT execute a compilação do sistema inteiro e armazene o resultado em cache local na máquina. A partir daí a execução da aplicação é mais rápida e seu desempenho logo de cara é melhor. Confesso que nunca utilizei essa abordagem, porém recomendo que leia a documentação sobre o Ngen.exe (Native Image Generator) aqui.

Durante a compilação, são aplicadas otimizações dinâmicas, como inlining de métodos, remoção de código não utilizado e otimização de loops, para melhorar ainda mais o desempenho do código.

É possível ter três tipos de abordagens de compilação JIT.

  • Pre-JIT Compiler: Todo o código-fonte é compilado para código de máquina ao mesmo tempo em um único ciclo de compilação. Este processo de compilação é executado no momento da primeira execução do aplicativo e pode levar bastante tempo para ser finalizado. Essa abordagem usa a ferramenta Ngen.exe (Native Image Generator). Acima eu citei esse processo.
  • Normal JIT Compiler: O código é compilado conforme necessário durante a execução, equilibrando o tempo de inicialização e a otimização de desempenho. Essa é uma das abordagens mais usadas.
  • EconoJIT Compiler: Introduzido no .NET Core, o EconoJIT prioriza a inicialização rápida em detrimento da otimização de desempenho, sendo adequado para cargas de trabalho leves.

Reforço aqui que esse é um overview sobre JIT. Recomendo muito, mas muito mesmo a leitura do livro The Book of the Runtime. Lá você vai entrar em contato com as entranhas do .NET. É maravilho!

Vamos seguindo...

AOT: Compilação Ahead-of-Time

Novamente reforço: Esse é apenas um overview sobre compilação AOT. Existe um universo de informações sobre esse tema na internet.

Ao contrário da compilação JIT, onde o código é compilado durante a execução do programa, a compilação AOT realiza a tradução do código-fonte para linguagem de máquina antes mesmo de ser executado, daí o nome Ahead of Time (ou compilação antecipada numa tradução livre). Essa abordagem proporciona benefícios significativos, como uma inicialização muito mais rápida do programa e uma menor sobrecarga de processamento durante sua execução.

É preciso destacar um ponto importante aqui: como a compilação é feita antes da execução, é necessário saber qual será o processador utilizado, pois cada um tem suas características.

Provavelmente você já se deparou num cenário onde foi efetuar um download de uma aplicação e precisou escolher qual o tipo de processador, Intel, AMD ou ARM e sua arquitetura, 32bit ou 64bit.

Printscreen da tela de download do Firefox onde é destacado os tipos de downloads oferecidos. Temos Windows 64, 32 e ARM, macOS e Linux 32 e 64 bits

Como exemplo, mostro o site do Firefox. Na imagem acima podemos notar que, para efetuar o download, precisamos selecionar qual é o nosso ambiente de execução. Note que temos três ambientes para Windows - 32bit, 64Bit e ARM64 - e dois ambientes para Linux - 32bit e 64Bit. No caso, estou usando macOS e o site já reconheceu meu ambiente.

Para cada um deles temos uma versão compilada da aplicação. E é aqui que a brincadeira começa a ficar séria... explico mais abaixo...

Processo de Compilação AOT

Como dito acima, a compilação Ahead-of-Time é basicamente um processo no qual o código-fonte é traduzido para código de máquina antes da execução do programa.

Podemos ter uma fase de pré-processamento, onde são feitas manipulações no código antes da compilação. Isso pode incluir a substituição de macros, inclusão de cabeçalhos ou outras modificações necessárias. Isso varia de linguagem para linguagem. Vou descrever alguns pontos aqui baseado no processo adotado pelo .NET.

O processo inicia com o código-fonte sendo compilado para gerar código intermediário (CIL). Esse processo em outras linguagens pode não existir, porém no .NET o CIL é traduzido para código de máquina específico da arquitetura do processador. Isso ocorre antes da execução do programa, ao contrário da compilação JIT, que compila o código intermediário durante a execução.

O resultado dessa compilação é geralmente um executável ou um arquivo de biblioteca compartilhada, dependendo do tipo de aplicação. Esse arquivo contém o código de máquina diretamente, eliminando a necessidade de uma etapa de compilação durante sua execução.

Lembrando que, pode ser que seja necessário criar mais de uma versão do binário para atender as diversas arquiteturas de processadores, como Intel, AMD e ARM por exemplo.

E esse é um ponto que podemos ter problemas, afinal, cada processador tem suas características, e pode ser que um determinado trecho de código funcione em um processador, mas não no outro. Existem inúmeras bibliotecas que fazem esse tipo de tratamento utilizando diretivas de compilação para saber qual ambiente está sendo executado e "injetar" o código apropriado.

Abaixo segue um exemplo simples em C++. Já adianto que eu não sou nenhum expert em C++, apenas me esforcei em criar um código...

#include <iostream>

int main() {
    // Verificar se o sistema operacional é 32 bits ou 64 bits
    #if defined(__x86_64__) || defined(_M_X64)
        // Código para sistemas operacionais de 64 bits
        // Exemplo de uso de recurso de 64 bits  
        long  long  int numero64Bits = 9223372036854775807LL; // Maior valor positivo para long long int 
        std::cout << "Exemplo de número de 64 bits: " << numero64Bits << "\n";
    #else
        // Código para sistemas operacionais de 32 bits
        long  long  int vaiDarRuim = 9223372036854775807LL; // Em SO de 32 bits, o compilador não reconhece o tipo long long int
        std::cout << "Essa linha nunca será executada :( \n";
    #endif

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Umas das principais diferenças entre um processador 32bit e um 64bit é a capacidade de armazenamento de uma variável numérica do tipo inteiro, por exemplo. No código acima, é possível notar que é necessário checar qual é a arquitetura do processador para poder utilizar a melhor abordagem de código.

Em .NET muitas coisas desse tipo já foram resolvidas. Ufa!

Quero fazer aqui uma menção honrosa. Quando estava falando sobre JIT disse que o .NET não foi criado a princípio para ter suporte a compilação AOT. Porém, em um determinado momento o projeto Mono - que levava o .NET a ambientes não Windows, como macOS, Linux, Android e iOS), liderado pelo lendário Miguel de Icaza adicionou suporte a compilação AOT. Você pode ver a documentação nesse link: AOT | Mono (mono-project.com)

É importante destacar que uma das principais vantagens do AOT em relação ao JIT é que o tempo de inicialização da aplicação é potencialmente mais rápido com um consumo de memória mais baixo. Isso se deve ao fato de que não é necessário ter nenhum tipo de processo antes da execução inicial da aplicação.

Segue abaixo uma versão visual do fluxo de compilação JIT e AOT para .NET.

JIT

1. Código Fonte .NET  
          |  
          | (Compilação para CIL)  
          V  
2. CIL   
          |  
  +-----> | (Compilação JIT em tempo de execução) 
  |       V 
3.| Código de Máquina (Específico por plataforma)
  |       |
  +-------+ (Execução)
          V
Enter fullscreen mode Exit fullscreen mode

AOT

1. Código Fonte .NET  
          |  
          | (Compilação para CIL)  
          V  
2. CIL  
          |  
          | (Compilação do AOT antes da execução)  
          V  
3. Código de Máquina (Específico por plataforma)  
          |  
          | (Execução)  
          V
Enter fullscreen mode Exit fullscreen mode

Essa visualização foi retirada desse post. Fiz alguns ajustes para melhorar o entendimento. Aliás, baita post, diga-se de passagem! Inclusive seguindo essa linha, recomendo um outro post sobre como a compilação do C++ funciona: How C++ Works: Understanding Compilation. Boa leitura!

Humm... tempo de inicialização menor e um consumo de memória mais baixo... Então AOT é melhor que JIT certo?

Pega um café... eu espero...

Certo, vamos refletir um pouco.

A compilação AOT pode adicionar complexidade ao processo de desenvolvimento, ja que podemos ter trechos de código que podem não funcionar em determinados processadores. Esse tipo de compilação pode ser desafiadora em ambientes que suportam várias arquiteturas ou plataformas, pois os binários precisam ser gerados para cada uma delas.

Na teoria não deveremos ter problemas desse tipo nas nossas aplicações .NET. A não ser que exista a necessidade de se criar bibliotecas de baixo nível para fins específicos.

Além disso como a compilação AOT ocorre antes do tempo de execução, pode haver menos flexibilidade para otimizações dinâmicas ou ajustes finos com base no ambiente de execução real. O time do .NET tem trabalhado muito nessa frente para gerar o código mais otimizado possível.

Outro ponto que o time está trabalhando é na diminuição do tempo de compilação. AOT geralmente leva mais tempo em comparação com a compilação JIT, pois toda a otimização é feita antecipadamente.

Os binários resultantes da compilação AOT podem ser maiores em comparação com os equivalentes JIT. Isso ocorre porque essa compilação geralmente incorpora todas as dependências necessárias ao binário, o que pode aumentar o tamanho final do aplicativo. Para reduzir esse tamanho temos uma ferramenta chamada Linker que remove códigos não utilizados da sua aplicação no processo de compilação.

Geração de código dinâmico via CIL ou uso de reflection é uma baita pedra no sapato do AOT. E o motivo é simples: O JIT vai compilando o software em tempo de execução e caso exista algum tipo de código dinâmico, acaba sendo transparente o processo de compilação e execução desse código já que o JIT faz isso a toda hora, sendo assim é algo mais do que natural.

Para o AOT esse processo se torna praticamente impossível. Isso se deve ao fato de que o código gerado dinamicamente é um código intermediário (CIL) e não código final de máquina. Além disso, uma vez que o processo de compilação ocorre antes da execução do programa, não existe a possibilidade de se efetuar uma nova compilação em tempo de execução.

É por isso que a Microsoft tem trabalhado muito no conceito de Source Generator - geração de código em tempo de escrita de código. Eu sei, é confuso, inclusive eu estou escrevendo um post sobre isso.
Mas em resumo, quando você está escrevendo um código csharp um processo de pré compilação ocorre a cada palavra escrita.

Tanto é que, caso você escreva algo que não faça parte da sintaxe da linguagem, recebe um erro, ou ainda pode receber uma dica marota do compilador para melhorar o trecho de código escrito.

É possível interagir com esse processo de pré compilação. E é isso que os Source Generators fazem, eles avaliam o que foi escrito e adicionam código.

Source Generators

A imagem acima descreve como os Source Generators são executados durante a fase de compilação.

Imagine que seja possível a substituição de um processo que utiliza reflection por um Source Generator que avalia o código e gera um novo código que faça a mesma coisa, porém sem necessidade de manipulação de código dinâmico.

Esse é o segredo do universo. Quanto mais a Microsoft avançar criando Source Generators para substituir processos que utilizam código dinâmico via CIL ou reflection, mais completo será o suporte a compilação AOT.

Para saber mais sobre as limitações do .NET 8 relacionadas a código dinâmico leia essa documentação.


Esse post é específico para falar sobre .NET8, JIT e AOT, porém, senti que era necessário criar um conteúdo explicando os Source Generators:https://dev.to/angelobelchior/net-source-generators-gerando-codigo-em-tempo-de-escrita-de-codigo-10h6.

Modéstia à parte, ficou bem completo. Recomendo muito a leitura.


Certo! E que tal agora a gente compilar uma aplicação Hello World via AOT?

Antes de tudo, precisamos se ater aos pré-requisitos. Acesse esse link e veja se está tudo ok com a sua máquina. Talvez seja necessário a instalação de alguma biblioteca ou ferramenta específica, principalmente se você está num ambiente unix.

Pra começar, abra o terminal e execute o seguinte comando para criar nossa aplicação:

dotnet new console -o AOT
Enter fullscreen mode Exit fullscreen mode

Com a aplicação criada, precisamos fazer uma alteração no arquivo AOT.csproj, informando que sua compilação é via AOT. Adicione as seguintes linhas:

<PropertyGroup>
    <PublishAot>true</PublishAot>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Feito isso, vamos gerar um executável da nossa aplicação no modo Release:

dotnet publish -r osx-arm64 -c Release
Enter fullscreen mode Exit fullscreen mode

Note que, osx-arm64 é o que chamamos de RID (runtime identifier), osx se refere ao sistema operacional e arm64 a arquitetura do processador.

Para saber todos os RID suportados pelo .NET, acesse esse link.

A saída da compilação é essa:

Versão do MSBuild 17.8.3+195e7f5a3 para .NET
Determinando os projetos a serem restaurados...
/Volumes/SSD/Git/AOTxJIT/AOT/AOT.csproj restaurado (em 4,76 sec).
AOT -> /Volumes/SSD/Git/AOTxJIT/AOT/bin/Release/net8.0/osx-x64/AOT.dll
Generating native code
AOT -> /Volumes/SSD/Git/AOTxJIT/AOT/bin/Release/net8.0/osx-x64/publish/
**** **AOT** dotnet publish -r osx-arm64 -c Release
Versão do MSBuild 17.8.3+195e7f5a3 para .NET
Determinando os projetos a serem restaurados...
/Volumes/SSD/Git/AOTxJIT/AOT/AOT.csproj restaurado (em 2,38 sec).
AOT -> /Volumes/SSD/Git/AOTxJIT/AOT/bin/Release/net8.0/osx-arm64/AOT.dll
Generating native code
AOT -> /Volumes/SSD/Git/AOTxJIT/AOT/bin/Release/net8.0/osx-arm64/publish/
Enter fullscreen mode Exit fullscreen mode

Notaram a linha Generating native code? Foi nesse momento que a nossa aplicação foi compilada para código nativo (código de máquina).

E para executar nossa aplicação...

/bin/Release/net8.0/osx-arm64/publish/AOT
Enter fullscreen mode Exit fullscreen mode

E a saída...

Hello, World!
Enter fullscreen mode Exit fullscreen mode

Agora uma coisa interessante.

Eu fiz um comparativo entre uma versão AOT e uma duas versões JIT. Tirei print das configurações de publicação de cada uma delas e dos binários gerados. Resolvi utilizar o Rider para fazer isso por pura praticidade, mas é totalmente possível fazer por linha de comando.

Na versão AOT incluí a seguinte opção <OptimizationPreference>Size</OptimizationPreference> no .csproj para que a compilação priorize o tamanho da aplicação. A outra opção seria <OptimizationPreference>Speed</OptimizationPreference>.

Configuração de Publicação:
Perfil de Publicação AOT

Binário Gerado:
Binário Gerado


Na aplicação compilada via JIT eu utilizei as opções de geração de arquivo único e Self-Contained (onde o runtime do .NET é embarcado junto com a aplicação). Na tabela abaixo essa compilação está referenciada como JIT 1.

Configuração de Publicação:
Perfil de Publicação JIT1

Binário Gerado:
Configuração de Publicação


Já a outra compilação, que eu chamo de JIT 2, foi feita normalmente, sendo que a execução dessa aplicação depende de ter o runtime do .NET instalado na máquina.

Configuração de Publicação:
Perfil de compilação JIT2

Binário Gerado:
Binário Gerado


Segue o resumo:

Tamanho Tempo de Abertura
AOT 1.5 MB ~2ms
JIT 1 132 KB ~6ms
JIT 2 112 KB ~6ms

Podemos notar que a aplicação AOT abriu 3 vezes mais rápido do que uma aplicação JIT. Eu utilizei o comando time do macOS. Acredito que deva existir similares para Linux e Windows.
É basicamente executar o comando time ./AOT no terminal para obter o tempo que aplicação demorou para executar.

Já em relação ao tamanho, as versões JIT são muito menores. Mesmo configurando a compilação AOT para priorizar o tamanho temos um binário consideravelmente grande.
Ainda é possível "espremer" mais o tamanho dele, porém, minha ideia aqui foi mostrar as configurações básicas para as compilações.


Antes de qualquer coisa, esse tipo de teste não serve muito como uma análise decisiva. Isso foi feito na minha máquina, no meu contexto e serve apenas para demonstrar alguns pontos de diferença entre JIT e AOT.

Existem várias configurações tanto pra JIT quanto para AOT para melhorar o start up da aplicação, e reduzir o tamanho do binário. Recomendo muito uma leitura com calma da documentação da Microsoft para deployments de aplicações .NET: Application publishing - .NET | Microsoft Learn

Três dicas de ouro:

1 - A Microsoft lançou um post fantástico falando sobre como tornar bibliotecas compatíveis com AOT. Leitura mais do que obrigatória!

2 - No Microsoft Build desse ano (2023) rolou um dos vídeos mais legais falando sobre performance e AOT :
Deep dive into .NET performance and native AOT. Dedique 45 minutos do seu tempo para assisti-lo. Não vai se arrepender.

3 - Meu grande amigo Tiago Tartari escreveu um post falando mais sobre esses assuntos. Eu recomendo muito você acompanhar o blog dele. Tem conteúdos sensacionais sonre SRE, performance em aplicações .NET, arquitetura e etc. Mais do que recomendado!!!


Ufa, chegamos ao final!

Eu espero que você tenha curtido esse post. Demorei alguns meses reunindo anotações, assistindo vídeos, fazendo testes e tomando café.

Esse conteúdo é resultado de muitas horas de dedicação e pra mim é uma honra poder compartilhar com todos e todas.

Nos vemos no próximo post.

Abraços

Top comments (2)

Collapse
 
amorguettinogueira profile image
Adriano Nogueira

Angelo, sabe se a MS fez melhorias significativas no AOT do .net7 para o .net8? Eu tenho uma lambda function escrita em .net7 com AOT publicada na AWS e vou fazer um benchmark pra ver se teve grande impacto... Meu desenvolvimento foi baseado nesse vídeo do Nich Chapsas: https://www.youtube.com/watch?v=3QJDJl-zDFM&ab_channel=NickChapsas

Collapse
 
angelobelchior profile image
Angelo Belchior

Sim, tem uma evolução muito grande, tanto na performance quanto no tamanho do binário.