O seguinte programa Java leva em média entre 0,50 segundos e 0,55 segundos para executar:
public static void main(String[] args) {
long startTime = System.nanoTime();
int n = 0;
for (int i = 0; i < 1000000000; i++) {
n += 2 * (i * i);
}
System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
System.out.println("n = " + n);
}
Se eu substituir 2 * (i * i)
por 2 * i * i
, leva entre 0,60 e 0,65 segundos para executar. Por quê?
Eu executei cada versão do programa 15 vezes, alternando entre as duas. Aqui estão os resultados:
2*(i*i) | 2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149 | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412 | 0.6393969
0.5466744 | 0.6608845
0.531159 | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526
A corrida mais rápida 2 * i * i
demorou mais que a mais lenta 2 * (i * i)
. Se eles tivessem a mesma eficiência, a probabilidade de isso acontecer seria menor que 1/2^15 * 100% = 0.00305%
.
java
performance
benchmarking
bytecode
jit
Stefan
fonte
fonte
2 * i * i
é mais lento. Vou tentar correr com Graal também.i * i * 2
mais rápido que2 * i * i
? " Para maior clareza de que o problema está na ordem das operações.Respostas:
Há uma pequena diferença na ordem do bytecode.
2 * (i * i)
:vs
2 * i * i
:À primeira vista, isso não deve fazer diferença; se alguma coisa a segunda versão é mais ideal, pois usa um slot a menos.
Portanto, precisamos nos aprofundar no nível mais baixo (JIT) 1 .
Lembre-se de que o JIT tende a desenrolar pequenos loops de forma muito agressiva. De fato, observamos um desenrolar de 16x para o
2 * (i * i)
caso:Vemos que há 1 registro "derramado" na pilha.
E para a
2 * i * i
versão:Aqui observamos muito mais "derramamento" e mais acessos à pilha
[RSP + ...]
, devido a resultados mais intermediários que precisam ser preservados.Portanto, a resposta para a pergunta é simples:
2 * (i * i)
é mais rápida do que2 * i * i
porque o JIT gera um código de montagem mais ideal para o primeiro caso.Mas é claro que é óbvio que nem a primeira nem a segunda versão são boas; o loop poderia realmente se beneficiar da vetorização, já que qualquer CPU x86-64 tem pelo menos suporte a SSE2.
Portanto, é uma questão do otimizador; como costuma ser o caso, ele se desenrola muito agressivamente e atira no próprio pé, perdendo o tempo todo em várias outras oportunidades.
De fato, as modernas CPUs x86-64 dividem ainda mais as instruções em micro-ops (µops) e com recursos como renomeação de registros, caches µop e buffers de loop, a otimização de loop requer muito mais requinte do que um simples desenrolar para obter o desempenho ideal. De acordo com o guia de otimização da Agner Fog :
Com relação a esses tempos de carregamento - até o mais rápido acerto L1D custa 4 ciclos , um registro extra e µop, então sim, mesmo alguns acessos à memória prejudicam o desempenho em ciclos apertados.
Mas voltando à oportunidade de vetorização - para ver o quão rápido pode ser, podemos compilar um aplicativo C semelhante com o GCC , que o vetoriza completamente (AVX2 é mostrado, SSE2 é semelhante) 2 :
Com tempos de execução:
1 Para obter a saída de montagem gerada pelo JIT, obtenha uma JVM de depuração e execute com
-XX:+PrintOptoAssembly
2 A versão C é compilada com o
-fwrapv
sinalizador, que permite ao GCC tratar o excesso de número inteiro assinado como um complemento de dois.fonte
ret
instrução, ou emita um rótulo e nenhuma instrução ret, para que a execução caia completamente. De fato, o GCC se comporta de vez em quando quando encontra o UB. Por exemplo: por que ret desaparecer com otimização? . Você definitivamente deseja compilar um código bem formado para garantir que o asm esteja correto.mov
/add-immediate
. por exemplo,movl RBX, R9
/addl RBX, #8
deveria serleal ebx, [r9 + 8]
, 1 cópia para copiar e adicionar. Ouleal ebx, [r9 + r9 + 16]
para fazerebx = 2*(r9+8)
. Então, sim, desenrolar-se a ponto de derramar é estúpido, e também é ingênuo o codegen braindead que não tira proveito das identidades inteiras e da matemática associativa.Quando a multiplicação ocorre
2 * (i * i)
, a JVM pode fatorar a multiplicação pelo2
loop, resultando neste código equivalente, mas mais eficiente:mas quando a multiplicação ocorre
(2 * i) * i
, a JVM não a otimiza, pois a multiplicação por uma constante não está mais logo antes da adição.Aqui estão algumas razões pelas quais eu acho que esse é o caso:
if (n == 0) n = 1
instrução no início do loop resulta em ambas as versões tão eficientes, já que fatorar a multiplicação não garante mais que o resultado será o mesmo2 * (i * i)
versãoAqui está o código de teste que eu usei para tirar essas conclusões:
E aqui estão os resultados:
fonte
n *= 2000000000;
2*1*1 + 2*2*2 + 2*3*3
. É óbvio que calcular1*1 + 2*2 + 3*3
e multiplicar por 2 está correto, enquanto que multiplicar por 8 não seria.2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²)
. Isso foi muito simples e eu esqueci porque o loop aumenta.2 * (i * i)
mas não de(2 * i) * i
? Eu pensaria que eles são equivalentes (essa pode ser minha suposição ruim). Nesse caso, a JVM não canonizaria a expressão antes de otimizar?Códigos de bytes: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Visualizador de códigos de bytes: https://github.com/Konloch/bytecode-viewer
No meu JDK (Windows 10 de 64 bits, 1.8.0_65-b17), posso reproduzir e explicar:
Resultado:
Então por que? O código de byte é este:
A diferença é: entre parênteses (
2 * (i * i)
):Sem colchetes (
2 * i * i
):Carregar tudo na pilha e depois voltar a trabalhar é mais rápido do que alternar entre colocar a pilha e operar nela.
fonte
Kasperd perguntou em um comentário da resposta aceita:
Não tenho reputação suficiente para responder a isso nos comentários, mas esses são os mesmos ISA. Vale ressaltar que a versão do GCC usa lógica inteira de 32 bits e a versão compilada da JVM usa lógica inteira de 64 bits internamente.
R8 a R15 são apenas novos X86_64 registros . EAX para EDX são as partes inferiores dos registros de uso geral RAX para RDX. A parte importante da resposta é que a versão do GCC não é desenrolada. Ele simplesmente executa uma rodada do loop por loop de código de máquina real. Embora a versão da JVM tenha 16 rodadas de loop em um loop físico (com base na resposta da rustyx, não reinterpretei o assembly). Essa é uma das razões pelas quais há mais registros sendo usados, já que o corpo do loop é na verdade 16 vezes mais longo.
fonte
*2
fora do loop. Embora neste caso, não seja nem uma vitória fazer isso, porque está fazendo isso de graça com o LEA. Em CPUs Intel,lea eax, [rax+rcx*2]
possui a mesma latência 1c queadd eax,ecx
. No entanto, nas CPUs AMD, qualquer índice escalado aumenta a latência do LEA para 2 ciclos. Portanto, a cadeia de dependência transportada por loop aumenta para 2 ciclos, tornando-se o gargalo da Ryzen. (aimul ecx,edx
taxa de transferência é de 1 por relógio no Ryzen e na Intel).Embora não esteja diretamente relacionado ao ambiente da pergunta, apenas por curiosidade, fiz o mesmo teste no .NET Core 2.1, x64, modo de lançamento.
Aqui está o resultado interessante, confirmando fonomena semelhante (ao contrário) acontecendo no lado escuro da força. Código:
Resultado:
2 * (i * i)
2 * i * i
fonte
Eu obtive resultados semelhantes:
Eu obtive os mesmos resultados se ambos os loops estavam no mesmo programa ou cada um estava em um arquivo .java / .class separado, executado em uma execução separada.
Finalmente, aqui está uma
javap -c -v <.java>
descompilação de cada um:vs.
PARA SUA INFORMAÇÃO -
fonte
-XX:+PrintOptoAssembly
. Ou apenas use vtune ou similar.Observação interessante usando Java 11 e desativando o desenrolamento de loop com a seguinte opção de VM:
O loop com a
2 * (i * i)
expressão resulta em código nativo 1 mais compacto :em comparação com a
2 * i * i
versão:Versão Java:
Resultados de referência:
Código fonte de referência:
1 - Opções de VM usadas:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0
fonte
i
antes de copiá-lo para calcular2*i
, ele faz isso depois, portanto, é necessária umaadd r11d,2
instrução extra . (Além disso, ele perde oadd same,same
olho mágico em vez deshl
1 (o add é executado em mais portas). Ele também perde o olho mágico de LEA parax*2 + 2
(lea r11d, [r8*2 + 2]
) se ele realmente deseja fazer as coisas nessa ordem por algum motivo maluco de programação de instruções. a versão desenrolada que a falta de LEA estava custando-lhe um monte de UOPs, mesmo que ambos os loops aqui.lea eax, [rax + r11 * 2]
substituiria duas instruções (em ambos os loops) se o compilador JIT tivesse tempo para procurar essa otimização em loops de longa execução. Qualquer compilador decente com antecedência encontraria. (A não ser que talvez ajuste somente para AMD, onde índice escalado LEA tem 2 ciclo de latência talvez por isso não vale a pena.)Tentei um JMH usando o arquétipo padrão: também adicionei uma versão otimizada com base na explicação de Runemoro .
O resultado está aqui:
No meu PC ( Core i7 860 - não está fazendo nada além de ler no meu smartphone):
n += i*i
entãon*2
é o primeiro2 * (i * i)
é o segundo.A JVM claramente não está otimizando da mesma maneira que um humano (com base na resposta de Runemoro).
Agora, então, lendo o bytecode:
javap -c -v ./target/classes/org/sample/MyBenchmark.class
Não sou especialista em bytecode, mas
iload_2
antes de nósimul
: provavelmente é aí que você obtém a diferença: suponho que a JVM otimize a leiturai
duas vezes (i
já está aqui e não há necessidade de carregá-la novamente) enquanto estiver no2*i*i
modo ' t.fonte
Mais um adendo. Eu reproduzi o experimento usando o Java 8 JVM mais recente da IBM:
E isso mostra resultados muito semelhantes:
(segundos resultados usando 2 * i * i).
Curiosamente, ao executar na mesma máquina, mas usando o Oracle Java:
os resultados são, em média, um pouco mais lentos:
Resumindo: até o número menor de versão do HotSpot é importante aqui, pois diferenças sutis na implementação do JIT podem ter efeitos notáveis.
fonte
Os dois métodos de adição geram código de bytes ligeiramente diferente:
Para
2 * (i * i)
vs:Para
2 * i * i
.E ao usar um benchmark JMH como este:
A diferença é clara:
O que você observa está correto, e não apenas uma anomalia do seu estilo de benchmarking (ou seja, sem aquecimento, consulte Como redigir um micro-benchmark correto em Java? )
Correndo novamente com Graal:
Você vê que os resultados são muito mais próximos, o que faz sentido, já que Graal é um compilador de melhor desempenho e mais moderno.
Portanto, isso depende apenas de quão bem o compilador JIT é capaz de otimizar uma parte específica do código e não necessariamente tem uma razão lógica para isso.
fonte