Perché if (variabile1% variabile2 == 0) è inefficiente?


179

Sono nuovo di Java e stavo eseguendo un codice la scorsa notte, e questo mi ha davvero infastidito. Stavo costruendo un semplice programma per visualizzare tutte le uscite X in un ciclo for, e ho notato una MASSIMA riduzione delle prestazioni, quando ho usato il modulo come variable % variablevs variable % 5000o whatnot. Qualcuno può spiegarmi perché questo è e cosa lo sta causando? Quindi posso essere migliore ...

Ecco il codice "efficiente" (scusate se ho un po 'di sintassi sbagliata, non sono sul computer con il codice in questo momento)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

Ecco il "codice inefficiente"

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

Intendiamoci, avevo una variabile di data per misurare le differenze, e una volta che è diventato abbastanza lungo, il primo ha impiegato 50ms mentre l'altro ha impiegato 12 secondi o qualcosa del genere. Potrebbe essere necessario aumentare stopNumo diminuire il valore progressCheckse il tuo PC è più efficiente del mio o no.

Ho cercato questa domanda sul Web, ma non riesco a trovare una risposta, forse non la sto facendo nel modo giusto.

EDIT: Non mi aspettavo che la mia domanda fosse così popolare, apprezzo tutte le risposte. Ho eseguito un benchmark su ogni metà del tempo impiegato e il codice inefficiente ha richiesto molto più tempo, 1/4 di secondo contro 10 secondi di dare o avere. Certo, stanno usando println, ma entrambi stanno facendo lo stesso importo, quindi non immaginerei che ciò possa distorcere molto, soprattutto perché la discrepanza è ripetibile. Per quanto riguarda le risposte, dato che sono nuovo di Java, permetterò ai voti di decidere per ora quale sia la risposta migliore. Proverò a sceglierne uno entro mercoledì.

EDIT2: Stasera farò un altro test, dove invece del modulo, incrementa semplicemente una variabile e quando raggiunge progressCheck ne eseguirà una, quindi resetterà quella variabile a 0. per una terza opzione.

EDIT3.5:

Ho usato questo codice e di seguito mostrerò i miei risultati .. Grazie a tutti per il meraviglioso aiuto! Ho anche provato a confrontare il valore breve del long con 0, quindi tutti i miei nuovi controlli si verificano sempre "65536" volte rendendolo uguale nelle ripetizioni.

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

risultati:

  • fisso = 874 ms (normalmente intorno a 1000ms, ma più veloce perché è una potenza di 2)
  • variabile = 8590 ms
  • variabile finale = 1944 ms (era ~ 1000ms quando si utilizza 50000)
  • incremento = 1904 ms
  • Conversione breve = 679 ms

Non abbastanza sorprendente, a causa della mancanza di divisione, la conversione corta è stata del 23% più veloce del modo "veloce". Questo è interessante da notare. Se devi mostrare o confrontare qualcosa ogni 256 volte (o lì), puoi farlo e utilizzare

if ((byte)integer == 0) {'Perform progress check code here'}

UNA NOTA DI INTERESSE FINALE, usando il modulo sulla "Variabile dichiarata finale" con 65536 (non un bel numero) era la metà della velocità (più lenta) del valore fisso. Dove prima era benchmarking alla stessa velocità.


29
Ho ottenuto lo stesso risultato in realtà. Sulla mia macchina, il primo ciclo funziona in circa 1,5 secondi e il secondo in circa 9 secondi. Se aggiungo finaldavanti alla progressCheckvariabile, corro di nuovo entrambi alla stessa velocità. Questo mi porta a credere che il compilatore o la JIT riescano a ottimizzare il loop quando sa che progressCheckè costante.
Marstran,


24
La divisione per una costante può essere facilmente convertita in una moltiplicazione per l'inverso moltiplicativo . La divisione per variabile non può. E una divisione a 32 bit è più veloce di una divisione a 64 bit su x86
phuclv,

2
@phuclv note La divisione a 32 bit non è un problema qui, è un'operazione di residuo a 64 bit in entrambi i casi
user85421

4
@RobertCotterman se si dichiara la variabile come finale, il compilatore crea lo stesso bytecode dell'uso della costante (eclipse / Java 11) ((nonostante si usi uno slot di memoria in più per la variabile))
user85421,

Risposte:


139

Si sta misurando lo stub OSR (sostituzione in pila) .

Lo stub OSR è una versione speciale del metodo compilato destinato in particolare al trasferimento dell'esecuzione dalla modalità interpretata al codice compilato mentre il metodo è in esecuzione.

Gli stub OSR non sono ottimizzati come i metodi normali, perché hanno bisogno di un layout di frame compatibile con il frame interpretato. L'ho già mostrato nelle seguenti risposte: 1 , 2 , 3 .

Una cosa simile accade anche qui. Mentre "codice inefficiente" esegue un ciclo lungo, il metodo viene compilato appositamente per la sostituzione in pila proprio all'interno del ciclo. Lo stato viene trasferito dal frame interpretato al metodo compilato OSR e questo stato include progressCheckla variabile locale. A questo punto JIT non può sostituire la variabile con la costante e quindi non può applicare alcune ottimizzazioni come la riduzione della forza .

In particolare, ciò significa che JIT non sostituisce la divisione intera con la moltiplicazione . (Vedi Perché GCC usa la moltiplicazione per uno strano numero nell'implementazione della divisione di numeri interi? Per il trucco asm da un compilatore in anticipo, quando il valore è una costante di tempo di compilazione dopo inline / propagazione costante, se tali ottimizzazioni sono abilitate Viene %anche ottimizzato un intero letterale a destra nell'espressione gcc -O0, simile a qui dove è ottimizzato da JITer anche in uno stub OSR.)

Tuttavia, se si esegue lo stesso metodo più volte, la seconda e le esecuzioni successive eseguiranno il codice normale (non OSR), che è completamente ottimizzato. Ecco un benchmark per dimostrare la teoria ( benchmarking usando JMH ):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

E i risultati:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

La prima iterazione di divVarè davvero molto più lenta, a causa dello stub OSR compilato in modo inefficiente. Ma non appena il metodo viene ripetuto dall'inizio, viene eseguita la nuova versione non vincolata che sfrutta tutte le ottimizzazioni del compilatore disponibili.


5
Esito a votare su questo. Da un lato, sembra un modo elaborato per dire "Hai incasinato il tuo benchmark, leggi qualcosa su JIT". D'altra parte, mi chiedo perché sembri essere così sicuro che l'OSR sia stato il principale punto rilevante qui. Voglio dire, fare un (micro) benchmark che coinvolge System.out.printlnprodurrà quasi necessariamente risultati spazzatura, e il fatto che entrambe le versioni siano ugualmente veloci non deve fare nulla con OSR in particolare , per quanto ne so ..
Marco13

2
(Sono curioso e mi piace capire questo. Spero che i commenti non siano inquietanti, potrebbero eliminarli in seguito, ma:) Il collegamento 1è un po 'dubbio - il ciclo vuoto potrebbe anche essere ottimizzato completamente. Il secondo è più simile a quello. Ma ancora una volta, non è chiaro il motivo per cui attribuisci la differenza all'OSR in modo specifico . Direi solo: ad un certo punto, il metodo è JIT e diventa più veloce. Per quanto ne so, l'OSR fa sì che (approssimativamente) l'uso del codice finale ottimizzato sia "rinviato al prossimo passaggio di ottimizzazione". (continua ...)
Marco 13,

1
(continua :) A meno che non si stia analizzando specificamente i log degli hotspot, non si può dire se la differenza sia causata dal confronto tra codice JITed e non-JIT, o confrontando JITed e il codice stub OSR. E certamente non puoi dirlo con certezza quando la domanda non contiene il codice reale o un benchmark JMH completo. Sostenendo quindi che la differenza è causata da suoni OSR, per me, inappropriatamente specifici (e "ingiustificati") rispetto al dire che è causata dalla SIC in generale. (Senza offesa - Mi sto solo chiedendo ...)
Marco13,

4
@ Marco13 c'è una semplice euristica: senza l'attività della SIC, ogni %operazione avrebbe lo stesso peso, dato che un'esecuzione ottimizzata è possibile, beh, se un ottimizzatore funzionasse davvero. Quindi il fatto che una variante ad anello sia significativamente più veloce dell'altra dimostra la presenza di un ottimizzatore e dimostra inoltre che non è riuscito a ottimizzare uno dei loop allo stesso livello dell'altro (all'interno dello stesso metodo!). Poiché questa risposta dimostra la capacità di ottimizzare entrambi i loop allo stesso livello, deve esserci qualcosa che ha ostacolato l'ottimizzazione. E questo è l'OSR nel 99,9% di tutti i casi
Holger,

4
@ Marco13 Quella era una "ipotesi istruita" basata sulla conoscenza di Runtime HotSpot e sull'esperienza di analisi di problemi simili prima. Un ciclo così lungo difficilmente potrebbe essere compilato in un modo diverso dall'OSR, specialmente in un semplice benchmark fatto a mano. Ora, quando OP ha pubblicato il codice completo, posso solo confermare nuovamente il ragionamento eseguendo il codice con -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
aprile

42

Nel seguito del commento di @phuclv , ho controllato il codice generato da JIT 1 , i risultati sono i seguenti:

per variable % 5000(divisione per costante):

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

per variable % variable:

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

Poiché la divisione richiede sempre più tempo della moltiplicazione, l'ultimo frammento di codice è meno performante.

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)

1 - Opzioni VM utilizzate: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main


14
Per dare un ordine di grandezza su "più lento", per x86_64: imulè 3 cicli, idivè compreso tra 30 e 90 cicli. Quindi la divisione dei numeri interi è tra 10x e 30x più lenta della moltiplicazione dei numeri interi.
Matthieu M.

2
Potresti spiegare cosa significhi tutto ciò per i lettori interessati, ma che non parlano assemblatore?
Nico Haase,

7
@NicoHaase Le due righe commentate sono le uniche importanti. Nella prima sezione, il codice sta eseguendo una moltiplicazione di numeri interi, mentre nella seconda sezione il codice sta eseguendo una divisione di numeri interi. Se pensi di fare moltiplicazione e divisione a mano, quando moltiplichi di solito stai facendo un mucchio di piccole moltiplicazioni e poi un grande insieme di aggiunte, ma la divisione è una piccola divisione, una piccola moltiplicazione, una sottrazione e ripetizione. La divisione è lenta perché essenzialmente stai facendo un sacco di moltiplicazioni.
MBraedley,

4
@MBraedley grazie per il tuo contributo, ma tale spiegazione dovrebbe essere aggiunta alla risposta stessa e non essere nascosta nella sezione commenti
Nico Haase,

6
@MBraedley: più precisamente, la moltiplicazione in una CPU moderna è veloce perché i prodotti parziali sono indipendenti e possono quindi essere calcolati separatamente, mentre ogni fase di una divisione dipende dalle fasi precedenti.
supercat

26

Come altri hanno notato, l'operazione di modulo generale richiede una divisione da eseguire. In alcuni casi, la divisione può essere sostituita (dal compilatore) con una moltiplicazione. Ma entrambi possono essere lenti rispetto all'addizione / sottrazione. Quindi, ci si può aspettare la migliore prestazione da qualcosa del genere:

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(Come piccolo tentativo di optmiziazione utilizziamo un down-counter pre-decremento qui perché su molte architetture il confronto con 0immediatamente dopo un'operazione aritmetica costa esattamente 0 istruzioni / cicli della CPU perché i flag dell'ALU sono già impostati correttamente dall'operazione precedente. il compilatore eseguirà comunque tale ottimizzazione automaticamente anche se si scrive if (counter++ == 50000) { ... counter = 0; }.)

Nota che spesso non vuoi / hai davvero bisogno del modulo, perché sai che il tuo contatore del loop ( i) o qualsiasi altra cosa viene incrementato solo di 1, e non ti interessa davvero il resto reale che il modulo ti darà, basta vedere se il contatore incrementale di uno raggiunge un valore.

Un altro 'trucco' è usare la potenza di due valori / limiti, ad es progressCheck = 1024;. Modulo una potenza di due può essere rapidamente calcolata tramite bit a bit and, cioè if ( (i & (1024-1)) == 0 ) {...}. Anche questo dovrebbe essere abbastanza veloce, e su alcune architetture potrebbe superare l'esplicito countersopra.


3
Un compilatore intelligente invertirebbe i loop qui. Oppure potresti farlo nella fonte. Il if()corpo diventa un corpo ad anello esterno e le cose al di fuori if()diventano un corpo ad anello interno che corre per min(progressCheck, stopNum-i)iterazioni. Quindi all'inizio, e ogni volta che counterraggiunge 0, devi fare long next_stop = i + min(progressCheck, stopNum-i);un for(; i< next_stop; i++) {}ciclo. In questo caso quel loop interno è vuoto e si spera che si ottimizzi completamente, puoi farlo nel sorgente e renderlo facile per il JITer, riducendo il tuo loop a i + = 50k.
Peter Cordes,

2
Ma sì, in generale un down-counter è una buona tecnica efficiente per roba di tipo fizzbuzz / progresscheck.
Peter Cordes,

Ho aggiunto alla mia domanda e ho fatto un incremento, --counterè veloce quanto la mia versione di incremento, ma meno code.anche era 1 in meno di quanto dovrebbe essere, sono curioso di sapere se dovrebbe essere counter--per ottenere il numero esatto che vuoi , non che sia una grande differenza
Robert Cotterman,

@PeterCordes Un compilatore intelligente stamperebbe semplicemente i numeri, senza alcun ciclo. (Penso che alcuni benchmark leggermente più banali abbiano iniziato a fallire in quel modo forse 10 anni fa.)
Peter - Reinstalla Monica il

2
@RobertCotterman Sì, --counterè spento da uno. counter--ti fornirà esattamente il progressChecknumero di iterazioni (o potresti impostare progressCheck = 50001;ovviamente).
JimmyB,

4

Sono anche sorpreso di vedere le prestazioni dei codici sopra. Riguarda il tempo impiegato dal compilatore per eseguire il programma secondo la variabile dichiarata. Nel secondo esempio (inefficiente):

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

Stai eseguendo l'operazione del modulo tra due variabili. Qui, il compilatore deve controllare il valore di stopNume progressCheckandare al blocco di memoria specifico situato per queste variabili ogni volta dopo ogni iterazione perché è una variabile e il suo valore potrebbe essere cambiato.

Ecco perché dopo ogni compilatore di iterazioni è andato nella posizione di memoria per verificare l'ultimo valore delle variabili. Pertanto al momento della compilazione il compilatore non è stato in grado di creare un codice byte efficiente.

Nel primo esempio di codice, si sta eseguendo un operatore di modulo tra una variabile e un valore numerico costante che non cambierà durante l'esecuzione e non è necessario che il compilatore controlli il valore di quel valore numerico dalla posizione di memoria. Ecco perché il compilatore è stato in grado di creare un codice byte efficiente. Se dichiari progressCheckcome una finalo come final staticvariabile, al momento del compilatore run-time / compile-time sappi che si tratta di una variabile finale e che il suo valore non cambierà, quindi il compilatore sostituisce il progressCheckcon il 50000codice:

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

Ora puoi vedere che questo codice sembra anche il primo (efficiente) esempio di codice. Le prestazioni del primo codice e, come detto sopra, entrambi i codici funzioneranno in modo efficiente. Non ci sarà molta differenza nel tempo di esecuzione di entrambi gli esempi di codice.


1
C'è un'enorme differenza, anche se stavo facendo l'operazione un trilione di volte, quindi oltre 1 trilione di operazioni ha risparmiato l'89% di tempo per fare il codice "efficiente". ti dispiace se lo fai solo poche migliaia di volte, stavi parlando di una differenza così piccola, probabilmente non è un grosso problema. intendo oltre 1000 operazioni ti farebbe risparmiare 1 milionesimo di 7 secondi.
Robert Cotterman,

1
@Bishal Dubey "Non ci sarà molta differenza nel tempo di esecuzione di entrambi i codici." Hai letto la domanda?
Grant Foster,

"Ecco perché dopo ogni compilatore di iterazioni è andato nella posizione di memoria per verificare l'ultimo valore delle variabili" - A meno che la variabile non sia stata dichiarata, volatileil "compilatore" non rileverà ripetutamente il suo valore dalla RAM.
JimmyB,
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.