Warum dauert eine Java-Schleife mit 4 Milliarden Iterationen nur 2 ms?

113

Ich verwende den folgenden Java-Code auf einem Laptop mit 2,7 GHz Intel Core i7. Ich wollte es messen lassen, wie lange es dauert, eine Schleife mit 2 ^ 32 Iterationen zu beenden, was ungefähr 1,48 Sekunden (4 / 2,7 = 1,48) waren.

Tatsächlich dauert es jedoch nur 2 Millisekunden statt 1,48 s. Ich frage mich, ob dies auf eine darunter liegende JVM-Optimierung zurückzuführen ist.

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
quelle
69
Nun ja. Da der Loop-Body keine Nebenwirkungen hat, beseitigt der Compiler diese recht gerne. Untersuchen Sie den Bytecode mit, um javap -vzu sehen.
Elliott Frisch
36
Sie werden das nicht im Bytecode sehen. javacführt nur sehr wenig tatsächliche Optimierung durch und überlässt das meiste dem JIT-Compiler.
Jorn Vernee
4
"Ich frage mich, ob dies auf eine darunter liegende JVM-Optimierung zurückzuführen ist." - Was denken Sie? Was könnte es sonst sein, wenn nicht eine JVM-Optimierung?
Apangin
7
Die Antwort auf diese Frage ist im Wesentlichen in stackoverflow.com/a/25323548/3182664 enthalten . Es enthält auch die resultierende Assembly (Maschinencode), die die JIT für solche Fälle generiert, was zeigt, dass die Schleife durch die JIT vollständig optimiert wird . (Die Frage unter stackoverflow.com/q/25326377/3182664 zeigt, dass es etwas länger dauern kann, wenn die Schleife nicht 4 Milliarden Operationen ausführt , sondern 4 Milliarden minus eins ;-)). Ich würde diese Frage fast als Duplikat der anderen betrachten - irgendwelche Einwände?
Marco13
7
Sie gehen davon aus, dass der Prozessor eine Iteration pro Hz ausführt. Das ist eine weitreichende Annahme. Prozessoren führen heute alle Arten von Optimierungen durch, wie @Rahul erwähnt hat, und wenn Sie nicht viel mehr über die Funktionsweise des Core i7 wissen, können Sie dies nicht annehmen.
Tsahi Asher

Antworten:

106

Hier gibt es eine von zwei Möglichkeiten:

  1. Der Compiler hat erkannt, dass die Schleife redundant ist und nichts unternimmt, um sie zu optimieren.

  2. Der JIT (Just-in-Time-Compiler) hat erkannt, dass die Schleife redundant ist und nichts tut, und hat sie daher optimiert.

Moderne Compiler sind sehr intelligent; Sie können sehen, wann Code nutzlos ist. Versuchen Sie, eine leere Schleife in GodBolt einzufügen, und sehen Sie sich die Ausgabe an. Aktivieren Sie dann die -O2Optimierungen. Sie werden sehen, dass die Ausgabe etwas in der Art von ist

main():
    xor eax, eax
    ret

Ich möchte etwas klarstellen, in Java werden die meisten Optimierungen von der JIT vorgenommen. In einigen anderen Sprachen (wie C / C ++) werden die meisten Optimierungen vom ersten Compiler durchgeführt.

van dench
quelle
Darf der Compiler solche Optimierungen vornehmen? Ich weiß es nicht genau für Java, aber .NET-Compiler sollten dies generell vermeiden, damit die JIT die besten Optimierungen für die Plattform vornehmen kann.
IllidanS4 will Monica
1
@ IllidanS4 Im Allgemeinen hängt dies vom Sprachstandard ab. Wenn der Compiler Optimierungen durchführen kann, die bedeuten, dass der vom Standard interpretierte Code den gleichen Effekt hat, dann ja. Es gibt jedoch viele Feinheiten, die berücksichtigt werden müssen, z. B. gibt es einige Transformationen für Gleitkommaberechnungen, die dazu führen können, dass ein Über- / Unterlauf eingeführt wird. Daher muss jede Optimierung sorgfältig durchgeführt werden.
user1997744
9
@ IllidanS4 Wie soll die Laufzeitumgebung besser optimieren können? Zumindest muss der Code analysiert werden, der nicht schneller sein kann als das Entfernen des Codes während der Kompilierung.
Gerhardh
2
@Gerhardh Ich habe nicht über diesen genauen Fall gesprochen, in dem die Laufzeit redundante Teile des Codes nicht besser entfernen kann, aber es kann natürlich einige Fälle geben, in denen dieser Grund richtig ist. Und weil es von anderen Sprachen andere Compilern für JRE sein, die Laufzeit sollte tut auch diese Optimierungen, so gibt es möglicherweise keinen Grund für sie sowohl von der Laufzeit und dem Compiler getan.
IllidanS4 will Monica
6
@ IllidanS4 Jede Laufzeitoptimierung kann nicht weniger als null Zeit dauern. Es würde keinen Sinn machen, zu verhindern, dass der Compiler den Code entfernt.
Gerhardh
55

Es sieht so aus, als ob es vom JIT-Compiler optimiert wurde. Wenn ich es ausschalte ( -Djava.compiler=NONE), läuft der Code viel langsamer:

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

Ich habe den Code von OP in eingefügt class MyClass.

Akavall
quelle
2
Seltsam. Wenn ich den Code in beide Richtungen ausführe, ist er ohne Flag schneller, jedoch nur um den Faktor 10, und das Hinzufügen oder Entfernen von Nullen zur Anzahl der Iterationen in der Schleife wirkt sich auch auf die Laufzeit um den Faktor 10 mit und ohne aus Flagge. Also (für mich) scheint die Schleife nicht vollständig wegoptimiert zu sein, sondern irgendwie nur zehnmal schneller gemacht zu werden. (Oracle Java 8-151)
tobias_k
@tobias_k es hängt davon ab, welche Phase der JIT die Schleife durchläuft, denke ich stackoverflow.com/a/47972226/1059372
Eugene
21

Ich werde nur das Offensichtliche sagen - dass dies eine JVM-Optimierung ist, die stattfindet, die Schleife wird einfach überhaupt entfernt. Hier ist ein kleiner Test, der zeigt, welchen großen Unterschied JITes macht, wenn es nur für C1 Compiler/ überhaupt deaktiviert / aktiviert ist .

Haftungsausschluss: Schreiben Sie keine Tests wie diese - dies soll nur beweisen, dass die eigentliche "Entfernung" der Schleife in folgenden Bereichen erfolgt 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;
    }
}

Die Ergebnisse zeigen, dass je nachdem, welcher Teil der JITMethode aktiviert ist, die Methode schneller wird (so viel schneller, dass es so aussieht, als würde sie "nichts" tun - Schleifenentfernung, die in der C2 Compiler- was die maximale Ebene ist , zu geschehen scheint ):

 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
quelle
13

Wie bereits erwähnt, kann der JIT -Compiler (Just-in-Time) eine leere Schleife optimieren, um unnötige Iterationen zu entfernen. Aber wie?

Tatsächlich gibt es zwei JIT-Compiler: C1 und C2 . Zunächst wird der Code mit dem C1 kompiliert. C1 sammelt die Statistiken und hilft der JVM zu erkennen, dass unsere leere Schleife in 100% der Fälle nichts ändert und nutzlos ist. In dieser Situation betritt C2 die Bühne. Wenn der Code sehr oft aufgerufen wird, kann er mithilfe gesammelter Statistiken optimiert und mit dem C2 kompiliert werden.

Als Beispiel werde ich das nächste Code-Snippet testen (mein JDK ist auf slowdebug build 9-internal eingestellt ):

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

Mit den folgenden Befehlszeilenoptionen:

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

Und es gibt verschiedene Versionen meiner Ausführungsmethode , die mit C1 und C2 entsprechend kompiliert wurden. Für mich sieht die letzte Variante (C2) ungefähr so ​​aus:

...

; 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

...

Es ist ein bisschen chaotisch, aber wenn Sie genau hinschauen, werden Sie feststellen, dass es hier keine lange Schleife gibt. Es gibt 3 Blöcke: B1, B2 und B3 und die Ausführungsschritte können B1 -> B2 -> B3oder sein B1 -> B3. Wobei Freq: 1- normalisierte geschätzte Häufigkeit einer Blockausführung.

Oleksandr Pyrohov
quelle
8

Sie messen die Zeit, die benötigt wird, um zu erkennen, dass die Schleife nichts bewirkt, kompilieren den Code in einem Hintergrundthread und entfernen den Code.

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

Wenn Sie dies mit ausführen, können -XX:+PrintCompilationSie sehen, dass der Code im Hintergrund auf Level 3 oder C1 Compiler und nach einigen Schleifen auf Level 4 von C4 kompiliert wurde.

    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

Wenn Sie die Schleife ändern, um eine zu verwenden, wird longsie nicht so optimiert.

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

stattdessen bekommst du

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
quelle
Das ist seltsam ... warum sollte ein longZähler die gleiche Optimierung verhindern?
Ryan Amos
@RyanAmos Die Optimierung wird nur auf die Anzahl der gemeinsamen primitiven Schleifen angewendet, wenn der Typ intnote char und short auf Bytecode-Ebene effektiv identisch sind.
Peter Lawrey
-1

Sie berücksichtigen die Start- und Endzeit in Nanosekunden und dividieren durch 10 ^ 6, um die Latenz zu berechnen

long d = (finish - start) / 1000000

es sollte sein, 10^9weil 1Sekunde = 10^9Nanosekunde.

DHARMENDRA SINGH
quelle
Was Sie vorgeschlagen haben, ist für mich irrelevant. Ich habe mich gefragt, wie lange es gedauert hat, und es spielt keine Rolle, ob diese Dauer in Millisekunden oder Sekunden gedruckt / dargestellt wird.
Twimo