Perché (a * b! = 0) è più veloce di (a! = 0 && b! = 0) in Java?


412

Sto scrivendo del codice in Java dove, ad un certo punto, il flusso del programma è determinato dal fatto che due variabili int, "a" e "b", siano diverse da zero (nota: aeb non sono mai negative, e mai all'interno dell'intervallo di overflow dei numeri interi).

Posso valutarlo con

if (a != 0 && b != 0) { /* Some code */ }

O in alternativa

if (a*b != 0) { /* Some code */ }

Poiché mi aspetto che quel pezzo di codice venga eseguito milioni di volte per esecuzione, mi chiedevo quale sarebbe stato più veloce. Ho fatto l'esperimento confrontandoli su un enorme array generato casualmente ed ero anche curioso di vedere come la scarsità dell'array (frazione di dati = 0) avrebbe influenzato i risultati:

long time;
final int len = 50000000;
int arbitrary = 0;
int[][] nums = new int[2][len];

for (double fraction = 0 ; fraction <= 0.9 ; fraction += 0.0078125) {
    for(int i = 0 ; i < 2 ; i++) {
        for(int j = 0 ; j < len ; j++) {
            double random = Math.random();

            if(random < fraction) nums[i][j] = 0;
            else nums[i][j] = (int) (random*15 + 1);
        }
    }

    time = System.currentTimeMillis();

    for(int i = 0 ; i < len ; i++) {
        if( /*insert nums[0][i]*nums[1][i]!=0 or nums[0][i]!=0 && nums[1][i]!=0*/ ) arbitrary++;
    }
    System.out.println(System.currentTimeMillis() - time);
}

E i risultati mostrano che se ti aspetti che "a" o "b" sia uguale a 0 in più di ~ 3% del tempo, a*b != 0è più veloce di a!=0 && b!=0:

Grafico grafico dei risultati di a AND b diverso da zero

Sono curioso di sapere perché. Qualcuno potrebbe far luce? È il compilatore o è a livello hardware?

Modifica: per curiosità ... ora che ho imparato a conoscere la previsione del ramo, mi chiedevo che cosa il confronto analogico avrebbe mostrato per un OR b è diverso da zero:

Grafico di a o b diverso da zero

Vediamo lo stesso effetto della previsione del ramo come previsto, il grafico è in qualche modo capovolto lungo l'asse X.

Aggiornare

1- Ho aggiunto !(a==0 || b==0)all'analisi per vedere cosa succede.

2- Ho anche incluso a != 0 || b != 0, (a+b) != 0e (a|b) != 0per curiosità, dopo aver appreso la previsione del ramo. Ma non sono logicamente equivalenti alle altre espressioni, perché solo un OR b deve essere diverso da zero per restituire vero, quindi non devono essere confrontati per l'efficienza di elaborazione.

3- Ho anche aggiunto il benchmark effettivo che ho usato per l'analisi, che sta solo ripetendo una variabile int arbitraria.

4- Alcune persone hanno suggerito di includere a != 0 & b != 0 invece di a != 0 && b != 0, con la previsione che si sarebbe comportato più da vicino a*b != 0perché avremmo rimosso l'effetto di previsione del ramo. Non sapevo che &potesse essere usato con variabili booleane, pensavo che fosse usato solo per operazioni binarie con numeri interi.

Nota: nel contesto che stavo considerando tutto ciò, int overflow non è un problema, ma è sicuramente una considerazione importante in contesti generali.

CPU: Intel Core i7-3610QM @ 2.3GHz

Versione Java: 1.8.0_45
Java (TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot (TM) VM a 64 bit Server (build 25.45-b02, modalità mista)


11
Che dire if (!(a == 0 || b == 0))? I microbench sono notoriamente inaffidabili, è improbabile che ciò sia realmente misurabile (~ 3% mi sembra un margine di errore).
Elliott Frisch,

9
Or a != 0 & b != 0.
Louis Wasserman,

16
La ramificazione è lenta se la diramazione prevista è errata. a*b!=0ha una filiale in meno
Erwin Bolwidt,

19
(1<<16) * (1<<16) == 0eppure entrambi sono diversi da zero.
CodesInChaos,

13
@Gene: l'ottimizzazione proposta non è valida. Anche ignorando l'overflow, a*bè zero se uno di aed bè zero; a|bè zero solo se entrambi lo sono.
hmakholm ha lasciato Monica il

Risposte:


240

Sto ignorando il problema che il tuo benchmarking potrebbe essere difettoso e prendo il risultato al valore nominale.

È il compilatore o è a livello hardware?

Quest'ultimo, penso:

  if (a != 0 && b != 0)

verrà compilato su 2 carichi di memoria e due rami condizionali

  if (a * b != 0)

compilerà 2 carichi di memoria, un ramo moltiplicativo e uno secondario.

È probabile che la moltiplicazione sia più veloce del secondo ramo condizionale se la previsione del ramo a livello hardware è inefficace. Man mano che aumenti il ​​rapporto ... la previsione del ramo sta diventando meno efficace.

Il motivo per cui i rami condizionali sono più lenti è che causano l'arresto della pipeline di esecuzione dell'istruzione. La previsione del ramo consiste nell'evitare lo stallo predicendo in che direzione andrà il ramo e scegliendo speculativamente l'istruzione successiva in base a quello. Se la previsione non riesce, si verifica un ritardo durante il caricamento delle istruzioni per l'altra direzione.

(Nota: la spiegazione sopra è semplificata. Per una spiegazione più accurata, è necessario consultare la documentazione fornita dal produttore della CPU per i codificatori del linguaggio assembly e gli autori di compilatori. La pagina Wikipedia su Branch Predictors è un buon background.)


Tuttavia, c'è una cosa che devi fare attenzione con questa ottimizzazione. Ci sono dei valori dove a * b != 0dare la risposta sbagliata? Considerare i casi in cui il calcolo del prodotto provoca un overflow di numeri interi.


AGGIORNARE

I tuoi grafici tendono a confermare ciò che ho detto.

  • C'è anche un effetto di "previsione del ramo" nel a * b != 0caso del ramo condizionale , e questo emerge nei grafici.

  • Se si proiettano le curve oltre 0,9 sull'asse X, sembra che 1) si incontreranno a circa 1,0 e 2) il punto di incontro avrà approssimativamente lo stesso valore Y di X = 0,0.


AGGIORNAMENTO 2

Non capisco perché le curve siano diverse per a + b != 0i a | b != 0casi e. Ci potrebbe essere qualcosa di intelligente nella logica predizione delle diramazioni. Oppure potrebbe indicare qualcos'altro.

(Si noti che questo tipo di cose può essere specifico per un determinato numero di modello di chip o anche per una versione. I risultati dei benchmark potrebbero essere diversi su altri sistemi.)

Tuttavia, entrambi hanno il vantaggio di lavorare per tutti i valori non negativi di ae b.


1
@DebosmitRay - 1) Non ci dovrebbero essere SW. I risultati intermedi verranno conservati in un registro. 2) Nel secondo caso, ci sono due rami disponibili: uno per eseguire "un po 'di codice" e l'altro per saltare all'istruzione successiva dopo il if.
Stephen C,

1
@StephenC hai ragione a essere confuso su a + b e a | b, perché le curve sono le stesse, penso che i colori siano davvero vicini. Ci scusiamo per i non vedenti!
Maljam,

3
@ njzk2 dal punto di vista della probabilità quei casi dovrebbero essere simmetrici in base all'asse del 50% (probabilità di zero di a&be a|b). Sono, ma non perfettamente, questo è il puzzle.
Antonín Lejsek,

3
@StephenC Il motivo per cui a*b != 0e a+b != 0benchmark in modo diverso è perché a+b != 0non è affatto equivalente e non avrebbe mai dovuto essere benchmark. Ad esempio, con a = 1, b = 0, la prima espressione restituisce false, mentre la seconda valuta true. Il moltiplicarsi si comporta come un operatore e , mentre l'aggiunta si comporta come un operatore o .
JS1,

2
@ AntonínLejsek Penso che le probabilità sarebbero diverse. Se hai nzeri, aumenta la probabilità di entrambi ae di bessere pari a zero n. In ANDun'operazione, con maggiore è nla probabilità che uno di essi sia diverso da zero e la condizione è soddisfatta. Questo è l'opposto di ORun'operazione (la probabilità che uno di essi sia zero aumenta con n). Questo si basa su una prospettiva matematica. Non sono sicuro se funziona così l'hardware.
WYSIWYG,

70

Penso che il tuo benchmark abbia alcuni difetti e potrebbe non essere utile per dedurre programmi reali. Ecco i miei pensieri:

  • (a|b)!=0e (a+b)!=0verifica se uno dei due valori è diverso da zero, mentre a != 0 && b != 0e (a*b)!=0verifica se entrambi sono diversi da zero. Quindi non stai confrontando i tempi solo dell'aritmetica: se la condizione è vera più spesso, provoca più esecuzioni del ifcorpo, il che richiede anche più tempo.

  • (a+b)!=0 farà la cosa sbagliata per valori positivi e negativi che si sommano a zero, quindi non puoi usarlo nel caso generale, anche se funziona qui.

  • Allo stesso modo, (a*b)!=0farà la cosa sbagliata per valori che traboccano. (Esempio casuale: 196608 * 327680 è 0 perché il risultato reale sembra essere divisibile per 2 32 , quindi i suoi 32 bit bassi sono 0 e quei bit sono tutto ciò che ottieni se si tratta di intun'operazione.)

  • La VM ottimizzerà l'espressione durante le prime esecuzioni del fractionciclo esterno ( ), quando fractionè 0, quando i rami non vengono quasi mai presi. L'ottimizzatore può fare cose diverse se inizi fractionda 0,5.

  • A meno che la VM non sia in grado di eliminare alcuni dei controlli dei limiti dell'array qui, ci sono altri quattro rami nell'espressione proprio a causa dei controlli dei limiti, e questo è un fattore complicante quando si cerca di capire cosa sta succedendo a basso livello. Potresti ottenere risultati diversi se dividi l'array bidimensionale in due array piatti, cambiando nums[0][i]e nums[1][i]in nums0[i]e nums1[i].

  • I predittori di rami CPU rilevano brevi schemi nei dati o esecuzioni di tutti i rami presi o non eseguiti. I dati di benchmark generati casualmente rappresentano lo scenario peggiore per un predittore di filiali . Se i dati del mondo reale hanno un modello prevedibile o hanno lunghe serie di valori tutti zero e tutti diversi da zero, i rami potrebbero costare molto meno.

  • Il codice particolare che viene eseguito dopo il soddisfacimento della condizione può influire sulle prestazioni della valutazione della condizione stessa, poiché influisce su cose come la possibilità o meno di srotolare il loop, quali registri della CPU sono disponibili e se è numsnecessario essere riutilizzato dopo aver valutato la condizione. Semplicemente incrementare un contatore nel benchmark non è un segnaposto perfetto per ciò che il codice reale farebbe.

  • System.currentTimeMillis()sulla maggior parte dei sistemi non è più preciso di +/- 10 ms. System.nanoTime()di solito è più preciso.

Ci sono molte incertezze ed è sempre difficile dire qualcosa di definito con questo tipo di micro-ottimizzazioni perché un trucco più veloce su una VM o CPU può essere più lento su un'altra. Se si esegue la JVM HotSpot a 32 bit, anziché la versione a 64 bit, tenere presente che è disponibile in due versioni: con la VM "Client" con ottimizzazioni diverse (più deboli) rispetto alla VM "Server".

Se riesci a disassemblare il codice macchina generato dalla VM , fallo invece di provare a indovinare cosa fa!


24

Le risposte qui sono buone, anche se avevo un'idea che potesse migliorare le cose.

Poiché i due rami e la previsione del ramo associato sono i probabili colpevoli, potremmo essere in grado di ridurre la ramificazione a un singolo ramo senza cambiare affatto la logica.

bool aNotZero = (nums[0][i] != 0);
bool bNotZero = (nums[1][i] != 0);
if (aNotZero && bNotZero) { /* Some code */ }

Potrebbe anche funzionare

int a = nums[0][i];
int b = nums[1][i];
if (a != 0 && b != 0) { /* Some code */ }

Il motivo è che, secondo le regole del corto circuito, se il primo booleano è falso, il secondo non dovrebbe essere valutato. Deve eseguire un ramo aggiuntivo per evitare di valutare nums[1][i]se nums[0][i]fosse falso. Ora, potresti non preoccuparti che nums[1][i]venga valutato, ma il compilatore non può essere certo che non eseguirà un riferimento fuori portata o null quando lo fai. Riducendo il blocco if a bool semplici, il compilatore può essere abbastanza intelligente da rendersi conto che valutare inutilmente il secondo booleano non avrà effetti collaterali negativi.


3
Upvoted anche se ho la sensazione che questo non del tutto rispondere alla domanda.
Pierre Arlaud,

3
Questo è un modo per introdurre un ramo senza cambiare la logica dal non-ramo (se il modo in cui hai ottenuto ae bavessi effetti collaterali li avresti tenuti). Hai ancora, &&quindi hai ancora un ramo.
Jon Hanna,

11

Quando prendiamo la moltiplicazione, anche se un numero è 0, il prodotto è 0. Durante la scrittura

    (a*b != 0)

Valuta il risultato del prodotto eliminando così le prime poche occorrenze dell'iterazione a partire da 0. Di conseguenza i confronti sono inferiori a quello quando la condizione è

   (a != 0 && b != 0)

Dove ogni elemento viene confrontato con 0 e valutato. Quindi il tempo richiesto è inferiore. Ma credo che la seconda condizione possa darti una soluzione più accurata.


4
Nella seconda espressione, se aè zero b, non è necessario valutarlo poiché l'intera espressione è già falsa. Quindi ogni elemento a confronto non è vero.
Kuba Wyrostek,

9

Stai utilizzando dati di input randomizzati che rendono imprevedibili i rami. In pratica i rami sono spesso prevedibili (~ 90%), quindi nel codice reale è probabile che il codice ramificato sia più veloce.

Detto ciò. Non vedo come a*b != 0possa essere più veloce di (a|b) != 0. Generalmente la moltiplicazione dei numeri interi è più costosa di un OR bit a bit. Ma cose come questa a volte diventano strane. Vedere ad esempio l'esempio "Esempio 7: complessità dell'hardware" dalla Galleria degli effetti della cache del processore .


2
&non è un "OR bit per bit" ma (in questo caso) un "AND logico" perché entrambi gli operandi sono booleani e non lo sono |;-)
siegi

1
@siegi TIL Java '&' è in realtà un AND logico senza corto circuito.
StackedCrooked
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.