Perché è molto più lento di int in Java x64?


90

Utilizzo Windows 8.1 x64 con aggiornamento Java 7 45 x64 (non è installato Java a 32 bit) su un tablet Surface Pro 2.

Il codice seguente richiede 1688 ms quando il tipo di i è lungo e 109 ms quando i è un int. Perché long (un tipo a 64 bit) è un ordine di grandezza più lento di int su una piattaforma a 64 bit con una JVM a 64 bit?

La mia unica ipotesi è che la CPU impiega più tempo per aggiungere un numero intero a 64 bit rispetto a uno a 32 bit, ma sembra improbabile. Ho il sospetto che Haswell non usi sommatori per il trasporto di ondulazioni.

Sto eseguendo questo in Eclipse Kepler SR1, btw.

public class Main {

    private static long i = Integer.MAX_VALUE;

    public static void main(String[] args) {    
        System.out.println("Starting the loop");
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheck()){
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheck() {
        return --i < 0;
    }

}

Modifica: ecco i risultati del codice C ++ equivalente compilato da VS 2013 (sotto), stesso sistema. lungo: 72265 ms int: 74656 ms Questi risultati erano in modalità di debug a 32 bit.

In modalità di rilascio a 64 bit: lungo: 875 ms lungo lungo: 906 ms int: 1047 ms

Ciò suggerisce che il risultato che ho osservato è la stranezza dell'ottimizzazione JVM piuttosto che i limiti della CPU.

#include "stdafx.h"
#include "iostream"
#include "windows.h"
#include "limits.h"

long long i = INT_MAX;

using namespace std;


boolean decrementAndCheck() {
return --i < 0;
}


int _tmain(int argc, _TCHAR* argv[])
{


cout << "Starting the loop" << endl;

unsigned long startTime = GetTickCount64();
while (!decrementAndCheck()){
}
unsigned long endTime = GetTickCount64();

cout << "Finished the loop in " << (endTime - startTime) << "ms" << endl;



}

Modifica: ho appena provato di nuovo in Java 8 RTM, nessun cambiamento significativo.


8
Il sospetto più probabile è la tua configurazione, non la CPU o le varie parti della JVM. Potete riprodurre in modo affidabile questa misura? Non ripetere il ciclo, non riscaldare il JIT, usare currentTimeMillis(), eseguire codice che può essere banalmente ottimizzato completamente, ecc. Puzza di risultati inaffidabili.

1
Stavo facendo il benchmarking qualche tempo fa, ho dovuto usare a longcome contatore del loop, perché il compilatore JIT ha ottimizzato il loop out, quando ho usato un int. Si dovrebbe guardare allo smontaggio del codice macchina generato.
Sam

7
Questo non è un microbenchmark corretto e non mi aspetto che i suoi risultati riflettano in alcun modo la realtà.
Louis Wasserman

7
Tutti i commenti che rimproverano l'OP per non aver scritto un microbenchmark Java appropriato sono indicibilmente pigri. Questo è il genere di cose che è molto facile capire se guardi e vedi cosa fa la JVM al codice.
tmyklebu

2
@maaartinus: La pratica accettata è una pratica accettata perché funziona attorno a un elenco di insidie ​​note. Nel caso di Benchmark Java corretti, vuoi assicurarti di misurare il codice ottimizzato correttamente, non una sostituzione sullo stack, e vuoi assicurarti che le tue misurazioni siano pulite alla fine. OP ha riscontrato un problema completamente diverso e il benchmark che ha fornito lo ha adeguatamente dimostrato. E, come notato, trasformare questo codice in un benchmark Java corretto non fa andare via la stranezza. E leggere il codice assembly non è difficile.
tmyklebu

Risposte:


80

La mia JVM fa questa cosa piuttosto semplice al ciclo interno quando usi longs:

0x00007fdd859dbb80: test   %eax,0x5f7847a(%rip)  /* fun JVM hack */
0x00007fdd859dbb86: dec    %r11                  /* i-- */
0x00007fdd859dbb89: mov    %r11,0x258(%r10)      /* store i to memory */
0x00007fdd859dbb90: test   %r11,%r11             /* unnecessary test */
0x00007fdd859dbb93: jge    0x00007fdd859dbb80    /* go back to the loop top */

Inganna, difficile, quando usi ints; prima c'è un po 'di stranezza che non pretendo di capire ma sembra che sia impostato per un ciclo srotolato:

0x00007f3dc290b5a1: mov    %r11d,%r9d
0x00007f3dc290b5a4: dec    %r9d
0x00007f3dc290b5a7: mov    %r9d,0x258(%r10)
0x00007f3dc290b5ae: test   %r9d,%r9d
0x00007f3dc290b5b1: jl     0x00007f3dc290b662
0x00007f3dc290b5b7: add    $0xfffffffffffffffe,%r11d
0x00007f3dc290b5bb: mov    %r9d,%ecx
0x00007f3dc290b5be: dec    %ecx              
0x00007f3dc290b5c0: mov    %ecx,0x258(%r10)   
0x00007f3dc290b5c7: cmp    %r11d,%ecx
0x00007f3dc290b5ca: jle    0x00007f3dc290b5d1
0x00007f3dc290b5cc: mov    %ecx,%r9d
0x00007f3dc290b5cf: jmp    0x00007f3dc290b5bb
0x00007f3dc290b5d1: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b5d5: mov    %r9d,%r8d
0x00007f3dc290b5d8: neg    %r8d
0x00007f3dc290b5db: sar    $0x1f,%r8d
0x00007f3dc290b5df: shr    $0x1f,%r8d
0x00007f3dc290b5e3: sub    %r9d,%r8d
0x00007f3dc290b5e6: sar    %r8d
0x00007f3dc290b5e9: neg    %r8d
0x00007f3dc290b5ec: and    $0xfffffffffffffffe,%r8d
0x00007f3dc290b5f0: shl    %r8d
0x00007f3dc290b5f3: mov    %r8d,%r11d
0x00007f3dc290b5f6: neg    %r11d
0x00007f3dc290b5f9: sar    $0x1f,%r11d
0x00007f3dc290b5fd: shr    $0x1e,%r11d
0x00007f3dc290b601: sub    %r8d,%r11d
0x00007f3dc290b604: sar    $0x2,%r11d
0x00007f3dc290b608: neg    %r11d
0x00007f3dc290b60b: and    $0xfffffffffffffffe,%r11d
0x00007f3dc290b60f: shl    $0x2,%r11d
0x00007f3dc290b613: mov    %r11d,%r9d
0x00007f3dc290b616: neg    %r9d
0x00007f3dc290b619: sar    $0x1f,%r9d
0x00007f3dc290b61d: shr    $0x1d,%r9d
0x00007f3dc290b621: sub    %r11d,%r9d
0x00007f3dc290b624: sar    $0x3,%r9d
0x00007f3dc290b628: neg    %r9d
0x00007f3dc290b62b: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b62f: shl    $0x3,%r9d
0x00007f3dc290b633: mov    %ecx,%r11d
0x00007f3dc290b636: sub    %r9d,%r11d
0x00007f3dc290b639: cmp    %r11d,%ecx
0x00007f3dc290b63c: jle    0x00007f3dc290b64f
0x00007f3dc290b63e: xchg   %ax,%ax /* OK, fine; I know what a nop looks like */

quindi il ciclo srotolato stesso:

0x00007f3dc290b640: add    $0xfffffffffffffff0,%ecx
0x00007f3dc290b643: mov    %ecx,0x258(%r10)
0x00007f3dc290b64a: cmp    %r11d,%ecx
0x00007f3dc290b64d: jg     0x00007f3dc290b640

quindi il codice di smontaggio per il ciclo srotolato, esso stesso un test e un ciclo diretto:

0x00007f3dc290b64f: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b652: jle    0x00007f3dc290b662
0x00007f3dc290b654: dec    %ecx
0x00007f3dc290b656: mov    %ecx,0x258(%r10)
0x00007f3dc290b65d: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b660: jg     0x00007f3dc290b654

Quindi va 16 volte più veloce per gli int perché il JIT ha svolto il intciclo 16 volte, ma non ha svolto affatto il longciclo.

Per completezza, ecco il codice che ho effettivamente provato:

public class foo136 {
  private static int i = Integer.MAX_VALUE;
  public static void main(String[] args) {
    System.out.println("Starting the loop");
    for (int foo = 0; foo < 100; foo++)
      doit();
  }

  static void doit() {
    i = Integer.MAX_VALUE;
    long startTime = System.currentTimeMillis();
    while(!decrementAndCheck()){
    }
    long endTime = System.currentTimeMillis();
    System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
  }

  private static boolean decrementAndCheck() {
    return --i < 0;
  }
}

I dump dell'assieme sono stati generati utilizzando le opzioni -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly. Nota che devi fare confusione con la tua installazione JVM per fare in modo che funzioni anche per te; è necessario mettere una libreria condivisa casuale esattamente nel posto giusto o fallirà.


8
OK, quindi net-net non è che la longversione sia più lenta, ma piuttosto che la intversione sia più veloce. Questo ha senso. Probabilmente non è stato investito così tanto impegno nel fare in modo che JIT ottimizzi le longespressioni.
Hot Licks

1
... scusate la mia ignoranza, ma cos'è "funrolled"? Non riesco nemmeno a cercare su Google il termine correttamente, e questo rende questa la prima volta che ho dovuto chiedere a qualcuno cosa significa una parola su Internet.
BrianH

1
@BrianDHall gccutilizza -fcome opzione della riga di comando per "flag" e l' unroll-loopsottimizzazione viene attivata dicendo -funroll-loops. Uso solo "unroll" per descrivere l'ottimizzazione.
chrylis -cautiouslyoptimistic-

4
@BRPocock: il compilatore Java non può, ma il JIT sì.
tmyklebu

1
Giusto per essere chiari, non "funroll". Lo ha svolto E ha convertito il ciclo srotolato in i-=16, che ovviamente è 16 volte più veloce.
Aleksandr Dubinsky

22

Lo stack JVM è definito in termini di parole , la cui dimensione è un dettaglio di implementazione ma deve essere larga almeno 32 bit. L'implementatore JVM può utilizzare parole a 64 bit, ma il bytecode non può fare affidamento su questo, quindi le operazioni con i valori longo doubledevono essere gestite con particolare attenzione. In particolare, le istruzioni del ramo intero JVM sono definite esattamente sul tipo int.

Nel caso del codice, lo smontaggio è istruttivo. Ecco il bytecode per la intversione compilata da Oracle JDK 7:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:I
     3: iconst_1      
     4: isub          
     5: dup           
     6: putstatic     #14  // Field i:I
     9: ifge          16
    12: iconst_1      
    13: goto          17
    16: iconst_0      
    17: ireturn       

Nota che la JVM caricherà il valore del tuo static i(0), sottrarrà uno (3-4), duplicherà il valore nello stack (5) e lo reinserirà nella variabile (6). Quindi esegue un ramo di confronto con zero e restituisce.

La versione con longè un po 'più complicata:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:J
     3: lconst_1      
     4: lsub          
     5: dup2          
     6: putstatic     #14  // Field i:J
     9: lconst_0      
    10: lcmp          
    11: ifge          18
    14: iconst_1      
    15: goto          19
    18: iconst_0      
    19: ireturn       

Innanzitutto, quando la JVM duplica il nuovo valore sullo stack (5), deve duplicare due parole dello stack. Nel tuo caso, è del tutto possibile che questo non sia più costoso della duplicazione, poiché la JVM è libera di utilizzare una parola a 64 bit se conveniente. Tuttavia, noterai che la logica del ramo è più lunga qui. La JVM non ha un'istruzione per confrontare a longcon zero, quindi deve inserire una costante 0Lnello stack (9), fare un longconfronto generale (10) e quindi ramificare il valore di quel calcolo.

Ecco due scenari plausibili:

  • La JVM sta seguendo esattamente il percorso del bytecode. In questo caso, sta facendo più lavoro nella longversione, spingendo e facendo scoppiare diversi valori extra, e questi sono nello stack gestito virtuale , non nello stack della CPU assistito da hardware reale. Se questo è il caso, vedrai comunque una significativa differenza di prestazioni dopo il riscaldamento.
  • La JVM si rende conto di poter ottimizzare questo codice. In questo caso, ci vuole più tempo per ottimizzare parte della logica push / compare praticamente inutile. Se questo è il caso, vedrai una differenza di prestazioni minima dopo il riscaldamento.

Ti consiglio di scrivere un microbenchmark corretto per eliminare l'effetto di avere il JIT kick in, e anche di provare questo con una condizione finale che non è zero, per costringere la JVM a fare lo stesso confronto con il JIT intche fa con il long.


1
@Katona Non necessariamente. In particolare, le JVM Client e Server HotSpot sono implementazioni completamente diverse e Ilya non ha indicato di selezionare Server (Client è solitamente l'impostazione predefinita a 32 bit).
chrylis -cautiouslyoptimistic-

1
@tmyklebu Il problema è che il benchmark sta misurando diverse cose contemporaneamente. L'uso di una condizione terminale diversa da zero riduce il numero di variabili.
chrylis -cautiouslyoptimistic-

1
@tmyklebu Il punto è che l'OP aveva inteso confrontare la velocità di incrementi, decrementi e confronti su int e long. Invece (supponendo che questa risposta sia corretta) stavano misurando solo confronti e solo contro 0, che è un caso speciale. Se non altro, rende il benchmark originale fuorviante: sembra che misuri tre casi generali, quando in realtà misura un caso specifico.
yshavit

1
@tmyklebu Non fraintendermi, ho votato positivamente la domanda, questa risposta e la tua risposta. Ma non sono d'accordo con la tua affermazione secondo cui @chrylis sta aggiustando il benchmark per smettere di misurare la differenza che sta cercando di misurare. OP può correggermi se sbaglio, ma non sembra che stiano cercando di misurare solo / principalmente == 0, il che sembra essere una parte sproporzionatamente grande dei risultati del benchmark. Mi sembra più probabile che l'OP stia cercando di misurare una gamma più generale di operazioni, e questa risposta sottolinea che il benchmark è fortemente sbilanciato verso solo una di quelle operazioni.
yshavit

2
@tmyklebu Niente affatto. Sono tutto per capire le cause alla radice. Tuttavia, avendo identificato che una delle principali cause alla radice è che il benchmark era inclinato, non è valido modificare il benchmark per rimuovere lo skew, così come scavare e capire di più su quell'inclinazione (ad esempio, che può abilitare una maggiore efficienza bytecode, che può rendere più facile lo srotolamento di loop, ecc.). Ecco perché ho votato positivamente sia questa risposta (che identificava l'inclinazione) che la tua (che scava nell'inclinazione in modo più dettagliato).
yshavit

8

L'unità di base dei dati in una Java Virtual Machine è la parola. La scelta della dimensione corretta della parola viene lasciata all'implementazione della JVM. Un'implementazione JVM dovrebbe scegliere una dimensione minima della parola di 32 bit. Può scegliere una dimensione di parola maggiore per aumentare l'efficienza. Né vi è alcuna restrizione che una JVM a 64 bit debba scegliere solo parole a 64 bit.

L'architettura sottostante non stabilisce che anche la dimensione della parola debba essere la stessa. JVM legge / scrive i dati parola per parola. Questo è il motivo per cui si potrebbe prendere più tempo per un lungo che un int .

Qui puoi trovare altre informazioni sullo stesso argomento.


4

Ho appena scritto un benchmark utilizzando il caliper .

I risultati sono abbastanza coerenti con il codice originale: una velocità di ~ 12x per l'utilizzo di intover long. Certamente sembra che stia succedendo il ciclo di srotolamento riportato da tmyklebu o qualcosa di molto simile.

timeIntDecrements         195,266,845.000
timeLongDecrements      2,321,447,978.000

Questo è il mio codice; nota che utilizza un'istantanea appena costruita di caliper, poiché non sono riuscito a capire come codificare rispetto alla loro versione beta esistente.

package test;

import com.google.caliper.Benchmark;
import com.google.caliper.Param;

public final class App {

    @Param({""+1}) int number;

    private static class IntTest {
        public static int v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    private static class LongTest {
        public static long v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    @Benchmark
    int timeLongDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            LongTest.reset();
            while (!LongTest.decrementAndCheck()) { k++; }
        }
        return (int)LongTest.v | k;
    }    

    @Benchmark
    int timeIntDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            IntTest.reset();
            while (!IntTest.decrementAndCheck()) { k++; }
        }
        return IntTest.v | k;
    }
}

1

Per la cronaca, questa versione fa un "riscaldamento" grezzo:

public class LongSpeed {

    private static long i = Integer.MAX_VALUE;
    private static int j = Integer.MAX_VALUE;

    public static void main(String[] args) {

        for (int x = 0; x < 10; x++) {
            runLong();
            runWord();
        }
    }

    private static void runLong() {
        System.out.println("Starting the long loop");
        i = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckI()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the long loop in " + (endTime - startTime) + "ms");
    }

    private static void runWord() {
        System.out.println("Starting the word loop");
        j = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckJ()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the word loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheckI() {
        return --i < 0;
    }

    private static boolean decrementAndCheckJ() {
        return --j < 0;
    }

}

I tempi complessivi migliorano di circa il 30%, ma il rapporto tra i due rimane grosso modo lo stesso.


@TedHopp - Ho provato a cambiare i limiti del loop nel mio ed è rimasto sostanzialmente invariato.
Hot Licks

@ Techrocket9: ottengo numeri simili ( intè 20ish volte più veloce) con questo codice.
tmyklebu

1

Per i record:

se uso

boolean decrementAndCheckLong() {
    lo = lo - 1l;
    return lo < -1l;
}

(cambiato "l--" in "l = l - 1l") le prestazioni lunghe migliorano del ~ 50%


0

Non ho una macchina a 64 bit con cui testare, ma la differenza piuttosto grande suggerisce che c'è più del bytecode leggermente più lungo al lavoro.

Vedo tempi molto vicini per long / int (4400 vs 4800ms) sul mio 1.7.0_45 a 32 bit.

Questa è solo un'ipotesi , ma ho il forte sospetto che sia l'effetto di una penalità di disallineamento della memoria. Per confermare / negare il sospetto, prova ad aggiungere un int dummy statico pubblico = 0; prima della dichiarazione di i. Ciò spingerà i verso il basso di 4 byte nel layout di memoria e potrebbe renderlo correttamente allineato per prestazioni migliori. Confermato di non causare il problema.

MODIFICARE: Il ragionamento alla base di ciò è che la VM potrebbe non riordinare i campi a suo piacimento aggiungendo padding per un allineamento ottimale, poiché ciò potrebbe interferire con JNI (Non è il caso).


La VM è certamente autorizzata a riordinare i campi e aggiungere riempimento.
Hot Licks

JNI deve accedere agli oggetti tramite questi metodi di accesso fastidiosi e lenti che richiedono comunque alcune maniglie opache poiché GC può verificarsi mentre il codice nativo è in esecuzione. È molto gratuito riordinare i campi e aggiungere imbottitura.
tmyklebu
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.