Perché 2 * (i * i) è più veloce di 2 * i * i in Java?


855

Il seguente programma Java impiega in media tra 0,50 secondi e 0,55 secondi per essere eseguito:

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

Se lo sostituisco 2 * (i * i)con2 * i * i , ci vogliono tra 0,60 e 0,65 secondi per l'esecuzione. Come mai?

Ho eseguito ogni versione del programma 15 volte, alternando tra i due. Ecco i risultati:

 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

La corsa più veloce 2 * i * iha richiesto più tempo della corsa più lenta 2 * (i * i). Se avessero la stessa efficienza, la probabilità che ciò accada sarebbe inferiore 1/2^15 * 100% = 0.00305%.


5
Ottengo risultati simili (numeri leggermente diversi, ma gap evidentemente evidente e consistente, decisamente più dell'errore di campionamento)
Krease

29
Inoltre si prega di consultare: stackoverflow.com/questions/504103/...
lexicore

3
@Krease Bene, hai colto il mio errore. Secondo il nuovo benchmark che ho eseguito 2 * i * iè più lento. Proverò a correre anche con Graal.
Jorn Vernee,

5
@nullpointer Per scoprire davvero perché uno è più veloce dell'altro, dovremmo ottenere lo smontaggio o i grafici ideali per questi metodi. L'assemblatore è molto fastidioso da provare e capire, quindi sto cercando di ottenere una build di debug OpenJDK in grado di produrre bei grafici.
Jorn Vernee,

4
È possibile rinominare la domanda in " Perché è i * i * 2più veloce di 2 * i * i? " Per una maggiore chiarezza sul fatto che il problema è nell'ordine delle operazioni.
Cœur il

Risposte:


1202

Vi è una leggera differenza nell'ordinamento del bytecode.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

A prima vista ciò non dovrebbe fare la differenza; semmai la seconda versione è più ottimale in quanto utilizza uno slot in meno.

Quindi dobbiamo scavare più a fondo nel livello inferiore (JIT) 1 .

Ricorda che JIT tende a srotolare i piccoli anelli in modo molto aggressivo. Effettivamente osserviamo uno srotolamento 16x per il 2 * (i * i)caso:

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

Vediamo che c'è un registro che viene "versato" nello stack.

E per la 2 * i * iversione:

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

Qui osserviamo molto più "spargimento" e più accessi allo stack [RSP + ...], a causa di risultati più intermedi che devono essere preservati.

Quindi la risposta alla domanda è semplice: 2 * (i * i)è più veloce di 2 * i * iperché la JIT genera un codice assembly più ottimale per il primo caso.


Ma ovviamente è ovvio che né la prima né la seconda versione vanno bene; il loop potrebbe davvero beneficiare della vettorializzazione, poiché qualsiasi CPU x86-64 ha almeno il supporto SSE2.

Quindi è un problema dell'ottimizzatore; come spesso accade, si svolge in modo troppo aggressivo e si spara in un piede, perdendo nel contempo altre opportunità.

In effetti, le moderne CPU x86-64 suddividono ulteriormente le istruzioni in micro-op (µops) e con funzionalità come la rinomina dei registri, cache µop e buffer di loop, l'ottimizzazione del loop richiede molta più finezza di un semplice srotolamento per prestazioni ottimali. Secondo la guida all'ottimizzazione di Agner Fog :

Il guadagno in termini di prestazioni dovuto alla cache µop può essere considerevole se la lunghezza media delle istruzioni è superiore a 4 byte. Possono essere considerati i seguenti metodi per ottimizzare l'uso della cache µop:

  • Assicurarsi che i loop critici siano sufficientemente piccoli da adattarsi alla cache µop.
  • Allineare le voci del loop più critiche e le voci delle funzioni di 32.
  • Evita lo srotolamento del loop non necessario.
  • Evita le istruzioni che hanno tempi di caricamento extra
    . . .

Per quanto riguarda i tempi di caricamento, anche il colpo L1D più veloce costa 4 cicli , un registro extra e µop, quindi sì, anche pochi accessi alla memoria danneggiano le prestazioni in loop stretti.

Ma torniamo all'opportunità di vettorializzazione - per vedere quanto può essere veloce, possiamo compilare un'applicazione C simile con GCC , che la vettorializza completamente (viene mostrato AVX2, SSE2 è simile) 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

Con tempi di esecuzione:

  • SSE: 0,24 sec. O 2 volte più veloce.
  • AVX: 0,15 s, o 3 volte più veloce.
  • AVX2: 0,08 secondi o 5 volte più veloce.

1 Per ottenere l'output dell'assembly generato da JIT, ottenere un JVM di debug ed eseguire-XX:+PrintOptoAssembly

2 La versione C è compilata con il -fwrapvflag, che consente a GCC di trattare l'overflow di numeri interi con segno come un avvolgimento del complemento a due.


11
Il singolo problema più grande che l'ottimizzatore incontra nell'esempio C è il comportamento indefinito invocato dall'overflow di numeri interi con segno. Il che, altrimenti, probabilmente comporterebbe semplicemente il caricamento di una costante poiché l'intero ciclo può essere calcolato in fase di compilazione.
Damon,

44
@Damon Perché il comportamento indefinito sarebbe un problema per l'ottimizzatore? Se l'ottimizzatore vede che trabocca quando prova a calcolare il risultato, significa solo che può ottimizzarlo come vuole, perché il comportamento non è definito.

13
@Runemoro: se l'ottimizzatore dimostra che chiamare la funzione comporterà inevitabilmente un comportamento indefinito, potrebbe scegliere di presumere che la funzione non verrà mai chiamata e non emettere alcun corpo per essa. Oppure emetti solo retun'istruzione o emetti un'etichetta e nessuna istruzione ret, quindi l'esecuzione fallisce. Tuttavia, GCC si comporta in questo modo quando incontra UB. Ad esempio: perché ret scompare con l'ottimizzazione? . Volete sicuramente compilare un codice ben formato per essere sicuri che l'asm sia sano.
Peter Cordes,

8
Probabilmente è solo un collo di bottiglia del throughput del front-end a causa dell'inefficiente code-gen. Non sta nemmeno usando LEA come spioncino per mov/ add-immediate. es. movl RBX, R9/ addl RBX, #8dovrebbe essere leal ebx, [r9 + 8], 1 uop per copiare e aggiungere. O leal ebx, [r9 + r9 + 16]da fare ebx = 2*(r9+8). Quindi sì, srotolarsi al punto di rovesciarsi è stupido, e così è ingenuo codegen braindead che non sfrutta le identità di interi e la matematica di interi associativi.
Peter Cordes,

7
La vettorializzazione per la riduzione sequenziale è stata disabilitata in C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), ma ora viene presa in considerazione per la riattivazione ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Quando la moltiplicazione è 2 * (i * i), la JVM è in grado di fattorizzare la moltiplicazione 2dal loop, ottenendo questo codice equivalente ma più efficiente:

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

ma quando la moltiplicazione è (2 * i) * i, la JVM non la ottimizza poiché la moltiplicazione per una costante non è più subito prima dell'aggiunta.

Ecco alcuni motivi per cui penso che sia così:

  • Aggiunta di un if (n == 0) n = 1 un'istruzione all'inizio del ciclo fa sì che entrambe le versioni siano altrettanto efficienti, poiché il factoring fuori dalla moltiplicazione non garantisce più che il risultato sarà lo stesso
  • La versione ottimizzata (fattorizzando la moltiplicazione per 2) è esattamente veloce come la 2 * (i * i)versione

Ecco il codice di test che ho usato per trarre queste conclusioni:

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

E qui ci sono i risultati:

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
Penso sulla versione ottimizzata, dovrebbe esseren *= 2000000000;
StefansArya

4
@StefansArya - No. Considera il caso in cui il limite è 4 e stiamo provando a calcolare 2*1*1 + 2*2*2 + 2*3*3. È ovvio che calcolare 1*1 + 2*2 + 3*3e moltiplicare per 2 è corretto, mentre moltiplicare per 8 non lo sarebbe.
Martin Bonner supporta Monica il

5
L'equazione matematica era proprio così 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). È stato molto semplice e l'ho dimenticato solo perché l'incremento del ciclo.
StefansArya,

5
Se si stampa l'assembly utilizzando un debug jvm, questo non sembra essere corretto. Vedrai un mucchio di sall ..., # 1, che sono moltiplicati per 2, nel loop. È interessante notare che la versione più lenta non sembra avere moltiplicazioni nel ciclo.
Daniel Berlin,

2
Perché la JVM può escludere i 2 da 2 * (i * i)ma non da (2 * i) * i? Penso che siano equivalenti (potrebbe essere la mia cattiva ipotesi). In tal caso, la JVM non canonicalizzerebbe l'espressione prima dell'ottimizzazione?
RedSpikeyThing

41

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

Sul mio JDK (Windows 10 64 bit, 1.8.0_65-b17) posso riprodurre e spiegare:

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

Produzione:

...
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

Allora perchè? Il codice byte è questo:

 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 {
     }
 }

La differenza è: Con parentesi ( 2 * (i * i)):

  • spingere const stack
  • spingere locale in pila
  • spingere locale in pila
  • moltiplicare la parte superiore dello stack
  • moltiplicare la parte superiore dello stack

Senza parentesi ( 2 * i * i):

  • spingere const stack
  • spingere locale in pila
  • moltiplicare la parte superiore dello stack
  • spingere locale in pila
  • moltiplicare la parte superiore dello stack

Caricare tutto nello stack e poi tornare indietro è più veloce del passaggio tra lo stack e il funzionamento su di esso.


Ma perché push-push-moltiplicare si moltiplica più velocemente di push-moltiplicare-push-moltiplicare?
m0skit0,

35

Kasperd ha chiesto in un commento della risposta accettata:

Gli esempi Java e C usano nomi di registro abbastanza diversi. Entrambi stanno usando l'ISA AMD64?

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

Non ho abbastanza reputazione per rispondere a questo nei commenti, ma questi sono lo stesso ISA. Vale la pena sottolineare che la versione GCC utilizza la logica intera a 32 bit e la versione compilata JVM utilizza la logica intera a 64 bit internamente.

Da R8 a R15 sono solo nuovi registri X86_64 . Da EAX a EDX sono le parti inferiori dei registri di uso generale da RAX a RDX. La parte importante nella risposta è che la versione di GCC non è srotolata. Esegue semplicemente un round del loop per ogni loop di codice macchina effettivo. Mentre la versione JVM ha 16 round del loop in un loop fisico (basato sulla risposta di rustyx, non ho reinterpretato l'assembly). Questo è uno dei motivi per cui vengono utilizzati più registri poiché il corpo del loop è in realtà 16 volte più lungo.


2
Peccato che gcc non si accorga che può affondare *2il ciclo. Anche se in questo caso, non è nemmeno una vittoria farlo, perché lo fa gratuitamente con LEA. Sulle CPU Intel, lea eax, [rax+rcx*2]ha la stessa latenza 1c di add eax,ecx. Tuttavia, nelle CPU AMD qualsiasi indice in scala aumenta la latenza LEA a 2 cicli. Quindi la catena di dipendenze trasportata ad anello si allunga a 2 cicli, diventando il collo di bottiglia di Ryzen. (la imul ecx,edxvelocità effettiva è 1 per clock su Ryzen e su Intel).
Peter Cordes,

31

Pur non essendo direttamente correlato all'ambiente della domanda, solo per curiosità, ho fatto lo stesso test su .NET Core 2.1, x64, modalità di rilascio.

Ecco il risultato interessante, che conferma simili fonomeni (viceversa) che si verificano sul lato oscuro della forza. Codice:

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

Risultato:

2 * (i * i)

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

2 * i * i

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

1
Sebbene questa non sia una risposta alla domanda, aggiunge valore. Detto questo, se qualcosa è di vitale importanza per il tuo post, per favore inseriscilo nel post piuttosto che collegandolo a una risorsa off-site. I collegamenti si interrompono.
Jared Smith,

1
@JaredSmith Grazie per il feedback. Considerando il link che menzioni è il link "risultato", quell'immagine non è una fonte fuori sito. L'ho caricato su StackOverflow tramite il proprio pannello.
Ünsal Ersöz,

1
È un collegamento a imgur, quindi sì, lo è, non importa come hai aggiunto il collegamento. Non riesco a vedere cosa c'è di così difficile nell'incollare l'output della console.
Jared Smith,

5
Tranne questo è il contrario
leppie il

2
@SamB è ancora nel dominio imgur.com, il che significa che sopravviverà solo fino a quando imgur.
p91paul

21

Ho ottenuto risultati simili:

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

Ho ottenuto i risultati SAME se entrambi i loop erano nello stesso programma, o ognuno era in un file .java / .class separato, eseguito su una corsa separata.

Infine, ecco un javap -c -v <.java>decompilare di ciascuno:

     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

FYI -

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
Una risposta migliore e forse puoi votare per annullare l'eliminazione - stackoverflow.com/a/53452836/1746118 ... Nota a margine - Non sono comunque il downvoter.
Naman,

@nullpointer - Sono d'accordo. Vorrei sicuramente votare per annullare l'eliminazione, se potessi. Vorrei anche "raddoppiare il voto" stefan per aver dato una definizione quantitativa di "significativo"
paulsm4

Quella è stata auto-cancellata poiché ha misurato la cosa sbagliata - vedi il commento dell'autore sulla domanda sopra
Krease

2
Ottieni un debug jre e corri con -XX:+PrintOptoAssembly. O semplicemente usa vtune o simili.
Rustyx,

1
@ rustyx - Se il problema è l'implementazione di JIT ... allora "ottenere una versione di debug" DI UN JRE COMPLETAMENTE DIVERSO non sarà necessariamente d'aiuto. Tuttavia: sembra che ciò che hai trovato sopra con il tuo smontaggio JIT sul tuo JRE spieghi anche il comportamento sul JRE e sul mio OP. E spiega anche perché gli altri JRE si comportano "in modo diverso". +1: grazie per l'ottimo lavoro investigativo!
paulsm4,

18

Osservazione interessante utilizzando Java 11 e disattivando lo svolgersi del loop con la seguente opzione VM:

-XX:LoopUnrollLimit=0

Il ciclo con l' 2 * (i * i)espressione produce un codice nativo più compatto 1 :

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

rispetto alla 2 * i * iversione:

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

Versione Java:

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)

Risultati benchmark:

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

Codice sorgente benchmark:

@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 - Opzioni VM utilizzate: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Wow, questo è un po 'rompiscatole. Invece di incrementare i prima di copiarlo per calcolare 2*i, lo fa dopo, quindi ha bisogno di un'istruzione aggiuntiva add r11d,2. (Inoltre manca lo add same,samespioncino invece di shl1 (aggiungi corse su più porte). Manca anche uno spioncino LEA per x*2 + 2( lea r11d, [r8*2 + 2]) se vuole davvero fare le cose in quell'ordine per qualche pazzo motivo di programmazione delle istruzioni. Potremmo già vedere da la versione srotolata che perdere su LEA gli costava un sacco di cicli, lo stesso di entrambi i cicli qui.
Peter Cordes,

2
lea eax, [rax + r11 * 2]sostituirà 2 istruzioni (in entrambi i loop) se il compilatore JIT avesse il tempo di cercare tale ottimizzazione nei loop di lunga durata. Qualunque buon compilatore in anticipo lo troverebbe. (A meno che non sia possibile sintonizzare solo per AMD, dove LEA con indice in scala ha una latenza di 2 cicli, quindi forse non ne vale la pena.)
Peter Cordes,

15

Ho provato un JMH usando l'archetipo predefinito: ho anche aggiunto una versione ottimizzata basata sulla spiegazione di Runemoro .

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

Il risultato è qui:

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

Sul mio PC ( Core i7 860 - non fa nulla di diverso dalla lettura sul mio smartphone):

  • n += i*iallora n*2è il primo
  • 2 * (i * i) è il secondo.

Chiaramente la JVM non sta ottimizzando allo stesso modo di un essere umano (in base alla risposta di Runemoro).

Ora, leggendo il bytecode: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Non sono esperto di bytecode, ma iload_2prima di noi imul: è probabilmente lì che ottieni la differenza: posso supporre che la JVM ottimizzi la lettura idue volte ( iè già qui e non è necessario caricarla di nuovo) mentre 2*i*iè possibile ' t.


4
Il bytecode AFAICT è piuttosto irrilevante per le prestazioni e non proverei a stimare ciò che è più veloce in base a esso. È solo il codice sorgente per il compilatore JIT ... sicuramente può conservare il significato riordinando le righe del codice sorgente cambiando il codice risultante e la sua efficienza, ma tutto ciò è piuttosto imprevedibile.
maaartinus,

13

Più di un addendum. Ho riproposto l'esperimento utilizzando l'ultima JVM Java 8 di IBM:

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)

E questo mostra risultati molto simili:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(secondi risultati usando 2 * i * i).

È interessante notare che, quando si esegue sullo stesso computer, ma utilizzando Oracle Java:

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)

i risultati sono in media un po 'più lenti:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Per farla breve: anche il numero di versione minore di HotSpot è importante qui, poiché sottili differenze nell'implementazione di JIT possono avere effetti notevoli.


5

I due metodi di aggiunta generano un codice byte leggermente diverso:

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

Per 2 * (i * i)vs:

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

Per 2 * i * i.

E quando si utilizza un benchmark JMH come questo:

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

}

La differenza è chiara:

# 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

Quello che osservi è corretto e non solo un'anomalia del tuo stile di benchmarking (ovvero nessun riscaldamento, vedi Come posso scrivere un micro-benchmark corretto in Java? )

Correre di nuovo con 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

Vedete che i risultati sono molto più vicini, il che ha senso, dal momento che Graal è un compilatore complessivamente più performante, più moderno.

Quindi questo è davvero solo fino a che punto il compilatore JIT è in grado di ottimizzare un particolare pezzo di codice e non ha necessariamente una ragione logica per farlo.

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.