Por que um loop Java de 4 bilhões de iterações leva apenas 2 ms?

113

Estou executando o seguinte código Java em um laptop com Intel Core i7 de 2,7 GHz. Eu pretendia deixá-lo medir quanto tempo leva para terminar um loop com 2 ^ 32 iterações, que esperava ser cerca de 1,48 segundos (4 / 2,7 = 1,48).

Mas, na verdade, leva apenas 2 milissegundos, em vez de 1,48 s. Estou me perguntando se isso é resultado de alguma otimização JVM subjacente?

public static void main(String[] args)
{
    long start = System.nanoTime();

    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++){
    }
    long finish = System.nanoTime();
    long d = (finish - start) / 1000000;

    System.out.println("Used " + d);
}
twimo
fonte
69
Bem, sim. Como o corpo do loop não tem efeitos colaterais, o compilador o elimina alegremente. Examine o código de byte javap -vpara ver.
Elliott Frisch
36
Você não verá isso no byte-code. javacfaz muito pouca otimização real e deixa a maior parte para o compilador JIT.
Jorn Vernee
4
'Estou me perguntando se isso é resultado de alguma otimização JVM subjacente? - O que você acha? O que mais poderia ser senão uma otimização JVM?
apangin
7
A resposta a essa pergunta está basicamente contida em stackoverflow.com/a/25323548/3182664 . Ele também contém a montagem resultante (código de máquina) que o JIT gera para tais casos, mostrando que o loop é totalmente otimizado pelo JIT . (A pergunta em stackoverflow.com/q/25326377/3182664 mostra que pode demorar um pouco mais se o loop não fizer 4 bilhões de operações, mas 4 bilhões menos um ;-)). Eu quase consideraria esta pergunta como uma duplicata da outra - alguma objeção?
Marco13
7
Você assume que o processador realizará uma iteração por Hz. Essa é uma suposição de longo alcance. Os processadores hoje executam todos os tipos de otimizações, como @Rahul mencionou, e a menos que você saiba muito mais sobre como o Core i7 funciona, você não pode presumir isso.
Tsahi Asher

Respostas:

106

Existem duas possibilidades acontecendo aqui:

  1. O compilador percebeu que o loop é redundante e não fez nada para otimizá-lo.

  2. O JIT (compilador just-in-time) percebeu que o loop é redundante e não fazia nada, então o otimizou.

Os compiladores modernos são muito inteligentes; eles podem ver quando o código é inútil. Tente colocar um loop vazio em GodBolt e olhar para a saída, em seguida, ative as -O2otimizações, você verá que a saída é algo na linha de

main():
    xor eax, eax
    ret

Gostaria de esclarecer algo, em Java a maioria das otimizações são feitas pelo JIT. Em algumas outras linguagens (como C / C ++), a maioria das otimizações é feita pelo primeiro compilador.

van dench
fonte
O compilador tem permissão para fazer essas otimizações? Não sei ao certo para Java, mas os compiladores .NET geralmente devem evitar isso para permitir que o JIT faça as melhores otimizações para a plataforma.
IllidanS4 quer Monica de volta em
1
@ IllidanS4 Em geral, isso depende do padrão do idioma. Se o compilador pode realizar otimizações que significam que o código, interpretado pelo padrão, tem o mesmo efeito, então sim. Existem muitas sutilezas que devem ser consideradas, por exemplo, existem algumas transformações para cálculos de ponto flutuante que podem resultar na possibilidade de over / underflow sendo introduzido, portanto, qualquer otimização deve ser feita com cuidado.
user1997744
9
@ IllidanS4 como o ambiente de execução deve ser capaz de fazer uma otimização melhor? No mínimo, ele deve analisar o código, o que não pode ser mais rápido do que remover o código durante a compilação.
Gerhardh
2
@Gerhardh Eu não estava falando sobre esse caso preciso, quando o tempo de execução não pode fazer um trabalho melhor na remoção de partes redundantes do código, mas pode haver, é claro, alguns casos em que esse motivo está correto. E como pode haver outros compiladores para JRE de outras linguagens, o tempo de execução também deve fazer essas otimizações, portanto, não há potencialmente nenhuma razão para que sejam feitas tanto pelo tempo de execução quanto pelo compilador.
IllidanS4 quer Monica de volta
6
@ IllidanS4 qualquer otimização de tempo de execução não pode levar menos que tempo zero. Impedir que o compilador remova o código não faria nenhum sentido.
Gerhardh
55

Parece que foi otimizado pelo compilador JIT. Quando eu o desligo ( -Djava.compiler=NONE), o código fica muito mais lento:

$ javac MyClass.java
$ java MyClass
Used 4
$ java -Djava.compiler=NONE MyClass
Used 40409

Eu coloquei o código do OP dentro de class MyClass.

Akavall
fonte
2
Esquisito. Quando executo o código nos dois sentidos, é mais rápido sem o sinalizador, mas apenas por um fator de 10, e adicionar ou remover zeros ao número de iterações no loop também afeta o tempo de execução por fatores de dez, com e sem o bandeira. Então (para mim) o loop parece não ter sido totalmente otimizado, apenas feito 10 vezes mais rápido, de alguma forma. (Oracle Java 8-151)
tobias_k
@tobias_k depende de qual estágio do JIT o loop está passando, eu acho stackoverflow.com/a/47972226/1059372
Eugene
21

Vou apenas declarar o óbvio - que se trata de uma otimização JVM que acontece, o loop simplesmente será removido. Aqui está um pequeno teste que mostra a grande diferença JITquando ativado / ativado apenas para C1 Compilere desativado em tudo.

Isenção de responsabilidade: não escreva testes como este - isso é apenas para provar que a "remoção" do loop real acontece no C2 Compiler:

@Benchmark
@Fork(1)
public void full() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        ++result;
    }
}

@Benchmark
@Fork(1)
public void minusOne() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-XX:TieredStopAtLevel=1" })
public void withoutC2() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

@Benchmark
@Fork(value = 1, jvmArgsAppend = { "-Xint" })
public void withoutAll() {
    long result = 0;
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++) {
        ++result;
    }
}

Os resultados mostram que dependendo de qual parte do JITestá habilitado, o método fica mais rápido (muito mais rápido que parece que está fazendo "nada" - remoção de loop, o que parece estar acontecendo no C2 Compiler- que é o nível máximo):

 Benchmark                Mode  Cnt      Score   Error  Units
 Loop.full        avgt    2      10⁻⁷          ms/op
 Loop.minusOne    avgt    2      10⁻⁶          ms/op
 Loop.withoutAll  avgt    2  51782.751          ms/op
 Loop.withoutC2   avgt    2   1699.137          ms/op 
Eugene
fonte
13

Conforme já apontado, o compilador JIT (just-in-time) pode otimizar um loop vazio para remover iterações desnecessárias. Mas como?

Na verdade, existem dois compiladores JIT: C1 e C2 . Primeiro, o código é compilado com o C1. C1 coleta as estatísticas e ajuda a JVM a descobrir que, em 100% dos casos, nosso loop vazio não muda nada e é inútil. Nesta situação C2 entra em cena. Quando o código é chamado com muita frequência, ele pode ser otimizado e compilado com o C2 usando as estatísticas coletadas.

Como exemplo, testarei o próximo snippet de código (meu JDK está configurado para slowdebug build 9-internal ):

public class Demo {
    private static void run() {
        for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
        }
        System.out.println("Done!");
    }
}

Com as seguintes opções de linha de comando:

-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Demo.run

E existem diferentes versões do meu método de execução , compilado com o C1 e C2 apropriadamente. Para mim, a variante final (C2) é mais ou menos assim:

...

; B1: # B3 B2 <- BLOCK HEAD IS JUNK  Freq: 1
0x00000000125461b0: mov   dword ptr [rsp+0ffffffffffff7000h], eax
0x00000000125461b7: push  rbp
0x00000000125461b8: sub   rsp, 40h
0x00000000125461bc: mov   ebp, dword ptr [rdx]
0x00000000125461be: mov   rcx, rdx
0x00000000125461c1: mov   r10, 57fbc220h
0x00000000125461cb: call  indirect r10    ; *iload_1

0x00000000125461ce: cmp   ebp, 7fffffffh  ; 7fffffff => 2147483647
0x00000000125461d4: jnl   125461dbh       ; jump if not less

; B2: # B3 <- B1  Freq: 0.999999
0x00000000125461d6: mov   ebp, 7fffffffh  ; *if_icmpge

; B3: # N44 <- B1 B2  Freq: 1       
0x00000000125461db: mov   edx, 0ffffff5dh
0x0000000012837d60: nop
0x0000000012837d61: nop
0x0000000012837d62: nop
0x0000000012837d63: call  0ae86fa0h

...

É um pouco confuso, mas se você olhar de perto, você notará que não há um loop de longa duração aqui. Existem 3 blocos: B1, B2 e B3 e as etapas de execução podem serB1 -> B2 -> B3 ou B1 -> B3. Onde Freq: 1- frequência estimada normalizada de execução de um bloco.

Oleksandr Pyrohov
fonte
8

Você está medindo o tempo que leva para detectar que o loop não faz nada, compila o código em um thread de segundo plano e elimina o código.

for (int t = 0; t < 5; t++) {
    long start = System.nanoTime();
    for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }
    long time = System.nanoTime() - start;

    String s = String.format("%d: Took %.6f ms", t, time / 1e6);
    Thread.sleep(50);
    System.out.println(s);
    Thread.sleep(50);
}

Se você executar isso com, -XX:+PrintCompilationpoderá ver que o código foi compilado em segundo plano para o compilador de nível 3 ou C1 e depois de alguns loops para o nível 4 de C4.

    129   34 %     3       A::main @ 15 (93 bytes)
    130   35       3       A::main (93 bytes)
    130   36 %     4       A::main @ 15 (93 bytes)
    131   34 %     3       A::main @ -2 (93 bytes)   made not entrant
    131   36 %     4       A::main @ -2 (93 bytes)   made not entrant
0: Took 2.510408 ms
    268   75 %     3       A::main @ 15 (93 bytes)
    271   76 %     4       A::main @ 15 (93 bytes)
    274   75 %     3       A::main @ -2 (93 bytes)   made not entrant
1: Took 5.629456 ms
2: Took 0.000000 ms
3: Took 0.000364 ms
4: Took 0.000365 ms

Se você alterar o loop para usar um, longele não ficará tão otimizado.

    for (long i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
    }

em vez disso você consegue

0: Took 1579.267321 ms
1: Took 1674.148662 ms
2: Took 1885.692166 ms
3: Took 1709.870567 ms
4: Took 1754.005112 ms
Peter Lawrey
fonte
Isso é estranho ... por que um longcontador impediria a mesma otimização de acontecer?
Ryan Amos
@RyanAmos a otimização só é aplicada à contagem de loop primitivo comum se a intnota de tipo char e short forem efetivamente iguais no nível de código de bytes.
Peter Lawrey
-1

Você considera o tempo de início e término em nanossegundos e divide por 10 ^ 6 para calcular a latência

long d = (finish - start) / 1000000

deve ser 10^9porque 1segundo = 10^9nanossegundo.

DHARMENDRA SINGH
fonte
O que você sugeriu é irrelevante para o meu ponto. O que eu queria saber é quanto tempo demorou, e não importa se essa duração é impressa / representada em termos de milissegundos ou segundos.
twimo