Qual è l'effetto di ordinare se ... altrimenti se le dichiarazioni per probabilità?


187

In particolare, se ho una serie di dichiarazioni if... else ife in qualche modo conosco in anticipo la probabilità relativa alla quale ciascuna valutazione valuteràtrue , quanta differenza fa nel tempo di esecuzione fa ordinarle in ordine di probabilità? Ad esempio, dovrei preferire questo:

if (highly_likely)
  //do something
else if (somewhat_likely)
  //do something
else if (unlikely)
  //do something

a questa?:

if (unlikely)
  //do something
else if (somewhat_likely)
  //do something
else if (highly_likely)
  //do something

Sembra ovvio che la versione ordinata sarebbe più veloce, tuttavia per leggibilità o esistenza di effetti collaterali, potremmo volerli ordinare in modo non ottimale. È anche difficile dire quanto bene farà la CPU con la previsione del ramo fino a quando non si esegue effettivamente il codice.

Quindi, nel corso della sperimentazione, ho finito per rispondere alla mia domanda per un caso specifico, ma mi piacerebbe anche ascoltare altre opinioni / intuizioni.

Importante: questa domanda presuppone che le ifdichiarazioni possano essere riordinate arbitrariamente senza avere altri effetti sul comportamento del programma. Nella mia risposta, i tre test condizionali si escludono a vicenda e non producono effetti collaterali. Certamente, se le dichiarazioni devono essere valutate in un certo ordine per ottenere un comportamento desiderato, allora la questione dell'efficienza è controversa.


35
potresti voler aggiungere una nota che le condizioni si escludono a vicenda, altrimenti le due versioni non sono equivalenti
idclev 463035818

28
È piuttosto interessante come una domanda con risposta autonoma abbia ottenuto oltre 20 voti con una risposta piuttosto scadente, in un'ora. Non chiamare nulla su OP, ma gli utenti devono stare attenti a saltare sul carro della banda. La domanda potrebbe essere interessante, ma i risultati sono dubbi.
luk32,

3
Credo che questo possa essere descritto come una forma di valutazione del corto circuito perché colpire un confronto nega di aver fatto un confronto diverso. Personalmente preferisco un'implementazione come questa quando un confronto veloce, diciamo booleano, può impedirmi di entrare in un confronto diverso che potrebbe comportare una manipolazione di stringhe, una regex o un'interazione di database con risorse pesanti.
MonkeyZeus,

11
Alcuni compilatori offrono la possibilità di raccogliere statistiche sui rami presi e di inviarli nuovamente al compilatore per consentirgli di effettuare ottimizzazioni migliori.

11
Se per te queste prestazioni sono importanti, dovresti probabilmente provare l'ottimizzazione guidata del profilo e confrontare il risultato manuale con il risultato del compilatore
Giustino

Risposte:


96

Come regola generale, la maggior parte se non tutte le CPU Intel presumono che i rami forward non vengano presi la prima volta che li vedono. Guarda il lavoro di Godbolt .

Successivamente, il ramo entra in una cache di previsione del ramo e il comportamento passato viene utilizzato per informare la previsione del ramo futuro.

Quindi, in un circuito ristretto, l'effetto del misordering sarà relativamente piccolo. Il predittore di rami apprenderà quale insieme di rami è più probabile e se hai una quantità non banale di lavoro nel ciclo, le piccole differenze non si sommano molto.

Nel codice generale, la maggior parte dei compilatori per impostazione predefinita (senza un altro motivo) ordinerà il codice macchina prodotto approssimativamente nel modo in cui è stato ordinato nel codice. Pertanto, se le istruzioni sono diramazioni in avanti quando falliscono.

Quindi dovresti ordinare i tuoi rami in ordine di probabilità decrescente per ottenere la migliore previsione dei rami da un "primo incontro".

Un microbenchmark che si avvolge strettamente più volte su una serie di condizioni e fa un lavoro banale sarà dominato da piccoli effetti del conteggio delle istruzioni e simili, e poco in termini di problemi relativi alla previsione del ramo. Quindi in questo caso devi profilare , poiché le regole empiriche non saranno affidabili.

Inoltre, la vettorializzazione e molte altre ottimizzazioni si applicano a piccoli circuiti stretti.

Quindi, nel codice generale, inserisci il codice più probabile all'interno di if blocco e ciò comporterà il minor numero di errori di predizione della filiale non memorizzati nella cache. In loop stretti, segui la regola generale per iniziare e, se hai bisogno di saperne di più, non hai altra scelta che profilare.

Naturalmente tutto questo esce dalla finestra se alcuni test sono molto più economici di altri.


19
Vale anche la pena considerare quanto costosi siano i test stessi: se un test è solo leggermente più probabile, ma molto più costoso, allora potrebbe valere la pena mettere l'altro test prima, perché i risparmi derivanti dal non effettuare il test costoso probabilmente supereranno il risparmi dalla previsione del ramo ecc.
psmears,

Il link che hai fornito non supporta le tue conclusioni Come regola generale, la maggior parte se non tutte le CPU Intel presumono che i rami forward non vengano presi la prima volta che li vedono . In realtà questo è vero solo per la CPU Arrendale relativamente oscura i cui risultati sono mostrati per primi. I risultati principali di Ivy Bridge e Haswell non lo supportano affatto. Haswell sembra molto vicino a "prevedere sempre la caduta" per i rami invisibili, e Ivy Bridge non è affatto chiaro.
BeeOnRope,

Resta generalmente inteso che le CPU non utilizzano realmente le previsioni statiche come in passato. In effetti, la moderna Intel sta probabilmente usando qualcosa di simile a un predittore TAGE probabilistico. È sufficiente inserire la cronologia delle filiali in varie tabelle della cronologia e prenderne una corrispondente alla cronologia più lunga. Usa un "tag" per cercare di evitare l'aliasing, ma il tag ha solo pochi bit. Se perdi tutte le lunghezze della cronologia, probabilmente viene fatta una previsione predefinita che non dipende necessariamente dalla direzione del ramo (su Haswell possiamo dire chiaramente che non lo fa).
BeeOnRope,

44

Ho composto il seguente test per calcolare l'esecuzione di due diversi blocchi if... else if, uno ordinato in ordine di probabilità, l'altro in ordine inverso:

#include <chrono>
#include <iostream>
#include <random>
#include <algorithm>
#include <iterator>
#include <functional>

using namespace std;

int main()
{
    long long sortedTime = 0;
    long long reverseTime = 0;

    for (int n = 0; n != 500; ++n)
    {
        //Generate a vector of 5000 random integers from 1 to 100
        random_device rnd_device;
        mt19937 rnd_engine(rnd_device());
        uniform_int_distribution<int> rnd_dist(1, 100);
        auto gen = std::bind(rnd_dist, rnd_engine);
        vector<int> rand_vec(5000);
        generate(begin(rand_vec), end(rand_vec), gen);

        volatile int nLow, nMid, nHigh;
        chrono::time_point<chrono::high_resolution_clock> start, end;

        //Sort the conditional statements in order of increasing likelyhood
        nLow = nMid = nHigh = 0;
        start = chrono::high_resolution_clock::now();
        for (int& i : rand_vec) {
            if (i >= 95) ++nHigh;               //Least likely branch
            else if (i < 20) ++nLow;
            else if (i >= 20 && i < 95) ++nMid; //Most likely branch
        }
        end = chrono::high_resolution_clock::now();
        reverseTime += chrono::duration_cast<chrono::nanoseconds>(end-start).count();

        //Sort the conditional statements in order of decreasing likelyhood
        nLow = nMid = nHigh = 0;
        start = chrono::high_resolution_clock::now();
        for (int& i : rand_vec) {
            if (i >= 20 && i < 95) ++nMid;  //Most likely branch
            else if (i < 20) ++nLow;
            else if (i >= 95) ++nHigh;      //Least likely branch
        }
        end = chrono::high_resolution_clock::now();
        sortedTime += chrono::duration_cast<chrono::nanoseconds>(end-start).count();

    }

    cout << "Percentage difference: " << 100 * (double(reverseTime) - double(sortedTime)) / double(sortedTime) << endl << endl;
}

Utilizzando MSVC2017 con / O2, i risultati mostrano che la versione ordinata è costantemente circa il 28% più veloce rispetto alla versione non ordinata. Per il commento di luk32, ho anche cambiato l'ordine dei due test, il che fa una notevole differenza (22% vs 28%). Il codice è stato eseguito su Windows 7 su un Intel Xeon E5-2697 v2. Questo è, ovviamente, molto specifico per i problemi e non dovrebbe essere interpretato come una risposta conclusiva.


9
OP dovrebbe fare attenzione, poiché la modifica di if... else ifun'istruzione potrebbe avere un effetto sostanziale sul modo in cui la logica scorre attraverso il codice. Il unlikelycontrollo potrebbe non presentarsi spesso, ma potrebbe esserci l'esigenza aziendale di verificare la unlikelycondizione prima di verificare la presenza di altri.
Luke T Brooks,

21
30% più veloce? Vuoi dire che è stato più veloce di circa la% di extra se le dichiarazioni non ha dovuto eseguire? Sembra un risultato abbastanza ragionevole.
UKMonkey,

5
Come lo hai valutato? Quale compilatore, CPU, ecc.? Sono abbastanza sicuro che questo risultato non sia portatile.
luk32,

12
Un problema con questo microbenchmark è che la CPU risolverà quale dei rami è più probabile e lo memorizzerà nella cache quando lo ripeterai ripetutamente. Se i rami non sono stati esaminati in un piccolo circuito stretto, la cache di previsione dei rami potrebbe non averli al suo interno e i costi potrebbero essere molto più elevati se la CPU indovina in modo errato con la guida della cache di previsione dei rami zero.
Yakk - Adam Nevraumont,

6
Questo benchmark non è troppo affidabile. Compilare con gcc 6.3.0 : g++ -O2 -march=native -std=c++14dà un leggero vantaggio alle istruzioni condizionali ordinate, ma il più delle volte, la differenza percentuale tra le due esecuzioni era ~ 5%. Diverse volte, in realtà è stato più lento (a causa di variazioni). Sono abbastanza sicuro che ordinare le ifs in questo modo non valga la pena preoccuparsi; Probabilmente la PGO gestirà completamente questi casi
Giustino il

30

No, non dovresti, a meno che tu non sia veramente sicuro che il sistema di destinazione sia interessato.Per impostazione predefinita, vai per leggibilità.

Dubito fortemente dei tuoi risultati. Ho modificato un po 'il tuo esempio, quindi invertire l'esecuzione è più semplice. Ideone mostra piuttosto coerentemente che l'ordine inverso è più veloce, anche se non molto. Su alcune corse anche questo occasionalmente capovolto. Direi che i risultati non sono conclusivi. anche coliru non riporta alcuna differenza reale. Posso controllare la CPU Exynos5422 sul mio odroid xu4 in seguito.

Il fatto è che le moderne CPU hanno predittori di filiali. C'è molta logica dedicata al pre-recupero di dati e istruzioni, e le moderne CPU x86 sono piuttosto intelligenti, quando si tratta di questo. Alcune architetture più sottili come ARM o GPU potrebbero essere vulnerabili a questo. Ma dipende molto sia dal compilatore che dal sistema di destinazione.

Direi che l'ottimizzazione dell'ordinamento delle filiali è piuttosto fragile ed effimera. Fallo solo come un passo davvero perfezionato.

Codice:

#include <chrono>
#include <iostream>
#include <random>
#include <algorithm>
#include <iterator>
#include <functional>

using namespace std;

int main()
{
    //Generate a vector of random integers from 1 to 100
    random_device rnd_device;
    mt19937 rnd_engine(rnd_device());
    uniform_int_distribution<int> rnd_dist(1, 100);
    auto gen = std::bind(rnd_dist, rnd_engine);
    vector<int> rand_vec(5000);
    generate(begin(rand_vec), end(rand_vec), gen);
    volatile int nLow, nMid, nHigh;

    //Count the number of values in each of three different ranges
    //Run the test a few times
    for (int n = 0; n != 10; ++n) {

        //Run the test again, but now sort the conditional statements in reverse-order of likelyhood
        {
          nLow = nMid = nHigh = 0;
          auto start = chrono::high_resolution_clock::now();
          for (int& i : rand_vec) {
              if (i >= 95) ++nHigh;               //Least likely branch
              else if (i < 20) ++nLow;
              else if (i >= 20 && i < 95) ++nMid; //Most likely branch
          }
          auto end = chrono::high_resolution_clock::now();
          cout << "Reverse-sorted: \t" << chrono::duration_cast<chrono::nanoseconds>(end-start).count() << "ns" << endl;
        }

        {
          //Sort the conditional statements in order of likelyhood
          nLow = nMid = nHigh = 0;
          auto start = chrono::high_resolution_clock::now();
          for (int& i : rand_vec) {
              if (i >= 20 && i < 95) ++nMid;  //Most likely branch
              else if (i < 20) ++nLow;
              else if (i >= 95) ++nHigh;      //Least likely branch
          }
          auto end = chrono::high_resolution_clock::now();
          cout << "Sorted:\t\t\t" << chrono::duration_cast<chrono::nanoseconds>(end-start).count() << "ns" << endl;
        }
        cout << endl;
    }
}

Ottengo la stessa differenza del 30% circa delle prestazioni quando cambio l'ordine degli if-block ordinati e in ordine inverso, come è stato fatto nel tuo codice. Non sono sicuro del motivo per cui Ideone e Coliru non mostrano alcuna differenza.
Carlton,

Sicuramente interessante. Cercherò di ottenere alcuni dati per altri sistemi, ma potrebbe richiedere del tempo prima che io debba giocare con esso. La domanda è interessante, soprattutto alla luce dei tuoi risultati, ma sono così spettacolari che ho dovuto fare un controllo incrociato.
luk32,

Se la domanda è: qual è l'effetto? la risposta non può essere No !
PJTraill,

Sì. Ma non ricevo notifiche per gli aggiornamenti alla domanda originale. Hanno reso obsoleta la formulazione della risposta. Scusate. Modificherò il contenuto in seguito, per sottolineare che ha risposto alla domanda originale e mostrato alcuni risultati che hanno dimostrato il punto originale.
luk32,

Vale la pena ripetere: "Per impostazione predefinita, passa per leggibilità". La scrittura di codice leggibile ti porterà spesso a risultati migliori rispetto al tentativo di ottenere un piccolo aumento delle prestazioni (in termini assoluti) rendendo il tuo codice più difficile da analizzare per gli umani.
Andrew Brēza,

26

Solo i miei 5 centesimi. Sembra l'effetto di ordinare se le dichiarazioni dovessero dipendere da:

  1. Probabilità di ciascuna istruzione if.

  2. Numero di iterazioni, in modo che il predittore di diramazioni possa intervenire.

  3. Suggerimenti del compilatore probabile / improbabile, ovvero layout del codice.

Per esplorare questi fattori, ho confrontato le seguenti funzioni:

ordered_ifs ()

for (i = 0; i < data_sz * 1024; i++) {
    if (data[i] < check_point) // highly likely
        s += 3;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (data[i] == check_point) // very unlikely
        s += 1;
}

reversed_ifs ()

for (i = 0; i < data_sz * 1024; i++) {
    if (data[i] == check_point) // very unlikely
        s += 1;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (data[i] < check_point) // highly likely
        s += 3;
}

ordered_ifs_with_hints ()

for (i = 0; i < data_sz * 1024; i++) {
    if (likely(data[i] < check_point)) // highly likely
        s += 3;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (unlikely(data[i] == check_point)) // very unlikely
        s += 1;
}

reversed_ifs_with_hints ()

for (i = 0; i < data_sz * 1024; i++) {
    if (unlikely(data[i] == check_point)) // very unlikely
        s += 1;
    else if (data[i] > check_point) // samewhat likely
        s += 2;
    else if (likely(data[i] < check_point)) // highly likely
        s += 3;
}

dati

L'array di dati contiene numeri casuali tra 0 e 100:

const int RANGE_MAX = 100;
uint8_t data[DATA_MAX * 1024];

static void data_init(int data_sz)
{
    int i;
        srand(0);
    for (i = 0; i < data_sz * 1024; i++)
        data[i] = rand() % RANGE_MAX;
}

I risultati

I seguenti risultati sono per Intel i5 a 3,2 GHz e G ++ 6.3.0. Il primo argomento è il check_point (ovvero la probabilità in %% per l'istruzione if altamente probabile), il secondo argomento è data_sz (ovvero il numero di iterazioni).

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/50/8                   25636 ns      25635 ns      27852
ordered_ifs/75/4                    4326 ns       4325 ns     162613
ordered_ifs/75/8                   18242 ns      18242 ns      37931
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs/100/8                   3381 ns       3381 ns     207612
reversed_ifs/50/4                   5342 ns       5341 ns     126800
reversed_ifs/50/8                  26050 ns      26050 ns      26894
reversed_ifs/75/4                   3616 ns       3616 ns     193130
reversed_ifs/75/8                  15697 ns      15696 ns      44618
reversed_ifs/100/4                  3738 ns       3738 ns     188087
reversed_ifs/100/8                  7476 ns       7476 ns      93752
ordered_ifs_with_hints/50/4         5551 ns       5551 ns     125160
ordered_ifs_with_hints/50/8        23191 ns      23190 ns      30028
ordered_ifs_with_hints/75/4         3165 ns       3165 ns     218492
ordered_ifs_with_hints/75/8        13785 ns      13785 ns      50574
ordered_ifs_with_hints/100/4        1575 ns       1575 ns     437687
ordered_ifs_with_hints/100/8        3130 ns       3130 ns     221205
reversed_ifs_with_hints/50/4        6573 ns       6572 ns     105629
reversed_ifs_with_hints/50/8       27351 ns      27351 ns      25568
reversed_ifs_with_hints/75/4        3537 ns       3537 ns     197470
reversed_ifs_with_hints/75/8       16130 ns      16130 ns      43279
reversed_ifs_with_hints/100/4       3737 ns       3737 ns     187583
reversed_ifs_with_hints/100/8       7446 ns       7446 ns      93782

Analisi

1. L'ordinamento conta

Per le iterazioni in 4K e (quasi) il 100% di probabilità di dichiarazioni molto apprezzate la differenza è enorme del 223%:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/100/4                   1673 ns       1673 ns     417073
reversed_ifs/100/4                  3738 ns       3738 ns     188087

Per le iterazioni in 4K e il 50% di probabilità di dichiarazioni molto apprezzate la differenza è di circa il 14%:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
reversed_ifs/50/4                   5342 ns       5341 ns     126800

2. Il numero di iterazioni conta

La differenza tra iterazioni 4K e 8K per una probabilità (quasi) del 100% di dichiarazioni molto apprezzate è circa due volte (come previsto):

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs/100/8                   3381 ns       3381 ns     207612

Ma la differenza tra iterazioni 4K e 8K per il 50% di probabilità di affermazioni molto apprezzate è 5,5 volte:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/50/8                   25636 ns      25635 ns      27852

Perché è così? A causa di mancati predittori di succursale. Ecco le filiali mancanti per ogni caso menzionato sopra:

ordered_ifs/100/4    0.01% of branch-misses
ordered_ifs/100/8    0.01% of branch-misses
ordered_ifs/50/4     3.18% of branch-misses
ordered_ifs/50/8     15.22% of branch-misses

Quindi sul mio i5 il predittore di filiali fallisce in modo spettacolare per filiali non molto probabili e grandi serie di dati.

3. Suggerimenti Aiutare un po '

Per le iterazioni in 4K i risultati sono leggermente peggiori per una probabilità del 50% e leggermente migliori per una probabilità prossima al 100%:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/4                    4660 ns       4658 ns     150948
ordered_ifs/100/4                   1673 ns       1673 ns     417073
ordered_ifs_with_hints/50/4         5551 ns       5551 ns     125160
ordered_ifs_with_hints/100/4        1575 ns       1575 ns     437687

Ma per le iterazioni 8K i risultati sono sempre un po 'migliori:

---------------------------------------------------------------------
Benchmark                              Time           CPU Iterations
---------------------------------------------------------------------
ordered_ifs/50/8                   25636 ns      25635 ns      27852
ordered_ifs/100/8                   3381 ns       3381 ns     207612
ordered_ifs_with_hints/50/8        23191 ns      23190 ns      30028
ordered_ifs_with_hints/100/8        3130 ns       3130 ns     221205

Quindi, anche i suggerimenti aiutano, ma solo un pochino.

La conclusione generale è: confrontare sempre il codice, perché i risultati potrebbero sorprendere.

Spero che aiuti.


1
i5 Nehalem? i5 Skylake? Il solo dire "i5" non è molto specifico. Inoltre, suppongo che tu abbia usato g++ -O2o -O3 -fno-tree-vectorize, ma dovresti dirlo.
Peter Cordes,

È interessante notare che with_hints è ancora diverso per ordine e inversione. Sarebbe bello se ti collegassi alla fonte da qualche parte. (ad esempio un collegamento Godbolt, preferibilmente un collegamento completo in modo che l'accorciamento del collegamento non possa marcire.)
Peter Cordes,

1
Il fatto che il predittore di filiali sia in grado di prevedere bene anche alla dimensione dei dati di input 4K, ovvero è in grado di "spezzare" il benchmark ricordando i risultati delle filiali in un ciclo con un periodo in migliaia è una testimonianza della potenza del moderno predittori di filiali. Tieni presente che i predittori sono abbastanza sensibili in alcuni casi a cose come l'allineamento, quindi è difficile trarre conclusioni forti su alcuni cambiamenti. Ad esempio, hai notato comportamenti opposti per il suggerimento in diversi casi, ma potrebbe essere spiegato dal suggerimento che cambia casualmente il layout del codice che ha influenzato il predittore.
BeeOnRope,

1
@PeterCordes il mio punto principale è mentre possiamo provare a prevedere i risultati di un cambiamento, ancora meglio misurare le prestazioni prima e dopo il cambiamento ... E hai ragione, avrei dovuto dire che è stato ottimizzato con -O3 e il processore è i5-4460 a 3,20 GHz
Andriy Berestovskyy il

19

Sulla base di alcune delle altre risposte qui, sembra che l'unica vera risposta sia: dipende . Dipende almeno da quanto segue (sebbene non necessariamente in questo ordine di importanza):

  • Probabilità relativa di ciascun ramo. Questa è la domanda originale che è stata posta. Sulla base delle risposte esistenti, sembrano esserci alcune condizioni in base alle quali l'ordinamento per probabilità aiuta, ma sembra non essere sempre così. Se le probabilità relative non sono molto diverse, è improbabile che faccia la differenza nell'ordine in cui si trovano. Tuttavia, se la prima condizione si verifica il 99,999% delle volte e la successiva è una frazione di ciò che rimane, allora vorrei supponiamo che mettere il primo più probabile sarebbe utile in termini di tempistica.
  • Costo del calcolo della condizione vero / falso per ciascun ramo. Se il costo del tempo per testare le condizioni è davvero elevato per una filiale rispetto a un'altra, è probabile che ciò abbia un impatto significativo sui tempi e sull'efficienza. Ad esempio, considera una condizione che richiede 1 unità di tempo per calcolare (ad esempio, verifica dello stato di una variabile booleana) rispetto a un'altra condizione che richiede decine, centinaia, migliaia o persino milioni di unità di tempo per calcolare (ad esempio, verifica del contenuto di un file su disco o eseguendo una query SQL complessa su un database di grandi dimensioni). Supponendo che il codice controlli le condizioni in ordine ogni volta, le condizioni più veloci dovrebbero probabilmente essere le prime (a meno che non dipendano da altre condizioni che non riescono prima).
  • Compilatore / interprete Alcuni compilatori (o interpreti) possono includere ottimizzazioni di un tipo di un altro che possono influire sulle prestazioni (e alcune di queste sono presenti solo se alcune opzioni sono selezionate durante la compilazione e / o l'esecuzione). Quindi, a meno che non si stiano confrontando due compilazioni ed esecuzioni di codice altrimenti identico sullo stesso sistema utilizzando lo stesso compilatore esatto in cui l'unica differenza è l'ordine dei rami in questione, si dovrà dare un margine di manovra per le variazioni del compilatore.
  • Sistema operativo / Hardware Come menzionato da luk32 e Yakk, varie CPU hanno le proprie ottimizzazioni (così come i sistemi operativi). Quindi i benchmark sono di nuovo sensibili alle variazioni qui.
  • Frequenza di esecuzione del blocco di codice Se si accede raramente al blocco che include i rami (ad esempio, solo una volta durante l'avvio), probabilmente importa molto poco l'ordine in cui si inseriscono i rami. D'altra parte, se il tuo codice sta martellando in questo blocco di codice durante una parte critica del tuo codice, l'ordinamento potrebbe essere molto importante (a seconda dei benchmark).

L'unico modo per sapere con certezza è confrontare il tuo caso specifico, preferibilmente su un sistema identico (o molto simile al) al sistema previsto sul quale verrà finalmente eseguito il codice. Se è destinato a funzionare su un set di sistemi diversi con hardware, sistema operativo diversi, ecc., È una buona idea fare un benchmark tra più varianti per vedere qual è il migliore. Potrebbe anche essere una buona idea avere il codice da compilare con un ordine su un tipo di sistema e un altro ordine su un altro tipo di sistema.

La mia regola empirica personale (per la maggior parte dei casi, in assenza di un benchmark) è ordinare in base a:

  1. Condizioni che si basano sul risultato di condizioni precedenti,
  2. Costo di calcolo della condizione, quindi
  3. Probabilità relativa di ciascun ramo.

13

Il modo in cui lo vedo normalmente risolto per codice ad alte prestazioni è mantenere l'ordine più leggibile, ma fornendo suggerimenti al compilatore. Ecco un esempio dal kernel Linux :

if (likely(access_ok(VERIFY_READ, from, n))) {
    kasan_check_write(to, n);
    res = raw_copy_from_user(to, from, n);
}
if (unlikely(res))
    memset(to + (n - res), 0, res);

Qui il presupposto è che il controllo dell'accesso passerà e che non venga restituito alcun errore res. Cercare di riordinare una di queste due clausole se confondesse semplicemente il codice, ma il simbolo likely()eunlikely() macro aiutano effettivamente la leggibilità sottolineando qual è il caso normale e qual è l'eccezione.

L'implementazione Linux di tali macro utilizza funzionalità specifiche di GCC . Sembra che clang e il compilatore Intel C supportino la stessa sintassi, ma MSVC non ha tale funzionalità .


4
Ciò sarebbe più utile se potessi spiegare come sono definite le macro likely()e unlikely()e includere alcune informazioni sulla corrispondente funzione del compilatore.
Nate Eldredge,

1
AFAIK, questi suggerimenti "solo" cambiano il layout di memoria dei blocchi di codice e se un sì o no porterà a un salto. Ciò può comportare vantaggi in termini di prestazioni, ad esempio la necessità (o la mancanza di ciò) di leggere pagine di memoria. Ma ciò non riorganizza l'ordine in cui vengono valutate le condizioni all'interno di un lungo elenco di altri if-if
Hagen von Eitzen,

@HagenvonEitzen Hmm sì, questo è un buon punto, non può influenzare l'ordine else ifse il compilatore non è abbastanza intelligente da sapere che le condizioni si escludono a vicenda.
jpa,

7

Dipende anche dal tuo compilatore e dalla piattaforma per cui stai compilando.

In teoria, la condizione più probabile dovrebbe far saltare il controllo il meno possibile.

In genere la condizione più probabile dovrebbe essere la prima:

if (most_likely) {
     // most likely instructions
} else 

Gli asm più popolari si basano su rami condizionali che saltano quando la condizione è vera . Quel codice C sarà probabilmente tradotto in tale pseudo asm:

jump to ELSE if not(most_likely)
// most likely instructions
jump to end
ELSE:

Questo perché i salti fanno sì che la CPU annulli la pipeline di esecuzione e si blocchi perché il contatore del programma è cambiato (per architetture che supportano pipeline che sono davvero comuni). Quindi si tratta del compilatore, che può o meno applicare alcune sofisticate ottimizzazioni sull'avere statisticamente la condizione più probabile per ottenere il controllo che faccia meno salti.


2
Hai affermato che il ramo condizionale si verifica quando la condizione è vera, ma l'esempio "pseudo asm" mostra il contrario. Inoltre, non si può dire che i salti condizionati (e tanto meno tutti i salti) bloccano la pipeline perché le CPU moderne in genere hanno una previsione di diramazione. In effetti, se si prevede che il ramo venga preso ma non preso, la tubazione verrà bloccata. Proverei comunque a ordinare le condizioni in ordine decrescente di probabilità, ma ciò che il compilatore e la CPU ne fanno dipendono fortemente dall'implementazione.
Arne Vogel,

1
Metto "not (most_likely)", quindi se most_likely è vero il controllo continuerà senza saltare.
NoImagination Acquista il

1
"Gli asm più popolari si basano su rami condizionali che saltano quando la condizione è vera" ... quali sarebbero gli ISA? Non è certamente vero per x86 né per ARM. Inferno per CPU ARM di base (e antichissime x86, anche per bps complessi di solito iniziano ancora con quell'assunto e poi si adattano) il predittore di ramo presuppone che un ramo in avanti non sia preso e i rami all'indietro lo sono sempre, quindi l'opposto della rivendicazione è vero.
Voo

1
I compilatori che ho provato per lo più hanno usato l'approccio sopra citato per un semplice test. Si noti che in clangrealtà ha adottato un approccio diverso per test2e test3: a causa dell'euristica che indica che un < 0o == 0test è probabilmente falso, ha deciso di clonare il resto della funzione su entrambi i percorsi, quindi è in grado di effettuare condition == falsela caduta attraverso il percorso. Questo è possibile solo perché il resto della funzione è breve: in test4ho aggiunto un'altra operazione ed è tornato all'approccio che ho descritto sopra.
BeeOnRope,

1
@ArneVogel: le filiali prese correttamente previste non bloccano completamente la pipeline su CPU moderne ma sono spesso significativamente peggiori di quelle non prese: (1) significano che il flusso di controllo non è contiguo, quindi il resto delle istruzioni dopo non lo jmpsono utile quindi sprecare / decodificare la larghezza di banda è sprecata (2) anche con la previsione i core più moderni eseguono solo un recupero per ciclo, quindi pone un limite rigido di 1 ramo / ciclo preso (OTOH Intel moderna può fare 2 non presi / ciclo) (3 ) è più difficile per la previsione dei rami gestire i rami consecutivi presi e nel caso di predittori veloci + lenti ...
BeeOnRope

6

Ho deciso di rieseguire il test sulla mia macchina usando il codice Lik32. Ho dovuto cambiarlo a causa del mio Windows o compilatore pensando che l'alta risoluzione è di 1ms, usando

mingw32-g ++. exe -O3 -Wall -std = c ++ 11 -fexceptions -g

vector<int> rand_vec(10000000);

GCC ha effettuato la stessa trasformazione su entrambi i codici originali.

Nota che solo le due prime condizioni sono testate poiché la terza deve essere sempre vera, GCC è una specie di Sherlock qui.

Inversione

.L233:
        mov     DWORD PTR [rsp+104], 0
        mov     DWORD PTR [rsp+100], 0
        mov     DWORD PTR [rsp+96], 0
        call    std::chrono::_V2::system_clock::now()
        mov     rbp, rax
        mov     rax, QWORD PTR [rsp+8]
        jmp     .L219
.L293:
        mov     edx, DWORD PTR [rsp+104]
        add     edx, 1
        mov     DWORD PTR [rsp+104], edx
.L217:
        add     rax, 4
        cmp     r14, rax
        je      .L292
.L219:
        mov     edx, DWORD PTR [rax]
        cmp     edx, 94
        jg      .L293 // >= 95
        cmp     edx, 19
        jg      .L218 // >= 20
        mov     edx, DWORD PTR [rsp+96]
        add     rax, 4
        add     edx, 1 // < 20 Sherlock
        mov     DWORD PTR [rsp+96], edx
        cmp     r14, rax
        jne     .L219
.L292:
        call    std::chrono::_V2::system_clock::now()

.L218: // further down
        mov     edx, DWORD PTR [rsp+100]
        add     edx, 1
        mov     DWORD PTR [rsp+100], edx
        jmp     .L217

And sorted

        mov     DWORD PTR [rsp+104], 0
        mov     DWORD PTR [rsp+100], 0
        mov     DWORD PTR [rsp+96], 0
        call    std::chrono::_V2::system_clock::now()
        mov     rbp, rax
        mov     rax, QWORD PTR [rsp+8]
        jmp     .L226
.L296:
        mov     edx, DWORD PTR [rsp+100]
        add     edx, 1
        mov     DWORD PTR [rsp+100], edx
.L224:
        add     rax, 4
        cmp     r14, rax
        je      .L295
.L226:
        mov     edx, DWORD PTR [rax]
        lea     ecx, [rdx-20]
        cmp     ecx, 74
        jbe     .L296
        cmp     edx, 19
        jle     .L297
        mov     edx, DWORD PTR [rsp+104]
        add     rax, 4
        add     edx, 1
        mov     DWORD PTR [rsp+104], edx
        cmp     r14, rax
        jne     .L226
.L295:
        call    std::chrono::_V2::system_clock::now()

.L297: // further down
        mov     edx, DWORD PTR [rsp+96]
        add     edx, 1
        mov     DWORD PTR [rsp+96], edx
        jmp     .L224

Quindi questo non ci dice molto se non che l'ultimo caso non ha bisogno di una previsione del ramo.

Ora ho provato tutte e 6 le combinazioni degli if, i primi 2 sono il contrario originale e ordinati. alto è> = 95, basso è <20, la metà è 20-94 con 10000000 iterazioni ciascuno.

high, low, mid: 43000000ns
mid, low, high: 46000000ns
high, mid, low: 45000000ns
low, mid, high: 44000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 44000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 45000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 42000000ns
mid, low, high: 46000000ns
high, mid, low: 46000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 43000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 44000000ns
low, mid, high: 44000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 48000000ns
high, mid, low: 44000000ns
low, mid, high: 44000000ns
mid, high, low: 45000000ns
low, high, mid: 45000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 47000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 46000000ns
low, high, mid: 44000000ns

high, low, mid: 43000000ns
mid, low, high: 46000000ns
high, mid, low: 45000000ns
low, mid, high: 45000000ns
mid, high, low: 45000000ns
low, high, mid: 44000000ns

high, low, mid: 42000000ns
mid, low, high: 46000000ns
high, mid, low: 44000000ns
low, mid, high: 45000000ns
mid, high, low: 45000000ns
low, high, mid: 44000000ns

1900020, 7498968, 601012

Process returned 0 (0x0)   execution time : 2.899 s
Press any key to continue.

Quindi perché l'ordine è alto, basso, medio quindi più veloce (marginalmente)

Perché il più imprevedibile è l'ultimo e quindi non viene mai eseguito attraverso un predittore di diramazione.

          if (i >= 95) ++nHigh;               // most predictable with 94% taken
          else if (i < 20) ++nLow; // (94-19)/94% taken ~80% taken
          else if (i >= 20 && i < 95) ++nMid; // never taken as this is the remainder of the outfalls.

Quindi i rami saranno previsti presi, presi e resto con

Errati del 6% + (0,94 *) 20%.

"Smistato"

          if (i >= 20 && i < 95) ++nMid;  // 75% not taken
          else if (i < 20) ++nLow;        // 19/25 76% not taken
          else if (i >= 95) ++nHigh;      //Least likely branch

I rami saranno previsti con non preso, non preso e Sherlock.

25% + (0,75 *) errori di previsione del 24%

Dare una differenza del 18-23% (differenza misurata di ~ 9%) ma dobbiamo calcolare i cicli invece di prevedere in modo errato%.

Supponiamo che la mia CPU Nehalem preveda una penalità di 17 cicli e che ogni controllo richieda 1 ciclo per emettere (4-5 istruzioni) e che il ciclo richieda anche un ciclo. Le dipendenze dei dati sono i contatori e le variabili del ciclo, ma una volta che i fraintendimenti sono fuori mano, non dovrebbe influenzare i tempi.

Quindi per "reverse" otteniamo i tempi (questa dovrebbe essere la formula usata in Computer Architecture: A Quantitative Approach IIRC).

mispredict*penalty+count+loop
0.06*17+1+1+    (=3.02)
(propability)*(first check+mispredict*penalty+count+loop)
(0.19)*(1+0.20*17+1+1)+  (= 0.19*6.4=1.22)
(propability)*(first check+second check+count+loop)
(0.75)*(1+1+1+1) (=3)
= 7.24 cycles per iteration

e lo stesso per "ordinati"

0.25*17+1+1+ (=6.25)
(1-0.75)*(1+0.24*17+1+1)+ (=.25*7.08=1.77)
(1-0.75-0.19)*(1+1+1+1)  (= 0.06*4=0.24)
= 8.26

(8,26-7,24) / 8,26 = 13,8% vs. ~ 9% misurato (vicino al misurato!?!).

Quindi l'ovvio dell'OP non è ovvio.

Con questi test, altri test con codice più complicato o più dipendenze di dati saranno sicuramente diversi, quindi misura il tuo caso.

La modifica dell'ordine del test ha modificato i risultati, ma ciò potrebbe essere dovuto a diversi allineamenti dell'inizio del loop, che idealmente dovrebbero essere allineati a 16 byte su tutte le nuove CPU Intel, ma non è in questo caso.


4

Mettili nell'ordine logico che preferisci. Certo, il ramo potrebbe essere più lento, ma la ramificazione non dovrebbe essere la maggior parte del lavoro svolto dal computer.

Se stai lavorando su una porzione di codice critica per le prestazioni, allora certamente usa l'ordine logico, l'ottimizzazione guidata dal profilo e altre tecniche, ma per il codice generale, penso che sia davvero una scelta stilistica.


6
Gli errori di previsione delle filiali sono costosi. In microbenchmarks, sono sotto costato , perché x86s hanno un grande tavolo di predizione delle diramazioni. Un loop stretto nelle stesse condizioni fa sì che la CPU sappia meglio di te quale è più probabile. Ma se hai rami in tutto il tuo codice, puoi fare in modo che la cache di previsione dei rami si esaurisca negli slot e la cpu assume qualunque cosa sia predefinita. Sapere quale sia questa ipotesi predefinita può salvare cicli in tutta la tua base di codice.
Yakk - Adam Nevraumont,

La risposta di @Yakk Jack è l'unica corretta qui. Non apportare ottimizzazioni che riducano la leggibilità se il compilatore è in grado di eseguire tale ottimizzazione. Non faresti la piegatura costante, l'eliminazione del codice morto, lo srotolamento del ciclo o qualsiasi altra ottimizzazione se il tuo compilatore lo fa per te, vero? Scrivi il tuo codice, usa l'ottimizzazione guidata dal profilo (che è design per risolvere questo problema perché i programmatori fanno schifo a indovinare) e poi vedi se il tuo compilatore lo ottimizza o meno. Alla fine non si desidera avere nessuna branchess nel codice critico per le prestazioni.
Christoph Diegelmann,

@Christoph Non includerei il codice che sapevo essere morto. Non userei i++quando ++ilo farei, perché sono consapevole che i++per alcuni iteratori è difficile ottimizzare fino in fondo ++ie la differenza (per me) non ha importanza. Si tratta di evitare la pessimizzazione; mettere il blocco più probabile al primo posto come abitudine predefinita non causerà una notevole riduzione della leggibilità (e potrebbe effettivamente aiutare!), mentre si traduce in un codice che è favorevole alla previsione dei rami (e quindi offre un piccolo aumento uniforme delle prestazioni che non può essere recuperato dalla successiva micro ottimizzazione)
Yakk - Adam Nevraumont,

3

Se conosci già la probabilità relativa dell'istruzione if-else, a fini prestazionali sarebbe meglio usare il modo ordinato, poiché controllerà solo una condizione (quella vera).

In modo non ordinato il compilatore verificherà inutilmente tutte le condizioni e richiederà tempo.

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.