Que nível de otimizações podemos esperar do compilador do C# e do JIT?

Neste post, compartilho um pequeno, mas esclarecedor exemplo de como o(s) compilador(es) .NET nos ajuda a obter melhor performance. Além disso, fazemos um pequeno passeio por ferramentas e conceitos importantes para performance.

Em tempo, este exemplo é inspirado em outro encontrado no excelente (embora já um pouco datado) livro Writing high-performance .net applications.

Um pouco de contexto

Em .NET, o processo de compilação acontece em “dois momentos distintos”. Há dois compiladores – Roslyn (se usamos C# ou VB) e o JIT.

O primeiro “momento” de compilação ocorre quando geramos um executável a partir do código-fonte. Diferente do que ocorre com C++, por exemplo, nesse momento, não temos algo pronto (uma representação nativa pronta para ser executada pelo SO)! O que temos é uma representação binária do nosso código em uma “linguagem intermediária” (Intermediate Language ou IL).

O segundo “momento” ocorre quando executamos o programa. Nesse instante, o JIT (Just In Time compiler), converte o código IL para código nativo que o o SO e o computador conseguem executar.

Essa abordagem, aparentemente estranha, é, na verdade, uma grande vantagem. O JIT consegue “entender” o ambiente operacional onde o programa está sendo executado e gerar o melhor código nativo possível. Além disso, não precisamos nos preocupar com aspectos de compatibilidade quando estamos compilando nossa aplicação.

Nosso código de exemplo

Para ilustrar um pouco sobre esse processo, vamos trabalhar com um código muito simples. Aqui, fazemos duas chamadas para uma mesma função. Esta, por sua vez, retorna o resultado de uma adição com duas constantes.

using System;
using System.Runtime.CompilerServices;

namespace UnderstandingJIT
{
    class Program
    {
        static void Main()
        {
            var a = TheAnswer();
            var b = TheAnswer();
            Console.WriteLine(a + b);
        }

        [MethodImpl(MethodImplOptions.NoInlining)]
        static int TheAnswer()
            => 21 + 21;
    }
}

O atributo MethodImplOptions.NoInlining é uma instrução para o JIT não realizar uma otimização extremamente comum. Veremos ela mais tarde.

O resultado em Intermediate Language

Como disse, o primeiro processo de compilação converte o código fonte em C# para uma linguagem intermediária.

Abaixo, vemos essa representação textual do resultado do processo de compilação. Essa representação foi gerada usando o ILSpy.

.method private hidebysig static 
	void Main () cil managed 
{
	// Method begins at RVA 0x2050
	// Code size 19 (0x13)
	.maxstack 2
	.entrypoint
	.locals init (
		[0] int32 b
	)

	IL_0000: call int32 UnderstandingJIT.Program::TheAnswer()
	IL_0005: call int32 UnderstandingJIT.Program::TheAnswer()
	IL_000a: stloc.0
	IL_000b: ldloc.0
	IL_000c: add
	IL_000d: call void [mscorlib]System.Console::WriteLine(int32)
	IL_0012: ret
} // end of method Program::Main

.method private hidebysig static 
	int32 TheAnswer () cil managed 
{
	// Method begins at RVA 0x206f
	// Code size 3 (0x3)
	.maxstack 8

	IL_0000: ldc.i4.s 42
	IL_0002: ret
} // end of method Program::TheAnswer

IL pode ser entendida, na minha opinião, como a linguagem canonica de .NET. Independente da linguagem que utilizamos (C#, VB, F#…), uma representação em IL é gerada.

Podemos programar com IL diretamente, se desejarmos. Mas, o propósito dessa linguagem não é praticidade, e sim ajuste para .NET.

Repare, que a representação gerada pelo compilador já omite a operação de soma que fizemos no código original – já temos alguma otimização.

Depurando com WinDBG

Para vermos o JIT em ação utilizaremos uma ferramenta chamada WinDBG.

Trata-se da ferramenta de depuração padrão do Windows. Ela não serve para depurarmos apenas aplicações .NET, mas qualquer outro tipo de aplicação que seja compatível com a plataforma.

Obviamente, ela oferece bem mais poder do que temos no Visual Studio. Entretanto, esse poder a mais vem acompanhado de alguma complexidade. Se você é desenvolvedor sério para Windows e precisa trabalhar com cenários pesados de depuração, precisa gastar algum tempo e aprender, pelo menos os fundamentos de WinDBG.

As instruções que seguem devem ser executadas logo após carregar o executável no WinDBG.

sxe ld clrjit
g
.loadby sos clr
!bpmd UnderstandingJIT.exe Program.Main
g

O que fazemos aqui foi fazer uma executar a aplicação até a carga do JIT e, depois, colocamos um breakpoint no início do método Main.

Aqui, temos um trecho código de Main já em Assembly.

call dword ptr ds:[15B4D28h]
mov  esi, eax
call dword ptr ds:[15B4D28h]
add  esi, eax
mov  ecx, esi
call mscorlib_ni+0xbae32c

As primeiras duas intruções call estão chamando nosso método TheAnswer. A terceira, está chamando o método Console.WriteLine.

Interessante objservar como todas as operações são executadas em registradores. O que faz com que a performance seja muito boa.

Depurando, linha por linha, a partir da primeira chamada do método TheAnswer vemos algo assim:

Entretanto, ao executarmos a segunda chamada, vamos algo assim:

O que aconteceu?

Em .NET, o JIT é acionado em toda “primeira execução” de um método. Ou seja, o JIT converte um método de IL para represetanção binária na primeira vez que o método é chamado fazendo todas as otimizações possíveis naquele instante.

O que ocorreu é que, na segunda execução do método já temos o método TheAnswer convertido para sua versão em Assembly.

push ebp
mov  ebp, esp
mov  eax, 2Ah
pop  ebp
ret

Importante você perceber que 2Ah é 42 em hexa. O valor de retorno está em EAX.

Lição importante: A primeira (e apenas a primeira) execução de um método em .NET possui um overhead em função da execução do JIT.

Suportando Inlining

No início do post, mencionei que o atributo MethodImplOptions.NoInlining serve como instrução para o JIT não realizar uma otimização extremamente comum.

Inlining é uma otimização em que o JIT substitui chamadas para funções com “corpo de código” daquela função. Feita de forma pouco cuidadosa, essa operação pode impactar drasticamente no tamanho do executável gerado.

Vejamos o que ocorre com a depuração em WinDBG quando permitimos que o JIT utilize inlining com nosso código.

Eis o nosso novo Main.

mov  ecx, 54h
call mscorlib_ni+0xb4e31c
ret

O que o JIT fez? Ele percebeu que a função retorna uma constante. Logo, no lugar de chamar a função ele pode trazer a constante para o código. Então, também ficou evidente que não seria necessária uma soma. Assim, o JIT pegou apenas o valor 54h (84 em decimal) e chamou Console.WriteLine.

Isso é bem lindo!

Time to Action

Tanto a geração de IL quanto o JIT são processos extremamente poderosos que geram executáveis extremamente eficientes. Entender um pouco melhor sobre como esses dois mecanismos funcionam pode nos ajudar a explicar (e melhorar) o desempenho de nossas aplicações.

Recomendo que você gaste um pouco mais de tempo entendendo IL, aprendendo a usar o WinDBG e verificando o que está acontecendo “debaixo do capo”. Em muitos cenários, isso pode te ajudar a escrever código mais performático.

Continuarei tratando de performance tanto em português quanto em inglês. Assine a newsletter para receber notificações.

Capa: Alexander Hough

4 Comentários

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *