Perché un ciclo Java da 4 miliardi di iterazioni richiede solo 2 ms?


113

Sto eseguendo il seguente codice Java su un laptop con Intel Core i7 a 2,7 GHz. Volevo fargli misurare il tempo necessario per completare un ciclo con 2 ^ 32 iterazioni, che mi aspettavo fossero circa 1,48 secondi (4 / 2,7 = 1,48).

Ma in realtà ci vogliono solo 2 millisecondi, invece di 1,48 s. Mi chiedo se questo sia il risultato di un'ottimizzazione JVM sottostante?

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
Beh si. Poiché il corpo del ciclo non ha effetti collaterali, il compilatore lo elimina abbastanza felicemente. Esaminare il byte-code con javap -vper vedere.
Elliott Frisch

36
Non lo vedrai di nuovo nel codice byte. javacfa pochissima ottimizzazione effettiva e lascia la maggior parte al compilatore JIT.
Jorn Vernee

4
"Mi chiedo se questo sia il risultato di un'ottimizzazione JVM sottostante?" - Cosa pensi? Cos'altro potrebbe essere se non un'ottimizzazione JVM?
apangin

7
La risposta a questa domanda è sostanzialmente contenuta in stackoverflow.com/a/25323548/3182664 . Contiene anche l'assembly risultante (codice macchina) che JIT genera per tali casi, dimostrando che il ciclo è completamente ottimizzato da JIT . (La domanda su stackoverflow.com/q/25326377/3182664 mostra che potrebbe volerci un po 'di più se il ciclo non esegue 4 miliardi di operazioni, ma 4 miliardi meno una ;-)). Quasi considererei questa domanda come un duplicato dell'altra - qualche obiezione?
Marco13

7
Presumi che il processore eseguirà un'iterazione per Hz. Questo è un presupposto di vasta portata. I processori oggi eseguono ogni sorta di ottimizzazione, come ha menzionato @Rahul, ea meno che tu non sappia molto di più su come funziona il Core i7, non puoi presumerlo.
Tsahi Asher

Risposte:


106

Ci sono una delle due possibilità in corso qui:

  1. Il compilatore si è reso conto che il ciclo è ridondante e non fa nulla, quindi lo ha ottimizzato.

  2. Il JIT (compilatore just-in-time) si è reso conto che il ciclo è ridondante e non fa nulla, quindi lo ha ottimizzato.

I compilatori moderni sono molto intelligenti; possono vedere quando il codice è inutile. Prova a inserire un loop vuoto in GodBolt e guarda l'output, quindi attiva le -O2ottimizzazioni, vedrai che l'output è qualcosa sulla falsariga di

main():
    xor eax, eax
    ret

Vorrei chiarire una cosa, in Java la maggior parte delle ottimizzazioni vengono effettuate dal JIT. In alcuni altri linguaggi (come C / C ++) la maggior parte delle ottimizzazioni viene eseguita dal primo compilatore.


Il compilatore è autorizzato a fare tali ottimizzazioni? Non so per certo per Java, ma i compilatori .NET dovrebbero generalmente evitarlo per consentire a JIT di eseguire le migliori ottimizzazioni per la piattaforma.
IllidanS4 vuole che Monica torni il

1
@ IllidanS4 In generale, questo dipende dallo standard della lingua. Se il compilatore può eseguire ottimizzazioni che significano che il codice, interpretato dallo standard, ha lo stesso effetto, allora sì. Ci sono molte sottigliezze da considerare, ad esempio, ci sono alcune trasformazioni per i calcoli in virgola mobile che possono comportare la possibilità di introdurre over / underflow, quindi qualsiasi ottimizzazione deve essere eseguita con attenzione.
user1997744

9
@ IllidanS4 come dovrebbe l'ambiente di runtime essere in grado di fare una migliore ottimizzazione? Per lo meno deve analizzare il codice che non può essere più veloce della rimozione del codice durante la compilazione.
Gerhardh

2
@ Gerhardh non stavo parlando di questo caso preciso, quando il runtime non può fare un lavoro migliore nella rimozione di parti ridondanti di codice, ma ci possono essere ovviamente alcuni casi in cui questo motivo è corretto. E poiché possono esserci altri compilatori per JRE da altri linguaggi, anche il runtime dovrebbe eseguire queste ottimizzazioni, quindi potenzialmente non c'è motivo per farlo sia dal runtime che dal compilatore.
IllidanS4 vuole che Monica torni il

6
@ IllidanS4 qualsiasi ottimizzazione del runtime non può richiedere meno di zero tempo. Impedire al compilatore di rimuovere il codice non avrebbe alcun senso.
Gerhardh

55

Sembra che sia stato ottimizzato dal compilatore JIT. Quando lo spengo ( -Djava.compiler=NONE), il codice viene eseguito molto più lentamente:

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

Ho inserito il codice di OP all'interno di class MyClass.


2
Strano. Quando eseguo il codice in entrambi i modi, è più veloce senza il flag, ma solo di un fattore 10, e l'aggiunta o la rimozione di zeri al numero di iterazioni nel ciclo influisce anche sul tempo di esecuzione per fattori di dieci, con e senza il bandiera. Quindi (per me) il ciclo sembra non essere completamente ottimizzato, ma solo 10 volte più veloce, in qualche modo. (Oracle Java 8-151)
tobias_k

@tobias_k dipende da quale fase del JIT sta attraversando il ciclo immagino stackoverflow.com/a/47972226/1059372
Eugene

21

Affermerò solo l'ovvio: che si tratta di un'ottimizzazione JVM che si verifica, il ciclo verrà semplicemente rimosso. Ecco un piccolo test che mostra l' enorme differenza JITquando è abilitato / abilitato solo per C1 Compilere disabilitato del tutto.

Dichiarazione di non responsabilità: non scrivere test in questo modo - questo è solo per dimostrare che l'effettiva "rimozione" del ciclo avviene in 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;
    }
}

I risultati mostrano che, a seconda di quale parte di JITè abilitata, il metodo diventa più veloce (così tanto che sembra che non stia facendo "niente" - rimozione del ciclo, che sembra accadere nel C2 Compiler- che è il livello massimo):

 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

Come già sottolineato, il compilatore JIT (just-in-time) può ottimizzare un ciclo vuoto per rimuovere iterazioni non necessarie. Ma come?

In realtà, ci sono due compilatori JIT: C1 e C2 . Innanzitutto, il codice viene compilato con C1. C1 raccoglie le statistiche e aiuta la JVM a scoprire che nel 100% dei casi il nostro loop vuoto non cambia nulla ed è inutile. In questa situazione entra in scena C2. Quando il codice viene chiamato molto spesso, può essere ottimizzato e compilato con C2 utilizzando le statistiche raccolte.

Ad esempio, testerò il prossimo frammento di codice (il mio JDK è impostato su 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!");
    }
}

Con le seguenti opzioni della riga di comando:

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

E ci sono diverse versioni del mio metodo di esecuzione , compilate in modo appropriato con C1 e C2. Per me, la variante finale (C2) è simile a questa:

...

; 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

...

È un po 'disordinato, ma se guardi da vicino, potresti notare che non c'è un ciclo di lunga durata qui. Ci sono 3 blocchi: B1, B2 e B3 e le fasi di esecuzione possono essere B1 -> B2 -> B3o B1 -> B3. Dove Freq: 1: frequenza stimata normalizzata di un'esecuzione di blocco.


8

Stai misurando il tempo necessario per rilevare che il ciclo non fa nulla, compila il codice in un thread in background ed elimina il codice.

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 lo esegui con -XX:+PrintCompilationpuoi vedere che il codice è stato compilato in background al compilatore di livello 3 o C1 e dopo alcuni cicli al livello 4 di 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 si modifica il ciclo per utilizzare un long, non viene ottimizzato.

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

invece ottieni

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

È strano ... perché un longcontatore impedirebbe la stessa ottimizzazione?
Ryan Amos

@RyanAmos l'ottimizzazione viene applicata solo al conteggio dei loop primitivi comuni se il tipo di intnota char e short sono effettivamente uguali a livello di codice byte.
Peter Lawrey

-1

Considera l'ora di inizio e di fine in nanosecondi e dividi per 10 ^ 6 per calcolare la latenza

long d = (finish - start) / 1000000

dovrebbe essere 10^9perché 1secondo = 10^9nanosecondo.


Quello che hai suggerito è irrilevante per il mio punto. Quello che mi chiedevo è quanto tempo ci è voluto e non importa se questa durata è stampata / rappresentata in termini di millisecondi o secondi.
twimo
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.