Março de 2016

Volume 31 Número 3

Compiladores - otimização gerenciada orientada por perfil usando JIT em segundo plano

Por Hadi Brais | Março de 2016

Algumas otimizações de desempenho realizadas por um compilador são sempre boas. Ou seja, independentemente do código executado em tempo de execução, a otimização aprimorará o desempenho. Considere, por exemplo, o desenrolamento do loop para habilitar a vetorização. Essa otimização transforma um loop para que, em vez de executar uma única operação no corpo do loop em um único conjunto de operandos (como adicionar dois inteiros armazenados em matrizes diferentes), a mesma operação seja realizada em vários conjuntos de operandos simultaneamente (adicionando quatro pares de inteiros).

Por outro lado, há otimizações muito importantes que o copilador realiza heuristicamente. Isto é, o compilador não sabe com certeza se essas otimizações realmente funcionarão bem para o código executado em tempo de execução. As duas otimizações mais importantes nessa categoria (ou provavelmente entre todas as categorias) são a alocação de registro e a incorporação de função. Você pode ajudar o compilador a tomar decisões melhores quando estiver executando tais otimizações ao executar o aplicativo uma ou mais vezes, fornecendo a ele a entrada típica do usuário e, ao mesmo tempo, registrando qual código foi executado.

As informações coletadas sobre a execução do aplicativo são chamadas de perfil. Então, o compilador poderá usar esse perfil para tornar mais efetivas certas otimizações, algumas vezes resultando em aumentos de velocidade significativos. Essa técnica é chamada de otimização orientada por perfil (PGO). Você deve usar essa técnica quando tiver escrito um código legível e de fácil de manutenção, tiver empregado bons algoritmos, tiver maximizado a localidade de acesso a dados, tiver minimizado a contenção em bloqueios e tiver ativado todas as otimizações do compilador possíveis, mas ainda não estiver satisfeito com o desempenho resultante. Em termos gerais, a PGO pode ser usada para melhorar outras características de seu código, não apenas o desempenho. Contudo, a técnica analisada neste artigo só pode ser utilizada para melhorar o desempenho.

Analisamos em detalhes a PGO nativa no compilador do Microsoft Visual C++ em um artigo anterior, em msdn.com/magazine/mt422584. Para os leitores desse artigo, tenho algumas ótimas novidades. Usar a PGO gerenciada é mais simples. Em particular, o recurso que analisarei neste artigo, o JIT em segundo plano (também chamado de JIT de vários núcleos) é muito mais simples. Porém, é um artigo avançado. Há três anos, a equipe do CRL escreveu uma postagem de blog de introdução (bit.ly/1ZnIj9y). O JIT em segundo plano tem suporte no Microsoft .NET Framework 4.5 e em todas as versões posteriores.

Há três técnicas gerenciadas de PGO:

  • Compile o código gerenciado para o código binário usando o Ngen.exe (um processo conhecido como pré-JIT) e então use o Mpgo.exe para gerar perfis representando cenários comuns de uso que podem ser usados para otimizar o desempenho do código binário. Isso é parecido com a PGO nativa. Essa técnica será mencionada como MPGO estática.
  • Na primeira vez em que um método de Linguagem Intermediária (IL) tiver o JIT compilado, gere um código binário instrumentado que registre as informações na execução em relação a quais partes do método estão sendo executadas. Posteriormente, use esse perfil na memória para recompilar com o JIT o método IL para gerar um código binário altamente otimizado. Também é parecido com a PGO nativa, exceto que tudo ocorre em tempo de execução. Essa técnica será mencionada como MPGO dinâmica.
  • Use o JIT em segundo plano para ocultar o máximo possível a sobrecarga do JIT compilando de modo inteligente, com o JIT, os métodos IL antes que eles sejam realmente executados pela primeira vez. Idealmente, quando o método fosse chamado pela primeira vez, você já teria compilado com o JIT e não haveria necessidade de esperar o compilador JIT compilar o método.

O interessante é que todas essas técnicas foram introduzidas no .NET Framework 4.5 e as versões posteriores também dão suporte a elas. A MPGO estática funciona apenas com as imagens nativas geradas pelo Ngen.exe. Em contraste, a MPGO dinâmica funciona apenas com os métodos IL. Sempre que possível, use o Ngen.exe para gerar imagens nativas e otimize-as usando a MPGO estática porque essa técnica é muito mais simples e, ao mesmo tempo, oferece aumentos de velocidade respeitáveis. A terceira técnica, o JIT em segundo plano, é muito diferente das duas primeiras porque reduz a sobrecarga de compilação do JIT, em vez de melhorar o desempenho do código binário gerado e, portanto, pode ser usada junto com qualquer uma das outras duas técnicas. Contudo, usar somente o JIT em segundo plano pode, algumas vezes, ser muito benéfico e melhorar o desempenho da inicialização do aplicativo ou de determinado cenário de uso comum em até 50%, o que é ótimo. Esta artigo se concentra exclusivamente no JIT em segundo plano. Na próxima seção, analisaremos o modo tradicional de compilar com o JIT os métodos IL e como isso afeta o desempenho. Então, analisaremos o funcionamento do JIT em segundo plano, por que ele funciona assim e como usá-lo corretamente.

JIT tradicional

Provavelmente, você já tem uma ideia básica sobre o funcionamento do .NET JIT porque há muitos artigos que discutem esse processo. Porém, eu gostaria de examinar novamente esse assunto com mais detalhes e precisão (mas não muito) antes de entrar no JIT em segundo plano para que você possa acompanhar facilmente a próxima seção e compreender bem o recurso.

Considere o exemplo mostrado na Figura 1. O T0 é o thread principal. As partes verdes do thread indicam que ele está executando o código do aplicativo em velocidade total. Vamos supor que T0 esteja sendo executado em um método com o JIT já compilado (a parte verde superior) e a próxima instrução seja chamar o M0 do método IL. Como esta é a primeira vez em que o M0 será executado e como ele é representado na IL, terá que ser compilado para o código binário que o processador possa executar. Por isso, quando a instrução de chamada for executada, uma função conhecida como stub IL do JIT será chamada. Por fim, essa função chama o compilador JIT para aplicar o JIT no código IL de M0 e retorna o endereço do código binário gerado. Este trabalho não tem nenhuma relação com o aplicativo em si e é representado pela parte vermelha de T0 para indicar que é uma sobrecarga. Felizmente, o local da memória que armazena o endereço do stub IL do JIT será corrigido com o endereço do código binário correspondente para que as futuras chamadas para a mesma função sejam executadas em velocidade total.

A sobrecarga do JIT tradicional ao executar o código gerenciado
Figura 1 Sobrecarga do JIT tradicional ao executar o código gerenciado

Agora, depois de retornar do M0, outro código já com o JIT compilado será executado e o M1 do método IL será chamado. Como o M0, o stub IL do JIT é chamado e, por sua vez, chama o compilador JIT para compilar o método e retorna o endereço do código binário. Depois de retornar do M1, mais código binário é executado e mais dois threads, T1 e T2, são executados. Aqui é onde as coisas ficam interessantes.

Depois de executar os métodos já com o JIT compilado, T1 e T2 chamarão o M3 do método IL, que nunca foi chamado antes e, portanto, tem que ter o JIT compilado. Internamente, o compilador JIT mantém uma lista de todos os métodos que estão tendo o JIT compilado. Há uma lista para cada AppDomain e uma para o código compartilhado. Essa lista é protegida por um bloqueio e todos os elementos também são protegidos por seus próprios bloqueios, para que diversos threads possam participar da compilação do JIT simultaneamente. O que acontecerá neste caso é que um thread, digamos, T1, estará compilando o método com o JIT e perdendo tempo fazendo um trabalho que não tem nenhuma relação com o aplicativo, enquanto o T2 não está fazendo nada — aguardando um bloqueio simplesmente porque, de fato, não tem nada a fazer — até que o código binário de M3 fique disponível. Ao mesmo tempo, T0 estará compilando M2. Quando um thread terminar de compilar um método com o JIT, substituirá o endereço do stub IL do JIT pelo endereço do código binário, irá liberar os bloqueios e executar o método. Observe que T2 finalmente despertará e executará o M3.

O resto do código executado por esses threads é mostrado nas barras verdes na Figure 1. Isso significa que o aplicativo está sendo executado em velocidade total. Mesmo quando um novo thread, T3, inicia a execução, todos os métodos que precisam ser executados já terão o JIT compilado e, portanto, também serão executados em total velocidade. O desempenho resultante fica muito próximo ao desempenho do código nativo.

Em termos gerais, a duração de cada um dos segmentos vermelhos depende principalmente do tempo necessário para aplicar o JIT no método, que, por sua vez, depende do tamanho e da complexidade do método. Pode variar desde alguns microssegundos a dezenas de milissegundos (excluindo o tempo para carregar qualquer assembly ou módulo exigido). Se a inicialização de um aplicativo exigir a execução pela primeira vez de menos de 100 métodos, isso não será um problema. Mas se exigir a execução pela primeira vez de centenas ou de milhares de métodos, o impacto de todos os segmentos vermelhos resultantes poderá ser significativo, especialmente quando o tempo necessário para aplicar o JIT em um método for comparável ao tempo necessário para executar o método, causando uma lentidão com uma porcentagem de dois dígitos. Por exemplo, se um aplicativo exigisse a execução de milhares de métodos diferentes na inicialização com um tempo JIT médio de 3 milissegundos, levaria 3 segundos para concluir a inicialização. Isso será um problema. Não será bom para os negócios porque seus clientes não ficarão satisfeitos.

Observe que há a possibilidade de mais de um JIT do thread compilar o mesmo método ao mesmo tempo. Também é possível que o JIT falhe na primeira tentativa, mas que na segunda, tenha êxito. Por fim, também é possível que um método com o JIT já compilado seja recompilado por JIT. Porém, todos esses casos estão além do escopo deste artigo e não será necessário conhecê-los quando você usar o JIT em segundo plano.

JIT em segundo plano

A sobrecarga da compilação do JIT analisada na seção anterior não pode ser evitada nem reduzida significativamente. Você precisa aplicar o JIT aos métodos IL para executá-los. Contudo, o que você pode fazer é mudar o tempo no qual a sobrecarga é incorrida. A principal ideia é que, em vez de esperar que o método IL seja chamado pela primeira vez para aplicar o JIT nele, você poderá aplicar o JIT nesse método antes, para que, quando for chamado, o código binário já tenha sido gerado. Se você tiver acertado, todos os threads mostrados na Figura 1 ficarão verdes e serão executados em total velocidade, como se você estivesse executando uma imagem nativa NGEN ou talvez melhor. Mas antes disso, dois problemas devem ser resolvidos.

O primeiro problema é que se você for aplicar o JIT em um método antes que ele seja necessário, qual thread aplicará o JIT nele? Não é difícil perceber que o melhor modo de resolver o problema é ter um thread dedicado executado em segundo plano e métodos JIT que provavelmente serão executados o mais rapidamente possível. Como consequência, isso só funcionará se pelo menos dois núcleos estiverem disponíveis (como acontece quase sempre) para que a sobrecarga da compilação do JIT seja ocultada pela execução sobreposta do código do aplicativo.

O segundo problema é este: Como você sabe em qual método aplicar o JIT em seguida, antes que ele seja chamado pela primeira vez? Lembre-se de que, geralmente, há chamadas de método condicionais em cada método e, portanto, não é possível simplesmente aplicar o JIT a todos os métodos que possam ser chamados ou ser teórico demais ao escolher em quais métodos aplicar o JIT em seguida. É muito provável que o thread em segundo plano do JIT fique para trás dos threads do aplicativo muito rapidamente. É aqui que entram os perfis. Primeiro, você treina a inicialização do aplicativo e quaisquer cenários de uso comuns, registra quais métodos tiveram o JIT compilado e a ordem de compilação do JIT para cada cenário separadamente. Então, você pode publicar o aplicativo junto com os perfis gravados para que quando ele for executado na máquina do usuário, a sobrecarga de compilação do JIT seja minimizada em relação à hora do relógio (é como o usuário percebe o tempo e o desempenho). Esse recurso é chamado de JIT em segundo plano e você pode usá-lo sem esforços em seu lado.

Na seção anterior, vimos como o compilador JIT pode compilar com o JIT diferentes métodos em paralelo em threads diferentes. Portanto, tecnicamente, esse JIT tradicional já tem vários núcleos. É lamentável e confuso que a documentação do MSDN refira-se ao recurso como JIT de vários núcleos, com base no requisito de pelo menos dois núcleos, em vez de com base em sua característica de definição. Estou usando o nome “JIT em segundo plano” porque é o que eu gostaria de divulgar. O PerfView tem um suporte predefinido para esse recurso e usa o nome JIT em segundo plano. Observe que o nome “JIT de vários núcleos” é o nome usado pela Microsoft no início do desenvolvimento. No resto desta seção, analisarei tudo o que você precisa fazer para aplicar essa técnica em seu código e como ela altera o modelo JIT tradicional. Também mostrarei como usar o PerfView para medir o benefício do JIT em segundo plano quando você o utiliza em seus próprios aplicativos.

Para usar o JIT em segundo plano, você precisa informar ao tempo de execução onde colocar os perfis (um para cada cenário que inicializa grande parte da compilação do JIT). Também precisa informar ao tempo de execução qual perfil usar para que ele leia o perfil para determinar quais métodos compilar no thread em segundo plano. Isso, claro, precisa ser feito de forma suficiente antes do início do cenário de uso associado.

Para especificar onde colocar os perfis, chame o método System.Runtime.Profile­Optimization.SetProfileRoot definido em mscorlib.dll. Esse método fica assim:

public static void SetProfileRoot(string directoryPath);

A finalidade de seu único parâmetro, directoryPath, é especificar o diretório da pasta onde todos os perfis serão lidos ou gravados. Apenas a primeira chamada para esse método no mesmo App­Domain tem efeito e qualquer outra chamada será ignorada (contudo, o mesmo caminho pode ser usado por diferentes AppDomains). E mais, se o computador não tiver pelo menos dois núcleos, qualquer chamada para SetProfileRoot será ignorada. A única coisa que esse método faz é armazenar o diretório especificado em uma variável interna para que ele possa ser usado sempre que for requerido mais tarde. Esse método geralmente é chamado pelo executável (.EXE) do processo durante a inicialização. As bibliotecas compartilhadas não devem chamá-lo. Você pode chamar esse método a qualquer momento enquanto o aplicativo está em execução, mas antes de qualquer chamada para o método ProfileOptim­ization.StartProfile. Esse outro método fica assim:

public static void StartProfile(string profile);

Quando o aplicativo estiver prestes a seguir um caminho de execução cujo desempenho você gostaria de otimizar (como a inicialização), chame esse método e informe o nome de arquivo e extensão do perfil. Se o arquivo não existir, um perfil será gravado e armazenado em um arquivo com o nome especificado na pasta que você especificou usando SetProfileRoot. Esse processo é chamado de “gravação do perfil”. Se o arquivo especificado existir e contiver um perfil válido do JIT em segundo plano, o JIT entrará em vigor em um JIT do thread em segundo plano dedicado que compila os métodos, escolhido de acordo com o que o perfil informa. Esse processo é chamado de “reprodução do perfil”. Durante a reprodução do perfil, o comportamento exibido pelo aplicativo ainda será gravado e o mesmo perfil de entrada será substituído.

Não é possível reproduzir um perfil sem gravar; atualmente, não há suporte para isso. Você pode chamar o StartProfile várias vezes especificando diferentes perfis adequados para diferentes caminhos de execução. Esse método não terá efeito se tiver sido chamado antes de inicializar a raiz do perfil usando SetProfileRoot. E mais, ambos os métodos não terão efeito se o argumento especificado for inválido. De fato, esses métodos não geram nenhuma exceção nem retornam códigos de erro para não impactar o comportamento dos aplicativos de um modo indesejável. Os dois são thread-safe, exatamente como qualquer outro método estático na estrutura.

Por exemplo, se você quiser melhorar o desempenho da inicialização, chame esses dois métodos como uma primeira etapa na função principal. Se você quiser melhorar o desempenho de certo cenário de uso, chame StartProfile quando o usuário for iniciar esse cenário e chame SetProfileRoot a qualquer momento antes. Lembre-se de que tudo acontece localmente em AppDomains.

É tudo que você precisa fazer para usar o JIT em segundo plano em seu código. É tão simples que você pode experimentar sem pensar demais se será útil ou não. Então, você poderá medir o aumento de velocidade ganho para determinar se vale a pena manter. Se o aumento de velocidade for de pelo menos 15%, você deverá mantê-lo. Do contrário, a decisão será sua. Agora, explicarei em detalhes como funciona.

Sempre que o StartProfile é chamado, as seguintes ações são realizadas no contexto do AppDomain no qual o código está sendo executado atualmente:

  1. Todo o conteúdo do arquivo que contém o perfil (se existir) é copiado para a memória. Então, o arquivo é fechado.
  2. Se não for a primeira vez em que o StartProfile foi chamado com êxito, já haverá um thread do JIT em segundo plano sendo executado. Neste caso, ele é terminado e um novo thread em segundo plano será criado. Então, o thread que chamou StartProfile retorna para o autor da chamada.
  3. Esta etapa ocorre no thread do JIT em segundo plano. O perfil é analisado. Os métodos gravados são compilados com o JIT na ordem em que foram gravados sequencialmente e o mais rápido possível. Esta etapa constitui o processo de reprodução do perfil.

É assim no que diz respeito ao thread em segundo plano. Se ele tiver terminado a compilação do JIT em todos os métodos gravados, será finalizado em silêncio. Se qualquer coisa der errado ao analisar ou compilar com o JIT os métodos, o thread será finalizado em silêncio. Se um assembly ou um módulo não tiver sido carregado e se for exigido aplicar o JIT em um método, ele não será carregado e o método não será compilado com o JIT. O JIT em segundo plano foi projetado de modo que não mude o comportamento do programa o máximo possível. Quando um módulo é carregado, seu construtor é executado. E mais, quando um módulo não puder ser encontrado, os retornos de chamada registrados no evento System.Reflection.Assembly.ModuleResolve serão chamados. Portanto, se o thread em segundo plano carregar um módulo antes do tempo, o comportamento desses funções poderá mudar. Isto se aplica igualmente aos retornos de chamada registrados no evento System.AppDomain.AssemblyLoad. Como o JIT em segundo plano não carrega os módulos necessários, pode não ser possível compilar muitos dos métodos gravados, levando a um pequeno benefício.

Você pode estar pensando: por que não criar mais de um thread em segundo plano para aplicar o JIT em mais métodos? Bem, primeiro, esses threads requerem muita computação, portanto, podem competir com os threads do aplicativo. Segundo, mais threads significam mais disputa de sincronização dos threads. Terceiro, é provável que os métodos tenham o JIT compilado, mas nunca sejam chamados por nenhum thread do aplicativo. Ao contrário, um método pode ser chamado pela primeira vez e nem mesmo estar gravado no perfil ou antes de ter o JIT compilado pelo thread de vários núcleos. Devido a esses problemas, ter mais de um thread em segundo plano pode não ser muito vantajoso. Contudo, a equipe CLR poderá fazer isso no futuro (especialmente quando a restrição de carregar os módulos puder ser diminuída). Agora, é hora de analisar o que acontece nos threads do aplicativo, inclusive o processo de gravação do perfil.

A Figura 2 mostra o mesmo exemplo da Figura 1, exceto que o segundo plano tem o JIT habilitado. Ou seja, há um JIT do thread em segundo plano compilando os métodos M0, M1, M3 e M2, nessa ordem. Observe como esse thread em segundo plano está disputando com os threads do aplicativo T0, T1, T2 e T3. O thread em segundo plano tem que aplicar o JIT em cada método antes que ele seja chamado pela primeira vez por qualquer thread para atender à sua finalidade. A análise a seguir supõe que este é o caso com M0, M1 e M3, mas não com M2.

Um exemplo mostrando a otimização do JIT em segundo plano em comparação com a Figura 1
Figura 2 Um exemplo mostrando a otimização do JIT em segundo plano em comparação com a Figura 1

Quando T0 estiver prestes a chamar M0, o thread do JIT em segundo plano já o terá compilado com o JIT. Porém, o endereço do método ainda não foi corrigido e aponta para o stub IL do JIT. O thread do JIT em segundo plano conseguiu corrigi-lo, mas não para determinar posteriormente se o método foi chamado ou não. Essa informação é usada pela equipe do CLR para avaliar o JIT em segundo plano. Portanto, o stub IL do JIT é chamado e vê que o método já foi compilado no thread em segundo plano. A única coisa que ele precisa fazer é corrigir o endereço e executar o método. Observe como a sobrecarga de compilação do JIT foi completamente eliminada nesse thread. M1 recebe o mesmo tratamento quando chamado em T0. M3 recebe o mesmo tratamento, também, quando chamado em T1. Mas, quando T2 chama M3 (consulte a Figura 1), o endereço do método foi corrigido por T1 muito rapidamente, portanto, chama diretamente o código binário real do método. Então, T0 chama M2. Porém, o thread do JIT em segundo plano não terminou ainda a compilação com JIT do método e, portanto, T0 espera no bloqueio JIT do método. Quando o método tiver o JIT compilado, T0 irá despertar e chamá-lo.

Ainda não analisei como os métodos são gravados no perfil. Também é possível que um thread do aplicativo chame um método que o thread do JIT em segundo plano nem começou a compilar com o JIT (ou nunca aplicará o JIT porque não está no perfil). Compilei as etapas realizadas em um thread do aplicativo quando ele chama um método IL estático ou dinâmico que não teve o JIT compilado ainda no seguinte algoritmo:

  1. Adquira o bloqueio da lista JIT do AppDomain onde existe o método.
  2. Se o código binário já foi gerado por algum outro thread do aplicativo, libere o bloqueio da lista JIT e vá para a etapa 13.
  3. Adicione um novo elemento à lista que representa o trabalho JIT do método, caso ele não exista. Se já existir, sua contagem de referência será incrementada.
  4. Libere o bloqueio da lista JIT.
  5. Adquira o bloqueio JIT do método.
  6. Se o código binário já foi gerado por algum outro thread do aplicativo, vá para a etapa 11.
  7. Se o método não tiver suporte do JIT em segundo plano, ignore esta etapa. Atualmente, o JIT em segundo plano dá suporte somente aos métodos IL emitidos estaticamente definidos em assemblies que não foram carregados com o System.Reflection.Assembly.Load. Agora, se o método tiver suporte, verifique se ele já teve o JIT compilado pelo thread JIT em segundo plano. Se este for o caso, registre o método e vá para a etapa 9. Do contrário, vá para a próxima etapa.
  8. Aplique o JIT ao método. O compilador JIT examina a IL do método, determina todos os tipos requeridos, assegura se todos os assemblies requeridos estão carregados e se todos os objetos do tipo requeridos são criados. Se qualquer coisa der errado, será gerada uma exceção. Esta etapa incorre em grande parte da sobrecarga.
  9. Substitua o endereço do stub IL do JIT pelo endereço do código binário real do método.
  10. Se o método foi compilado com o JIT por um thread do aplicativo, ao invés do thread JIT em segundo plano, haverá um gravador JIT em segundo plano ativo e o método terá suporte no JIT em segundo plano; o método será gravado em um perfil na memória. A ordem na qual os métodos tiveram o JIT compilado é manida no perfil. Observe que o código binário gerado não é gravado.
  11. Libere o bloqueio JIT do método.
  12. Diminua com segurança a contagem de referência do método usando o bloqueio da lista. Se ela se tornar zero, o elemento será removido.
  13. Execute o método.

O processo de gravação do JIT em segundo plano termina quando ocorre qualquer uma das situações a seguir:

  • O AppDomain associado ao gerenciador do JIT em segundo plano é descarregado por qualquer motivo.
  • StartProfile é chamado novamente no mesmo AppDomain.
  • A taxa na qual os métodos têm o JIT compilado nos threads do aplicativo fica muito pequena. Isso indica que o aplicativo atingiu um estado estável no qual raramente requer a compilação do JIT. Qualquer método que tenha o JIT compilado após esse ponto não será interessante para o JIT em segundo plano.
  • Um dos limites de gravação foi atingido. O número máximo de módulos é 512, o número máximo de métodos é 16.384 e a maior duração contínua de gravação é de um minuto.

Quando o processo de gravação termina, o perfil gravado na memória é despejado no arquivo especificado. Assim, na próxima vez em que o aplicativo for executado, ele escolherá o perfil que reflete o comportamento exibido pelo aplicativo durante sua última execução. Como mencionado antes, os perfis são sempre substituídos. Se você quiser manter o perfil atual, deverá fazer uma cópia manual dele antes de chamar StartProfile. O tamanho de um perfil geralmente não excede cerca de 12 kilobytes.

Antes de fechar a seção, gostaria de falar sobre como selecionar as raízes do perfil. Para os clientes do aplicativo, você pode especificar um diretório específico do usuário ou um diretório relativo do aplicativo, dependendo de querer ter diferentes conjuntos de perfis para diferentes usuários ou apenas um conjunto de perfis para todos os usuários. Para os aplicativos ASP.NET e Silverlight, provavelmente você usará um diretório relativo do aplicativo. De fato, começando com o ASP.NET 4.5 e o Silverlight 4.5, o JIT em segundo plano é habilitado por padrão e os perfis são armazenados perto do aplicativo. O tempo de execução se comportará como se você tivesse chamado SetProfileRoot e StartProfile no método principal, portanto, você não terá que fazer nada para usar o recurso. Entretanto, você ainda poderá chamar StartProfile como descrito anteriormente. Você pode desativar o JIT em segundo plano automático definindo o sinalizador profileGuidedOptimizations como None no arquivo de configuração da Web, como descrito na postagem do Blog do .NET, “Uma solução fácil para melhorar o desempenho da inicialização do aplicativo” (bit.ly/1ZnIj9y). Esse sinalizador pode requerer apenas outro valor, a saber All, que habilita o JIT em segundo plano (o padrão).

JIT em segundo plano em ação

O JIT em segundo plano é um rastreamento de eventos para o provedor do Windows (ETW). Ou seja, ele informa o número de eventos relacionados ao recurso para os consumidores ETW, como o Gravador de Desempenho do Windows e o PerfView. Esses eventos permitem que você faça o diagnóstico de qualquer ineficiência ou falha ocorrida no JIT em segundo plano. Em particular, você pode determinar quantos métodos foram compilados no thread em segundo plano e o tempo JIT total desses métodos. É possível baixar o PerfView de bit.ly/1PpJUpv (não requer nenhuma instalação, apenas descompactar e executar). Usarei este código simples para uma demonstração:

class Program {
  const int OneSecond = 1000;
  static void PrintHelloWorld() {
    Console.WriteLine("Hello, World!");
  }
  static void Main() {
    ProfileOptimization.SetProfileRoot(@"C:\Users\Hadi\Desktop");
    ProfileOptimization.StartProfile("HelloWorld Profile");
    Thread.Sleep(OneSecond);
    PrintHelloWorld();
  }
}

Na função principal, SetProfileRoot e StartProfile são chamados para configurar o JIT em segundo plano. O thread é colocado para dormir por cerca de um segundo, então, um método, PrintHelloWorld, é chamado. Esse método simplesmente chama Console.WriteLine e retorna. Copile este código para uma IL executável. Observe que o Console.WriteLined não requer a compilação do JIT porque ele já foi compilado usando o NGEN ao instalar o .NET Framework em seu computador.

Use o PerfView para inicializar e traçar o perfil do executável (para obter mais informações sobre como fazer isso, consulte a postagem “Melhorando o desempenho de seu aplicativo com o PerfView” no Blog do .NET, em bit.ly/1nabIYC ou no Tutorial do PerfView do Channel 9, em bit.ly/23fwp6r). Lembre-se de marcar a caixa de seleção JIT em Segundo Plano (exigido apenas no .NET Framework 4.5 e 4.5.1) para habilitar a captura de eventos desse recurso. Espere até que o PerfView termine, então abra a página do JITStats (consulte a Figura 3); o PerfView informará que o processo não usa a compilação do JIT em segundo plano. É por isso que, na primeira execução, um perfil precisa ser gerado.

O local do JITStats no PerfView
Figura 3 O local do JITStats no PerfView

Portanto, agora que você gerou um perfil JIT em segundo plano, use o PerfView para inicializar e traçar o perfil do executável. Contudo, desta vez, quando você abrir a página do JITStats, verá que um método, PrintHelloWorld, teve o JIT compilado no thread do JIT em segundo plano e que um método Main não teve. Também informará que cerca de 92% do tempo do JIT foi gasto compilando todos os métodos IL ocorridos nos threads do aplicativo. O relatório do PerfView também mostrará uma lista de todos os métodos que tiveram o JIT compilado, a IL e o tamanho binário de cada método, quem compilou o método com o JIT e outras informações. Você também poderá acessar facilmente o conjunto completo de informações sobre os eventos do JIT em segundo plano. Porém, devido à falta de espaço aqui, não entrarei em detalhes.

Você pode estar imaginando a finalidade de suspender por cerca de um segundo. Isso é necessário para ter o PrintHelloWorld compilado pelo JIT no thread em segundo plano. Do contrário, provavelmente esse thread do aplicativo começará a compilar o método antes do thread em segundo plano. Em outras palavras, você precisa chamar StartProfile cedo o bastante para que o thread em segundo plano possa ficar à frente na maior parte do tempo.

Conclusão

O JIT em segundo plano é uma otimização orientada por perfil com suporte no .NET Framework 4.5 e posterior. Este artigo analisou quase tudo o que você precisa saber sobre o recurso. Demonstrei em detalhes por que essa otimização é necessária, como funciona e como usá-la corretamente em seu código. Use esse recurso quando o NGEN não for conveniente ou possível. Como ele é fácil de usar, você poderá experimentá-lo sem pensar muito se beneficiaria seu aplicativo ou não. Se você estiver contente com o aumento de velocidade obtido, mantenha-o. Do contrário, poderá removê-lo facilmente. A Microsoft usou o JIT em segundo plano para melhorar o desempenho de inicialização de alguns de seus aplicativos. Espero que você possa usá-lo com eficiência em seus aplicativos também para conseguir aumentos de velocidade significativos na inicialização dos cenários de uso extensivo do JIT e na inicialização do aplicativo.


Hadi Brais* é acadêmico com doutorado do Instituto Indiano de Tecnologia de Délhi e pesquisa otimizações do compilador para a tecnologia de memória da próxima geração. Ele passa a maior parte do tempo escrevendo código em C/C++/C# e aprofundando-se em tempos de execução, em estruturas de compilador e em arquiteturas do computador. Ele mantém um blog em hadibrais.wordpress.com. Entre em contato com ele em hadi.b@live.com.*

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Vance Morrison