domingo, 14 de junho de 2009

A JVM e seu compilador JIT

Até mesmo entre alguns programadores Java, há certas dúvidas sobre como alguns aspectos da JVM funcionam. Em particular, embora muitos saibam que existe um compilador durante a execução do programa, poucos sabem exatamente quais os benefícios. Mais do que isso, não é raro encontrar desenvolvedores procurando um compilador ahead of time (AOT) para java para gerar programas nativos. Este post pretende mostrar algumas vantagens do compilador just in time (JIT), bem como demonstrar um pouco da sua capacidade. As informações contidas neste artigo se referem ao Hotspot, o compilador jit da Sun. Porém, a maior parte da informações contida é aplicável a todas as JVM's.

Hotspot client e server
Antes de falar especificamente sobre o Hotspot, é preciso saber que existem na verdade dois Hotspot's, chamados de client e server.
O client compiler é o compilador presente na JRE de 32 bits. Ele tem como objetivos gerar código nativo rapidamente e, preferencialmente, sem afetar a responsividade do código sendo executado. Seu nome vem do fato de que é preferivelmente utilizado em aplicações clientes que normalmente interagem com usuários. Já o server compiler está presente apenas no JDK (e nas JRE de 64bits) e exige mais tempo para gerar código nativo. Contudo, várias otimizações só estão presentes no server compiler. Seu principal uso é para aplicações que tipicamente executam em servidores, com um tempo de vida maior. Para ativar o server, basta invocar o executável java com a opção -server.

E como funciona o Hotspot?
O Hotspot (nome do compilador jit da máquina virtual da Sun) observa a execução dos programas e, a partir do perfil de execução e da plataforma em que se encontra, gera código nativo para o programa sendo executado. Isso significa que ele pode (e o faz) gerar código específico para o processador em que o programa está sendo executado, se aproveitando de registradores específicos e instruções especiais.

Exemplo prático
Para ilustrar o comportamento do JIT, vamos mostrar um pequeno exemplo. O código abaixo (que também se encontra no repositório de códigos deste blog) é bem simples e mostra bem o que o Hotspot é capaz de fazer:

long bits = 0L;
if(args.length > 0) {
bits = Long.parseLong(args[0]);
}
long start = System.nanoTime();
int n = 2000000001;
boolean shift = bits > 0;
while (n > 0) {
if(shift) {
bits ^= 1 << 5;
}
n--;
}
System.out.println("bits: "+ bits);
long end = System.nanoTime();
System.out.println(TimeUnit.NANOSECONDS.toMillis(end-start) + "ms");

Executando o código acima nos três modos (puramente interpretado, compilado com o client compiler e com o server compiler) passando o número 1 como argumento, temos os seguintes resultados (em um Solaris usando java6u14):

Interpretado (invocado com -Xint): infinito!
Client compiler: 2903ms
Server compiler: 11ms

O resultado do server compiler é impressionante. Para comparar, o código C equivalente (compilado no GCC 3.4.6), no mesmo computador mas com o tempo medido com o utilitário 'time' do UNIX:

gcc: 7422ms
gcc -O1: 2541ms
gcc -O5: 1447ms

A versão server é inclusive muito mais rápida que a versão nativa compilada com -O5 (o 5o nível de otimização do GCC - mais do que isso não trouxe melhorias para este exemplo). Mas mesmo a versão client tem desempenho parecido com o GCC -O1.

Java mais rápido que código nativo?
Para entender o que ocorreu com o programa anterior, serão explicadas algumas das técnicas de otimização feitas pelo Hotspot. Para começar, o Hotspot detecta que é necessário otimizar o corpo do loop. Para isso, ele aplica uma técnica conhecida como loop unrolling com fator de 2. O loop resultante fica parecido com:

if(n % 2 == 1) {
if(shift) bits ^= 1 << 5;
n--;
}
while (n > 0) {
if(shift) {
bits ^= 1 << 5;
}
n--;
if(shift) {
bits ^= 1 << 5;
}
n--;

}

Porém, dentro do loop, o valor de shift não é alterado e o 'n--' não tem efeito sobre a computação sendo feita. O compilador, dessa vez, reordena as instruções para ficar parecido com:

while (n > 0) {
if(shift) {
bits ^= 1 << 5;
bits ^= 1 << 5;
}
n--;
n--;
}

Para que a variável 'bits' não seja carregada mais de uma vez e porque a operação XOR (^) é associativa, o compilador resolve colapsar as duas instruções em uma só:



while (n > 0) {
if(shift) {
bits ^= (1 << 5) ^ (1 << 5);
}
n -= 2;
}


Nesse momento, o Hotspot percebe que a expressão:
(1 << 5) ^ (1 << 5)
na verdade é a constante 0! Assim, a parte interna do loop pode ser eliminada, bastando adicionar as instruções do seu efeito colateral (decrementar o n até 0). Dessa forma, o código final executado é parecido com:

if(n % 2 == 1) {
if(shift) bits ^= 1 << 5;
n--;
}
n = 0;

É claro que esta é uma simplificação do que acontece realmente (o loop na verdade é dividido em três seções, mas estas explicações detalhadas ficam para outro post). A versão client compiler pára antes de fazer todas as otimizações para que o código nativo possa ser executado mais rapidamente. O código gerado, porém, não é o mais otimizado possível - que é um quase noop.

Conclusão
Não se preocupe em escrever o código java mais otimizado possível. É preferível escrever um código mais legível e deixar as otimizações mais estranhas para o Hotspot.
Embora não se possa afirmar que a JVM consiga gerar código tão eficiente quanto os compiladores nativos, é seguro afirmar, pelo menos, que eles tem desempenhos equivalentes.

PS: O exemplo aqui presente e muitas outras discussões bastante interessantes podem ser acompanhadas no google groups de linguagens da jvm.

update: muitos erros de português...

update2: Os tempos da versão java medidos com o time foram omitidos por serem virtualmente identicos ao tempo acima. Em todo caso, o user time é, em média, 100ms superior ao tempo medido de dentro da aplicação.

5 comentários:

  1. Muito bom.
    fiz um programa equivalente em c# mas não consegui achar nenhum lugar para pedir maior nível de otimização do jit do .NET. O máximo que se tem é pedir um /o+ na compilação.
    de qqer forma o tempo ficou em torno de 2350 ms.

    ResponderExcluir
  2. Muito bacana o artigo. Mas um pouco corner case. Ia ser legal se existissem estudos de programas 'reais'.
    O bacana foi também saber que no modo server e client são utilizados hotspot's diferentes.

    ResponderExcluir
  3. (eu cheguei neste post pelo google reader do wagão)
    Diminui dois zeros desse n e refaz o teste.
    para mim deu isso:

    (GCC)
    [rafaellg@memphis]$ time fast 1
    33
    real 0m0.062s
    user 0m0.052s
    sys 0m0.004s

    (SUN JAVA)
    time java Fast 1
    bits: 33
    6ms
    real 0m0.121s
    user 0m0.068s
    sys 0m0.016s


    Parece que você encontrou alguma picuinha da implementação do gcc.

    ResponderExcluir
  4. olá rafel,
    diminuindo dois 0 do loop faz com que o teste seja pequeno demais - o tempo de analisar e compilar o código fica grande demais
    de qualquer maneira, com ou sem os 0's, o GCC gera o mesmo codigo assembly (pelo menos no gcc 3.4.6 do openSolaris intel) - se voce puder verificar com GCC -S, mas deve ser igual no linux tb

    ResponderExcluir
  5. Wagner, o código original fazia parte de um programa que esta com um comportamento estranho em produção, onde era usada uma JVM de 64 bits. O comportamento estranho era que o loop era executado instantaneamente. O loop interno era idêntico ao postado.

    ResponderExcluir