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);
}

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.


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.


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 

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.


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

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.


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
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.