Warum ist 2 * (i * i) in Java schneller als 2 * i * i?


855

Die Ausführung des folgenden Java-Programms dauert durchschnittlich zwischen 0,50 und 0,55 Sekunden:

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

Wenn ich ersetzen 2 * (i * i)mit 2 * i * i, dauert es zwischen 0,60 und 0,65 Sekunden zu laufen. Woher?

Ich habe jede Version des Programms 15 Mal ausgeführt, abwechselnd zwischen den beiden. Hier sind die Ergebnisse:

 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

Der schnellste Lauf von 2 * i * idauerte länger als der langsamste Lauf von 2 * (i * i). Wenn sie die gleiche Effizienz hätten, wäre die Wahrscheinlichkeit, dass dies geschieht, geringer als 1/2^15 * 100% = 0.00305%.


5
Ich erhalte ähnliche Ergebnisse (leicht unterschiedliche Zahlen, aber definitiv wahrnehmbare und konsistente Lücke, definitiv mehr als Stichprobenfehler)
Krease


3
@Krease Gut, dass du meinen Fehler entdeckt hast. Nach dem neuen Benchmark, den ich lief, 2 * i * iist es langsamer. Ich werde auch versuchen, mit Graal zu laufen.
Jorn Vernee

5
@nullpointer Um wirklich herauszufinden, warum einer schneller als der andere ist, müssten wir die Demontage oder die idealen Diagramme für diese Methoden erhalten. Der Assembler ist sehr nervig, um herauszufinden, also versuche ich, einen OpenJDK-Debug-Build zu bekommen, der schöne Grafiken ausgeben kann.
Jorn Vernee

4
Sie können Ihre Frage in " Warum ist i * i * 2schneller als 2 * i * i? " Umbenennen, um die Klarheit zu verbessern, dass das Problem in der Reihenfolge der Vorgänge liegt.
Cœur

Antworten:


1202

Es gibt einen kleinen Unterschied in der Reihenfolge des Bytecodes.

2 * (i * i)::

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

Auf den ersten Blick sollte dies keinen Unterschied machen; Wenn überhaupt, ist die zweite Version optimaler, da sie einen Steckplatz weniger verwendet.

Wir müssen also tiefer in die untere Ebene (JIT) 1 graben .

Denken Sie daran, dass JIT dazu neigt, kleine Schleifen sehr aggressiv abzuwickeln. In der Tat beobachten wir ein 16-faches Abrollen für den 2 * (i * i)Fall:

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

Wir sehen, dass es 1 Register gibt, das auf den Stapel "verschüttet" wird.

Und für die 2 * i * iVersion:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

Hier beobachten wir viel mehr "Verschütten" und mehr Zugriffe auf den Stapel [RSP + ...], da mehr Zwischenergebnisse erhalten bleiben müssen.

Die Antwort auf die Frage ist daher einfach: Sie 2 * (i * i)ist schneller als 2 * i * iweil die JIT für den ersten Fall einen optimaleren Assembler-Code generiert.


Aber natürlich ist es offensichtlich, dass weder die erste noch die zweite Version etwas Gutes ist; Die Schleife könnte wirklich von der Vektorisierung profitieren, da jede x86-64-CPU mindestens SSE2-Unterstützung bietet.

Es ist also ein Problem des Optimierers. Wie so oft rollt es sich zu aggressiv ab und schießt sich in den Fuß, während es verschiedene andere Möglichkeiten verpasst.

Tatsächlich zerlegen moderne x86-64-CPUs die Anweisungen weiter in Micro-Ops (µops). Mit Funktionen wie Registerumbenennung, µop-Caches und Loop-Puffern erfordert die Loop-Optimierung viel mehr Finesse als ein einfaches Abrollen, um eine optimale Leistung zu erzielen. Laut dem Optimierungsleitfaden von Agner Fog :

Der Leistungsgewinn aufgrund des µop-Cache kann beträchtlich sein, wenn die durchschnittliche Befehlslänge mehr als 4 Bytes beträgt. Die folgenden Methoden zur Optimierung der Verwendung des µop-Cache können in Betracht gezogen werden:

  • Stellen Sie sicher, dass kritische Schleifen klein genug sind, um in den µop-Cache zu passen.
  • Richten Sie die kritischsten Schleifeneinträge und Funktionseinträge um 32 aus.
  • Vermeiden Sie unnötiges Abrollen der Schleife.
  • Vermeiden Sie Anweisungen mit zusätzlicher Ladezeit
    . . .

In Bezug auf diese Ladezeiten kostet selbst der schnellste L1D-Treffer 4 Zyklen , ein zusätzliches Register und µop. Ja, selbst ein paar Zugriffe auf den Speicher beeinträchtigen die Leistung in engen Schleifen.

Aber zurück zur Vektorisierungsmöglichkeit - um zu sehen, wie schnell es sein kann, können wir eine ähnliche C-Anwendung mit GCC kompilieren , die sie direkt vektorisiert (AVX2 wird gezeigt, SSE2 ist ähnlich) 2 :

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

Mit Laufzeiten:

  • SSE: 0,24 s oder 2-mal schneller.
  • AVX: 0,15 s oder dreimal schneller.
  • AVX2: 0,08 s oder 5-mal schneller.

1 Um eine von JIT generierte Assembly-Ausgabe zu erhalten, rufen Sie eine Debug-JVM ab und führen Sie sie aus-XX:+PrintOptoAssembly

2 Die C-Version wird mit dem -fwrapvFlag kompiliert , wodurch GCC den Überlauf von vorzeichenbehafteten Ganzzahlen als Wrap-Around mit zwei Komplementen behandeln kann.


11
Das größte Problem, auf das der Optimierer im C-Beispiel stößt, ist das undefinierte Verhalten, das durch einen vorzeichenbehafteten Ganzzahlüberlauf ausgelöst wird. Was sonst wahrscheinlich dazu führen würde, dass einfach eine Konstante geladen wird, da die gesamte Schleife zur Kompilierungszeit berechnet werden kann.
Damon

44
@Damon Warum sollte undefiniertes Verhalten ein Problem für den Optimierer sein? Wenn der Optimierer beim Versuch, das Ergebnis zu berechnen, einen Überlauf feststellt, bedeutet dies nur, dass er es nach Belieben optimieren kann, da das Verhalten undefiniert ist.

13
@ Runemoro: Wenn der Optimierer beweist, dass das Aufrufen der Funktion unweigerlich zu undefiniertem Verhalten führt, kann er davon ausgehen, dass die Funktion niemals aufgerufen wird, und keinen Text dafür ausgeben. Oder geben Sie nur eine retAnweisung aus, oder geben Sie ein Label und keine Ret-Anweisung aus, damit die Ausführung einfach durchfällt. GCC verhält sich in der Tat manchmal so, wenn es auf UB trifft. Zum Beispiel: Warum mit der Optimierung verschwinden? . Sie möchten auf jeden Fall wohlgeformten Code kompilieren, um sicherzugehen, dass der Asm gesund ist.
Peter Cordes

8
Es ist wahrscheinlich nur ein Front-End-Uop-Durchsatz-Engpass aufgrund des ineffizienten Code-Gens. Es wird nicht einmal LEA als Guckloch für mov/ verwendet add-immediate. zB movl RBX, R9/ addl RBX, #8sollte sein leal ebx, [r9 + 8], 1 uop zum Kopieren und Hinzufügen. Oder leal ebx, [r9 + r9 + 16]zu tun ebx = 2*(r9+8). Also ja, das Abrollen bis zum Verschütten ist dumm, ebenso wie naiver Braindead-Codegen, der ganzzahlige Identitäten und assoziative ganzzahlige Mathematik nicht ausnutzt.
Peter Cordes

7
Die Vektorisierung zur sequentiellen Reduzierung wurde in C2 deaktiviert ( bugs.openjdk.java.net/browse/JDK-8078563 ), wird jedoch jetzt für die erneute Aktivierung in Betracht gezogen ( bugs.openjdk.java.net/browse/JDK-8188313 ).
Pron

131

Wenn die Multiplikation ist 2 * (i * i), kann die JVM die Multiplikation mit 2aus der Schleife herausrechnen, was zu diesem äquivalenten, aber effizienteren Code führt:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

Wenn die Multiplikation jedoch erfolgt (2 * i) * i, wird sie von der JVM nicht optimiert, da die Multiplikation mit einer Konstanten nicht mehr unmittelbar vor der Addition erfolgt.

Hier sind einige Gründe, warum ich denke, dass dies der Fall ist:

  • Das Hinzufügen einer if (n == 0) n = 1Anweisung am Anfang der Schleife führt dazu, dass beide Versionen genauso effizient sind, da das Ausklammern der Multiplikation nicht mehr garantiert, dass das Ergebnis dasselbe ist
  • Die optimierte Version (durch Ausklammern der Multiplikation mit 2) ist genauso schnell wie die 2 * (i * i)Version

Hier ist der Testcode, mit dem ich diese Schlussfolgerungen gezogen habe:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

Und hier sind die Ergebnisse:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
Ich denke auf der optimierten n *= 2000000000;
Version

4
@StefansArya - Nein. Betrachten Sie den Fall, in dem das Limit 4 ist, und wir versuchen zu berechnen 2*1*1 + 2*2*2 + 2*3*3. Es ist offensichtlich, dass das Berechnen 1*1 + 2*2 + 3*3und Multiplizieren mit 2 korrekt ist, während das Multiplizieren mit 8 nicht korrekt wäre.
Martin Bonner unterstützt Monica

5
Die mathematische Gleichung war genau so 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Das war sehr einfach und ich habe es einfach vergessen, weil die Schleife inkrementiert wurde.
StefansArya

5
Wenn Sie die Assembly mit einem Debug-JVM ausdrucken, scheint dies nicht korrekt zu sein. Sie werden eine Reihe von sall ..., # 1, die mit 2 multipliziert werden, in der Schleife sehen. Interessanterweise scheint die langsamere Version keine Multiplikationen in der Schleife zu haben.
Daniel Berlin

2
Warum kann die JVM die 2 aus, 2 * (i * i)aber nicht aus (2 * i) * iherausrechnen? Ich würde denken, dass sie gleichwertig sind (das könnte meine schlechte Annahme sein). Wenn ja, würde die JVM den Ausdruck nicht vor der Optimierung kanonisieren?
RedSpikeyThing

41

Bytecodes: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Bytecodes Viewer: https://github.com/Konloch/bytecode-viewer

Auf meinem JDK (Windows 10 64 Bit, 1.8.0_65-b17) kann ich Folgendes reproduzieren und erklären:

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

Ausgabe:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

Warum also? Der Bytecode lautet wie folgt:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

Der Unterschied ist: Mit Klammern ( 2 * (i * i)):

  • Schieben Sie den Const-Stack
  • Schieben Sie lokal auf Stapel
  • Schieben Sie lokal auf Stapel
  • Stapeloberseite multiplizieren
  • Stapeloberseite multiplizieren

Ohne Klammern ( 2 * i * i):

  • Schieben Sie den Const-Stack
  • Schieben Sie lokal auf Stapel
  • Stapeloberseite multiplizieren
  • Schieben Sie lokal auf Stapel
  • Stapeloberseite multiplizieren

Das Laden aller Daten auf den Stapel und das anschließende Zurückarbeiten ist schneller als das Umschalten zwischen dem Auflegen des Stapels und dem Bearbeiten des Stapels.


Aber warum ist Push-Push-Multiplizieren-Multiplizieren schneller als Push-Multiplizieren-Push-Multiplizieren?
m0skit0

35

Kasperd fragte in einem Kommentar zur akzeptierten Antwort:

Die Java- und C-Beispiele verwenden ganz unterschiedliche Registernamen. Verwenden beide Beispiele den AMD64 ISA?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

Ich habe nicht genug Ruf, um dies in den Kommentaren zu beantworten, aber dies sind die gleichen ISA. Es sei darauf hingewiesen, dass die GCC-Version eine 32-Bit-Ganzzahllogik verwendet und die JVM-kompilierte Version intern eine 64-Bit-Ganzzahllogik verwendet.

R8 bis R15 sind nur neue X86_64- Register . EAX to EDX sind die unteren Teile der RAX to RDX-Allzweckregister. Der wichtige Teil der Antwort ist, dass die GCC-Version nicht abgewickelt wird. Es wird einfach eine Runde der Schleife pro tatsächlicher Maschinencode-Schleife ausgeführt. Während die JVM-Version 16 Runden der Schleife in einer physischen Schleife enthält (basierend auf der Rustyx-Antwort habe ich die Assembly nicht neu interpretiert). Dies ist einer der Gründe, warum mehr Register verwendet werden, da der Schleifenkörper tatsächlich 16-mal länger ist.


2
Schade, dass gcc nicht bemerkt, dass es *2die Schleife verlassen kann. Obwohl es in diesem Fall nicht einmal ein Gewinn ist, dies zu tun, weil es mit LEA kostenlos ist. Hat auf Intel-CPUs lea eax, [rax+rcx*2]die gleiche 1c-Latenz wie add eax,ecx. Bei AMD-CPUs erhöht jedoch jeder skalierte Index die LEA-Latenz auf 2 Zyklen. Die durch Schleifen übertragene Abhängigkeitskette verlängert sich also auf 2 Zyklen und wird zum Engpass bei Ryzen. (Der imul ecx,edxDurchsatz beträgt 1 pro Takt bei Ryzen und Intel).
Peter Cordes

31

Obwohl dies nicht direkt mit der Umgebung der Frage zusammenhängt, habe ich aus Neugier den gleichen Test im Release-Modus von .NET Core 2.1, x64 durchgeführt.

Hier ist das interessante Ergebnis, das ähnliche Phonomen (umgekehrt) bestätigt, die über der dunklen Seite der Kraft auftreten. Code:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

Ergebnis:

2 * (i * i)

  • Ergebnis: 119860736, 438 ms
  • Ergebnis: 119860736, 433 ms
  • Ergebnis: 119860736, 437 ms
  • Ergebnis: 119860736, 435 ms
  • Ergebnis: 119860736, 436 ms
  • Ergebnis: 119860736, 435 ms
  • Ergebnis: 119860736, 435 ms
  • Ergebnis: 119860736, 439 ms
  • Ergebnis: 119860736, 436 ms
  • Ergebnis: 119860736, 437 ms

2 * i * i

  • Ergebnis: 119860736, 417 ms
  • Ergebnis: 119860736, 417 ms
  • Ergebnis: 119860736, 417 ms
  • Ergebnis: 119860736, 418 ms
  • Ergebnis: 119860736, 418 ms
  • Ergebnis: 119860736, 417 ms
  • Ergebnis: 119860736, 418 ms
  • Ergebnis: 119860736, 416 ms
  • Ergebnis: 119860736, 417 ms
  • Ergebnis: 119860736, 418 ms

1
Dies ist zwar keine Antwort auf die Frage, bietet aber einen Mehrwert. Wenn jedoch etwas für Ihren Beitrag von entscheidender Bedeutung ist, fügen Sie es bitte in den Beitrag ein, anstatt eine Verknüpfung zu einer externen Ressource herzustellen. Links gehen tot.
Jared Smith

1
@ JaredSmith Danke für das Feedback. Wenn man bedenkt, dass der von Ihnen erwähnte Link der "Ergebnis" -Link ist, ist dieses Bild keine externe Quelle. Ich habe es über ein eigenes Panel in den Stackoverflow hochgeladen.
Ünsal Ersöz

1
Es ist ein Link zu imgur, also ist es egal, wie Sie den Link hinzugefügt haben. Ich verstehe nicht, was beim Kopieren und Einfügen einer Konsolenausgabe so schwierig ist.
Jared Smith


2
@SamB ist immer noch auf der Domain imgur.com, was bedeutet, dass es nur so lange überlebt wie imgur.
p91paul

21

Ich habe ähnliche Ergebnisse erhalten:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

Ich habe die gleichen Ergebnisse erhalten, wenn sich beide Schleifen im selben Programm befanden oder sich jede in einer separaten Java-Datei / Klasse befand, die in einem separaten Lauf ausgeführt wurde.

Zum Schluss hier eine javap -c -v <.java>Dekompilierung von jedem:

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

vs.

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

Zu Ihrer Information -

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

1
Eine bessere Antwort und vielleicht können Sie abstimmen, um wiederherzustellen - stackoverflow.com/a/53452836/1746118 ... Randnotiz - Ich bin sowieso nicht der Downvoter.
Naman

@nullpointer - Ich stimme zu. Ich würde definitiv für das Wiederherstellen stimmen, wenn ich könnte. Ich möchte auch Stefan "doppelt upvoten", weil er eine quantitative Definition von "signifikant" gegeben hat
paulsm4

Dieser wurde selbst gelöscht, da er das Falsche gemessen hat - siehe den Kommentar des Autors zu der obigen Frage
Krease

2
Holen Sie sich ein Debug-Jre und führen Sie mit -XX:+PrintOptoAssembly. Oder verwenden Sie einfach vtune oder ähnliches.
Rustyx

1
@ rustyx - Wenn das Problem in der JIT-Implementierung liegt ... dann hilft es nicht unbedingt, eine Debug-Version einer völlig anderen JRE zu erhalten. Trotzdem: Es hört sich so an, als ob das, was Sie oben bei Ihrer JIT-Demontage auf Ihrer JRE gefunden haben, auch das Verhalten auf der JRE und meiner des OP erklärt. Und erklärt auch, warum sich andere JREs "anders" verhalten. +1: Danke für die hervorragende Detektivarbeit!
Pauls4

18

Interessante Beobachtung mit Java 11 und Ausschalten des Schleifens mit der folgenden VM-Option:

-XX:LoopUnrollLimit=0

Die Schleife mit dem 2 * (i * i)Ausdruck führt zu einem kompakteren nativen Code 1 :

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

im Vergleich zur 2 * i * iVersion:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

Java-Version:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

Benchmark-Ergebnisse:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

Benchmark-Quellcode:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - Verwendete VM-Optionen: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Wow, das ist ein Kopfzerbrechen. Anstatt i vor dem Kopieren zu erhöhen 2*i, um es zu berechnen , wird es danach ausgeführt, sodass eine zusätzliche add r11d,2Anweisung erforderlich ist . (Außerdem fehlt das add same,sameGuckloch anstelle shlvon 1 (Hinzufügen von Läufen an mehr Ports). Außerdem fehlt ein LEA-Guckloch für x*2 + 2( lea r11d, [r8*2 + 2]), wenn aus einem verrückten Grund für die Befehlsplanung wirklich Dinge in dieser Reihenfolge ausgeführt werden sollen Die ungerollte Version, die LEA verpasst hat, hat viel Geld gekostet, genau wie beide Loops hier.
Peter Cordes

2
lea eax, [rax + r11 * 2]würde 2 Anweisungen (in beiden Schleifen) ersetzen, wenn der JIT-Compiler Zeit hätte, diese Optimierung in lang laufenden Schleifen zu suchen. Jeder anständige Compiler, der der Zeit voraus ist, würde es finden. (Es sei denn, es wird möglicherweise nur für AMD eingestellt, wo LEA mit skaliertem Index eine Latenz von 2 Zyklen aufweist, die sich also möglicherweise nicht lohnt.)
Peter Cordes

15

Ich habe einen JMH mit dem Standard-Archetyp ausprobiert: Ich habe auch eine optimierte Version hinzugefügt, die auf Runemoros Erklärung basiert .

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

Das Ergebnis ist hier:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

Auf meinem PC ( Core i7 860 - es macht nichts anderes als das Lesen auf meinem Smartphone):

  • n += i*idann n*2ist zuerst
  • 2 * (i * i) ist an zweiter Stelle.

Die JVM optimiert eindeutig nicht auf die gleiche Weise wie ein Mensch (basierend auf Runemoros Antwort).

Lesen Sie nun den Bytecode: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Ich bin kein Experte für Bytecode, aber wir iload_2vor uns imul: Hier liegt wahrscheinlich der Unterschied: Ich kann davon ausgehen, dass die JVM das Lesen izweimal optimiert ( iist bereits vorhanden und muss nicht erneut geladen werden), während sie sich in der 2*i*iDose befindet. ' t.


4
AFAICT-Bytecode ist für die Leistung ziemlich irrelevant, und ich würde nicht versuchen, abzuschätzen, was darauf basierend schneller ist. Es ist nur der Quellcode für den JIT-Compiler ... sicher kann eine bedeutungserhaltende Neuordnung von Quellcodezeilen den resultierenden Code und seine Effizienz ändern, aber das alles ist ziemlich unvorhersehbar.
Maaartinus

13

Eher ein Nachtrag. Ich habe das Experiment mit der neuesten Java 8 JVM von IBM wiederholt:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

Und das zeigt sehr ähnliche Ergebnisse:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(zweite Ergebnisse mit 2 * i * i).

Interessanterweise, wenn Sie auf demselben Computer ausgeführt werden, aber Oracle Java verwenden:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

Ergebnisse sind im Durchschnitt etwas langsamer:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Lange Rede, kurzer Sinn: Auch die geringe Versionsnummer von HotSpot spielt hier eine Rolle, da subtile Unterschiede innerhalb der JIT-Implementierung bemerkenswerte Auswirkungen haben können.


5

Die beiden Methoden zum Hinzufügen generieren leicht unterschiedlichen Bytecode:

  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

Für 2 * (i * i)vs:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

Zum 2 * i * i .

Und wenn Sie einen JMH- Benchmark wie diesen verwenden:

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

Der Unterschied ist klar:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

Was Sie beobachten, ist korrekt und nicht nur eine Anomalie Ihres Benchmarking-Stils (dh kein Aufwärmen, siehe Wie schreibe ich einen korrekten Mikro-Benchmark in Java? )

Wieder laufen mit Graal:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

Sie sehen, dass die Ergebnisse viel näher liegen, was Sinn macht, da Graal ein insgesamt leistungsfähigerer, modernerer Compiler ist.

Das hängt also wirklich davon ab, wie gut der JIT-Compiler in der Lage ist, einen bestimmten Code zu optimieren, und hat nicht unbedingt einen logischen Grund dafür.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.