Mostrando postagens com marcador jit. Mostrar todas as postagens
Mostrando postagens com marcador jit. Mostrar todas as postagens

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.

sábado, 30 de maio de 2009

Java6u14: EA e LE

Sem fazer nenhum alarde (e uma semana antes do JavaOne 2009) a Sun resolveu lançar mais uma atualização para o JRE6 - o Update 14. Devido às indefinições sobre o Java7 (que ainda não possui uma JSR), a Sun tem feito, em suas atualizações do Java6, grandes modificações, entre as quais o "novo plugin" no update 10, capacidade de atualização "in place" para windows no update 11, o plugin de 64 bits no update 12 e - o principal assunto deste artigo - análise de escape e o novo garbage collector (G1) no update 14.


Escape Analysis
A análise de escape permite à JVM detectar quando um objeto é "não escapante" (ou seja, seu ciclo de vida está confinado a um escopo local). Quando um objeto desse tipo é detectado, o JIT vai (supostamente) alocá-lo na pilha (ao invés do heap) e tratar seus campos como variáveis locais e, no melhor caso, trabalhar apenas com os registradores do seu CPU. Além disso, é garantidamente seguro ignorar os monitores destes objetos - uma otimização conhecida como "lock elision".

Um pouco de história:
Na época do desenvolvimento do Java6, a análise de escape estava prometida como um dos responsáveis por um aumento de performance sobre o Java5. Algumas JVMs da época já tinham essa feature (ou pelo menos em teoria, de acordo com este artigo), mas devido ao tempo escasso, a JVM6 era capaz de fazer a análise, mas não implementava nenhuma otimização com essas informações.

Na prática...
Vamos ver como a EA (escape analysis) e LE (lock elision) se saem em alguns microbenchmarks simples.
O primeiro teste consiste em alocar um objeto simples, com dois campos, dentro de um for, alterar o valor dos seus campos, e somá-los. Estas operações são feitas através de métodos ao inves de acessar os campos diretamente, com a análise de escape desligada primeiro, depois ligada.
Antes de mais nada, a EA só pode ser ativada na jvm server (ou seja, está presente apenas na JDK6U14 e não na JRE6U14) e com uma opção não padrão (daquelas que começam com -XX)
-XX:+DoEscapeAnalysis

A parte principal do código:


public void execute() {
for (int i = 0; i < max; i++) {
Foo foo = new Foo();
foo.setNumA(i);
foo.setNumB(i * i);
total += foo.sum();
}
}



Os resultados foram:

Tempo de execução para 1000000000 iterações
client vm: 11922 ms
server vm: 8452 ms
server vm + EA: 1611ms

Ou, em iterações por millisegundo:
client vm: 83872
server vm: 118306
server vm + EA: 620578

Como pode ser visto, usando a EA, o pequeno teste ficou mais de 6x mais rápido. Claro que estes números não devem ser lidos literalmente, pois este teste foi especificamente designado para tirar vantagem da análise - em outras palavras não espere esta melhoria no eclipse do dia-a-dia ou no jboss em produção.

Lock elision
Lock elision é uma das técnicas que poderia ser uma gigantesca vantagem das linguagens dinamicamente compiladas sobre as estaticamente compiladas. E o update 14 especificamente menciona que com a análise de escape ativada, os locks também seriam eliminados. Executando o mesmo teste, agora com todos os métodos marcados como sincronizados, temos:

Alterando o código anterior para:

public void execute() {
for (int i = 0; i < max; i++) {
SFoo foo = new SFoo();
foo.setNumA(i);
foo.setNumB(i * i);
total += foo.sum();
}
}

Onde SFoo é idêntico a Foo, porém com seus métodos marcados como synchronized.

Tempo de execução para 100000000 (perceba que eu tive que usar 10x menos operações para a versão sincronizada):
client vm: 6337 ms
server vm: 4184 ms
server vm + EA: 276 ms (!!!)

Ou, em iterações por millisegundo:
client vm: 15778
server vm: 23897
server vm + EA: 361271

Ou seja, o speedup aqui foi de 21x - bem melhor do que a versão não sincronizada. Este resultado sugere que o lock elision é, de alguma forma feito. Porém, se compararmos somente os resultados da EA, a versão sincronizada é quase 2x mais lenta, o que sugere que a JIT não elimina todo o overhead da entrada e saída do monitor - quem sabe para continuar com algumas garantias de visibilidade.

Já se alterarmos o código inicial para:

public void execute() {
for (int i = 0; i < max; i++) {
Foo foo = new Foo();
synchronized (foo) {
foo.setNumA(i);
foo.setNumB(i * i);
total += foo.sum();
}
}
}

Seria esperado que os resultados fossem parecidos com o primeiro caso, já que o monitor do objeto foo só é adquirido uma vez por iteração. Vamos aos resultados (dessa vez apenas com o EA ativado), desta vez combinado com outras opções presentes antes do update 14 que poderiam afetar este teste:

Tempo em millisegundos para 1000000000 iterações:
server + EA: 9606ms
server + EA + Eliminate locks: 9543ms
server + EA + Biased locking: 9599ms
server + todas acima: 9515ms

As opções acima (Eliminate Locks e Biased Locking) são ativadas respectivamente por: -XX:+EliminateLocks e -XX:+UseBiasedLocking e, supostamente, são ativadas por padrão.
Aparentemente, um bloco sincronizado maior limita as otimizações que podem ser feitas pela análise de escape - os resultados obtidos estão aproximadamente 3x piores do que o teste anterior (com os métodos marcados com 'synchronized').

G1 e "Compressed OOPTS"
Além da EA, o update 14 trouxe também um novo garbage collector chamado G1 (garbage first), apresentado no JavaOne 2008 e ainda em estágio experimental. Trata-se de um GC paralelo e que tenta garantir uma pausa máxima (que pode ser definida pela linha de comando) e pode ser ativada pela combinação de opções:
-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC
Por último, os "compressed object pointers" também foram integrados. Quem usa uma jvm de 64bits já deve ter notado que o consumo de memória é bem maior do que na jvm de 32 bits. Isso ocorre porque as referências para objetos ocupam 64 bits, mesmo quando todo o espaço de endereçamento não é necessário. Com esta opção nas JVM de 64 bits, as referências para objetos continuam com 32 bits a menos que seja necessário. Esta opção é ativada por:

-XX:+UseCompressedOops


e já existia nas JVM de "performance".


Mais informações sobre o G1 podem ser encontradas em:

e uma pequena demonstração dos ponteiros comprimidos pode ser vista em:
(ainda na jvm6 de performance)

Conclusão
Estas últimas atualizações podem trazer ganhos de performance muito bons para alguns cenários, embora haja alguma limitação no que a JVM pode fazer na presença de locks - o que pode muito bem ser corrigido em updates futuros ou quem sabe no jdk7. Embora eu espere que o JDK7 não demore a sair, acho muito interessante que a Sun não esteja se contendo em aperfeiçoar a sua JVM mesmo nas atualizações menores.







public static void main(String[] args) {
//yey
/**
* woot
*/
}