Un salto costoso con GCC 5.4.0


171

Avevo una funzione che assomigliava a questa (mostrando solo la parte importante):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

Scritta in questo modo, la funzione ha richiesto ~ 34ms sulla mia macchina. Dopo aver modificato la condizione per aumentare la moltiplicazione (rendendo il codice simile al seguente):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

il tempo di esecuzione è diminuito a ~ 19ms.

Il compilatore usato era GCC 5.4.0 con -O3 e dopo aver verificato il codice asm generato usando godbolt.org ho scoperto che il primo esempio genera un salto, mentre il secondo no. Ho deciso di provare GCC 6.2.0 che genera anche un'istruzione jump quando si usa il primo esempio, ma GCC 7 sembra non generarne più.

Scoprire questo modo per accelerare il codice è stato piuttosto raccapricciante e ha impiegato parecchio tempo. Perché il compilatore si comporta in questo modo? È previsto ed è qualcosa che i programmatori dovrebbero cercare? Ci sono altre cose simili a questa?

EDIT: collegamento a godbolt https://godbolt.org/g/5lKPF3


17
Perché il compilatore si comporta in questo modo? Il compilatore può fare ciò che vuole, purché il codice generato sia corretto. Alcuni compilatori sono semplicemente migliori nelle ottimizzazioni rispetto ad altri.
Jabberwocky,

26
La mia ipotesi è che la valutazione del cortocircuito ne sia la &&causa.
Jens,

9
Si noti che questo è il motivo per cui abbiamo anche &.
rubenvb,

7
L'ordinamento di @Jakub molto probabilmente aumenterà la velocità di esecuzione, vedi questa domanda .
rubenvb,

8
@rubenvb "non deve essere valutato" in realtà non significa nulla per un'espressione che non ha effetti collaterali. Sospetto che il vettore esegua il controllo dei limiti e che GCC non possa provare che non sarà fuori dai limiti. EDIT: In realtà, non penso che tu stia facendo nulla per impedire a i + shift di essere fuori dai limiti.
Casuale 832,

Risposte:


263

L'operatore logico AND ( &&) utilizza la valutazione del cortocircuito, il che significa che il secondo test viene eseguito solo se il primo confronto viene considerato vero. Questa è spesso esattamente la semantica richiesta. Ad esempio, considera il seguente codice:

if ((p != nullptr) && (p->first > 0))

È necessario assicurarsi che il puntatore sia diverso da null prima di dereferenziarlo. Se questa non fosse una valutazione di cortocircuito, avresti un comportamento indefinito perché dovresti dereferenziare un puntatore nullo.

È anche possibile che la valutazione del corto circuito produca un aumento delle prestazioni nei casi in cui la valutazione delle condizioni è un processo costoso. Per esempio:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

Se DoLengthyCheck1fallisce, non ha senso chiamare DoLengthyCheck2.

Tuttavia, nel binario risultante, un'operazione di cortocircuito si traduce spesso in due rami, poiché questo è il modo più semplice per il compilatore di conservare queste semantiche. (Ecco perché, dall'altro lato della medaglia, la valutazione del corto circuito a volte può inibire il potenziale di ottimizzazione.) Puoi vederlo guardando la porzione pertinente di codice oggetto generato per la tua ifdichiarazione da GCC 5.4:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

Qui puoi vedere i due confronti ( cmpistruzioni) qui, ciascuno seguito da un salto / ramo condizionato separato ( jao salta se sopra).

È una regola empirica generale che i rami siano lenti e che pertanto debbano essere evitati in anelli stretti. Questo è vero su praticamente tutti i processori x86, dall'umile 8088 (i cui tempi di recupero lenti e la coda di prefetch estremamente ridotta [paragonabile a una cache di istruzioni], unita alla totale mancanza di previsione dei rami, significavano che i rami presi richiedevano il dump della cache ) alle moderne implementazioni (le cui lunghe condotte rendono le filiali errate altrettanto costose). Nota il piccolo avvertimento in cui sono scivolato lì. I processori moderni dal Pentium Pro dispongono di avanzati motori di previsione delle filiali progettati per ridurre al minimo il costo delle filiali. Se è possibile prevedere correttamente la direzione della filiale, il costo è minimo. Il più delle volte funziona bene, ma se ti imbatti in casi patologici in cui il predittore di ramo non è dalla tua parte,il tuo codice può diventare estremamente lento . Questo è presumibilmente dove sei, dal momento che dici che l'array non è ordinato.

Dici che i benchmark hanno confermato che la sostituzione di &&con a *rende il codice notevolmente più veloce. Il motivo è evidente quando si confronta la parte pertinente del codice oggetto:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

È un po 'controintuitivo che questo potrebbe essere più veloce, poiché ci sono più istruzioni qui, ma è così che a volte funziona l'ottimizzazione. Si vedono gli stessi confronti ( cmp) fatti qui, ma ora ciascuno è preceduto da un xore seguito da un setbe. XOR è solo un trucco standard per cancellare un registro. Il setbeè un'istruzione x86 che imposta un bit in base al valore di un flag, e viene spesso utilizzato per implementare il codice di rami. Qui setbeè l'inverso di ja. Imposta il registro di destinazione su 1 se il confronto era inferiore o uguale (poiché il registro era pre-azzerato, altrimenti sarebbe 0), mentre jaramificato se il confronto era superiore. Una volta che questi due valori sono stati ottenuti in r15ber14bregistri, si moltiplicano insieme usando imul. La moltiplicazione era tradizionalmente un'operazione relativamente lenta, ma è dannatamente veloce sui processori moderni e questo sarà particolarmente veloce, perché moltiplica solo due valori di dimensioni di byte.

Avresti potuto facilmente sostituire la moltiplicazione con l'operatore AND bit per bit ( &), che non esegue la valutazione del corto circuito. Ciò rende il codice molto più chiaro ed è un modello generalmente riconosciuto dai compilatori. Ma quando lo fai con il tuo codice e lo compili con GCC 5.4, continua ad emettere il primo ramo:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

Non vi è alcun motivo tecnico per cui è stato necessario emettere il codice in questo modo, ma per qualche motivo, la sua euristica interna gli sta dicendo che questo è più veloce. Probabilmente sarebbe più veloce se il predittore del ramo fosse dalla tua parte, ma probabilmente sarà più lento se la previsione del ramo fallisce più spesso di quanto non riesca.

Le nuove generazioni del compilatore (e altri compilatori, come Clang) conoscono questa regola e talvolta la useranno per generare lo stesso codice che avresti cercato ottimizzando a mano. Vedo regolarmente Clang tradurre le &&espressioni nello stesso codice che sarebbe stato emesso se l'avessi usato &. Quello che segue è l'output rilevante di GCC 6.2 con il tuo codice usando l' &&operatore normale :

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

Si noti come intelligente questo è! Sta usando le condizioni firmate ( jge setle) anziché le condizioni non firmate ( jae setbe), ma questo non è importante. Puoi vedere che fa ancora il confronto e la diramazione per la prima condizione come la versione precedente e usa le stesse setCCistruzioni per generare codice branchless per la seconda condizione, ma è diventato molto più efficiente nel modo in cui fa l'incremento . Invece di fare un secondo confronto ridondante per impostare i flag per sbbun'operazione, utilizza la conoscenza che r14dsarà 1 o 0 per aggiungere semplicemente incondizionatamente questo valore nontopOverlap. Se r14dè 0, l'aggiunta è no-op; in caso contrario, aggiunge 1, esattamente come dovrebbe fare.

GCC 6.2 produce effettivamente un codice più efficiente quando si utilizza l' &&operatore di cortocircuito rispetto &all'operatore bit a bit :

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

Il ramo e l'insieme condizionale sono ancora lì, ma ora ritorna al modo meno intelligente di incrementare nontopOverlap. Questa è una lezione importante sul perché dovresti stare attento quando cerchi di ingannare il tuo compilatore!

Ma se con i benchmark puoi provare che il codice di branching è in realtà più lento, allora può pagare per provare a risolvere il tuo compilatore. Devi solo farlo con un'attenta ispezione dello smontaggio e prepararti a rivalutare le tue decisioni quando esegui l'upgrade a una versione successiva del compilatore. Ad esempio, il codice che hai potrebbe essere riscritto come:

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

Non c'è alcuna ifdichiarazione qui, e la stragrande maggioranza dei compilatori non penserà mai di emettere codice di ramificazione per questo. GCC non fa eccezione; tutte le versioni generano qualcosa di simile al seguente:

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

Se hai seguito gli esempi precedenti, questo dovrebbe sembrare molto familiare. Entrambi i confronti sono fatti in modo branchless, i risultati intermedi andEd insieme, e quindi questo risultato (che sarà 0 o 1) è addED a nontopOverlap. Se vuoi il codice senza rami, questo ti garantirà virtualmente di averlo.

GCC 7 è diventato ancora più intelligente. Ora genera un codice praticamente identico (tranne un leggero riarrangiamento delle istruzioni) per il trucco di cui sopra come il codice originale. Quindi, la risposta alla tua domanda, "Perché il compilatore si comporta in questo modo?" , probabilmente perché non sono perfetti! Tentano di utilizzare l'euristica per generare il codice più ottimale possibile, ma non sempre prendono le decisioni migliori. Ma almeno possono diventare più intelligenti nel tempo!

Un modo di guardare a questa situazione è che il codice di ramificazione ha le migliori prestazioni nel migliore dei casi . Se la previsione del ramo ha esito positivo, saltare le operazioni non necessarie comporterà un tempo di esecuzione leggermente più veloce. Tuttavia, il codice branchless ha le migliori prestazioni nel caso peggiore . Se la previsione del ramo fallisce, l'esecuzione di alcune istruzioni aggiuntive necessarie per evitare un ramo sarà sicuramente più veloce di un ramo non previsto. Anche il compilatore più intelligente e intelligente avrà difficoltà a fare questa scelta.

E per la tua domanda se questo è qualcosa che i programmatori devono fare attenzione, la risposta è quasi certamente no, tranne in alcuni hot loop che stai cercando di accelerare tramite le micro-ottimizzazioni. Quindi, ti siedi con lo smontaggio e trovi il modo di modificarlo. E, come ho detto prima, sii pronto a rivisitare quelle decisioni quando esegui l'aggiornamento a una versione più recente del compilatore, perché potrebbe fare qualcosa di stupido con il tuo codice complicato o potrebbe aver cambiato la sua euristica di ottimizzazione abbastanza da poter tornare indietro all'utilizzo del codice originale. Commenta a fondo!


3
Bene, non esiste un "migliore" universale. Tutto dipende dalla tua situazione, motivo per cui devi assolutamente fare un benchmark quando stai facendo questo tipo di ottimizzazione delle prestazioni di basso livello. Come ho spiegato nella risposta, se siete alla dimensione perdere di branch prediction, rami pronosticate male, stanno andando a rallentare il vostro codice giù un sacco . L'ultimo bit di codice non utilizza alcun ramo (notare l'assenza di j*istruzioni), quindi sarà più veloce in quel caso. [continua]
Cody Grey


2
@ 8bit Bob ha ragione. Mi riferivo alla coda di prefetch. Probabilmente non avrei dovuto chiamarlo cache, ma non ero terribilmente preoccupato per il fraseggio e non ho trascorso molto tempo a cercare di ricordare i dettagli, dal momento che non immaginavo che a nessuno importasse molto tranne la curiosità storica. Se vuoi dettagli, lo Zen of Assembly Language di Michael Abrash ha un valore inestimabile. L'intero libro è disponibile in vari posti online; ecco la parte applicabile sulla ramificazione , ma dovresti leggere e comprendere anche le parti sul prefetching.
Cody Grey

6
@Hurkyl Sento che l'intera risposta parla a quella domanda. Hai ragione sul fatto che non l'ho davvero chiamato esplicitamente, ma sembrava che fosse già abbastanza lungo. :-) Chiunque abbia il tempo di leggere l'intera cosa dovrebbe acquisire una comprensione sufficiente di questo punto. Ma se ritieni che manchi qualcosa o desideri ulteriori chiarimenti, ti preghiamo di non timidezza nel modificare la risposta per includerla. Ad alcune persone non piace, ma non mi dispiace assolutamente. Ho aggiunto un breve commento al riguardo, insieme a una modifica della mia formulazione come suggerito da 8bittree.
Cody Grey

2
Hah, grazie per il complemento, @green. Non ho nulla di specifico da suggerire. Come in ogni cosa, diventi un esperto facendo, vedendo e sperimentando. Ho letto tutto ciò su cui posso mettere le mani quando si tratta dell'architettura x86, dell'ottimizzazione, degli interni del compilatore e di altre cose di basso livello, e so ancora solo una piccola parte di tutto ciò che c'è da sapere. Il modo migliore per imparare è sporcarsi le mani scavando. Ma prima ancora di poter sperare di iniziare, avrai bisogno di una solida conoscenza di C (o C ++), puntatori, linguaggio assembly e tutti gli altri fondamentali di basso livello.
Cody Grey

23

Una cosa importante da notare è che

(curr[i] < 479) && (l[i + shift] < 479)

e

(curr[i] < 479) * (l[i + shift] < 479)

non sono semanticamente equivalenti! In particolare, se hai mai la situazione in cui:

  • 0 <= ie i < curr.size()sono entrambi veri
  • curr[i] < 479 è falso
  • i + shift < 0o i + shift >= l.size()è vero

allora l'espressione (curr[i] < 479) && (l[i + shift] < 479)è garantita per essere un valore booleano ben definito. Ad esempio, non provoca un errore di segmentazione.

Tuttavia, in queste circostanze, l'espressione (curr[i] < 479) * (l[i + shift] < 479)è un comportamento indefinito ; si è permesso di causare un errore di segmentazione.

Ciò significa che, ad esempio, per lo snippet di codice originale, il compilatore non può semplicemente scrivere un ciclo che esegue entrambi i confronti e fa andun'operazione, a meno che il compilatore non possa anche dimostrare che l[i + shift]non causerà mai un segfault in una situazione in cui non è necessario.

In breve, il codice originale offre meno opportunità di ottimizzazione rispetto a quest'ultimo. (ovviamente, se il compilatore riconosce o meno l'opportunità è una domanda completamente diversa)

È possibile correggere la versione originale invece facendo

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

Questo! A seconda del valore di shift(e max) c'è UB qui ...
Matthieu M.

18

L' &&operatore implementa la valutazione del corto circuito. Ciò significa che il secondo operando viene valutato solo se il primo valuta true. Ciò si traduce sicuramente in un salto in quel caso.

È possibile creare un piccolo esempio per mostrare questo:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

L'output dell'assemblatore è disponibile qui .

Puoi vedere prima il codice generato che chiama f(x), quindi controlla l'output e passa alla valutazione di g(x)quando era true. Altrimenti lascia la funzione.

L'uso della moltiplicazione "booleana" invece forza la valutazione di entrambi gli operandi ogni volta e quindi non ha bisogno di un salto.

A seconda dei dati, il salto può causare un rallentamento perché disturba la pipeline della CPU e altre cose come l'esecuzione speculativa. Normalmente la previsione delle filiali aiuta, ma se i tuoi dati sono casuali non c'è molto che possa essere previsto.


1
Perché affermi che la moltiplicazione forza ogni volta la valutazione di entrambi gli operandi? 0 * x = x * 0 = 0 indipendentemente dal valore di x. Come ottimizzazione, il compilatore può "cortocircuitare" anche la moltiplicazione. Vedere stackoverflow.com/questions/8145894/… , ad esempio. Inoltre, a differenza &&dell'operatore, la moltiplicazione può essere valutata in modo pigro con il primo o con il secondo argomento, consentendo una maggiore libertà di ottimizzazione.
SomeWittyUsername

@Jens - "Normalmente la previsione del ramo aiuta, ma se i tuoi dati sono casuali non c'è molto che possa essere previsto." - fa la buona risposta.
SChepurin,

1
@SomeWittyUsername Ok, il compilatore è ovviamente libero di fare qualsiasi ottimizzazione che mantenga il comportamento osservabile. Questo può o meno trasformarlo e tralasciare i calcoli. se si calcola 0 * f()e fha un comportamento osservabile, il compilatore deve chiamarlo. La differenza è che la valutazione del corto circuito è obbligatoria &&ma consentita se può dimostrare che è equivalente per *.
Jens,

@SomeWittyUsername solo nei casi in cui è possibile prevedere il valore 0 da una variabile o costante. Immagino che questi casi siano davvero pochi. Certamente l'ottimizzazione non può essere effettuata nel caso dell'OP, poiché è coinvolto l'accesso all'array.
Diego Sevilla,

3
@Jens: la valutazione del corto circuito non è obbligatoria. Il codice deve solo comportarsi come se fosse in corto circuito; al compilatore è consentito utilizzare qualsiasi mezzo gli piaccia per ottenere il risultato.

-2

Ciò potrebbe essere dovuto al fatto che quando si utilizza l'operatore logico, &&il compilatore deve verificare due condizioni affinché l'istruzione if abbia esito positivo. Tuttavia, nel secondo caso, poiché stai implicitamente convertendo un valore int in un valore bool, il compilatore fa alcune ipotesi basate sui tipi e sui valori che vengono passati, insieme a (possibilmente) una singola condizione di salto. È anche possibile che il compilatore ottimizzi completamente i jmps con bit shift.


8
Il salto deriva dal fatto che la seconda condizione viene valutata se e solo se la prima è vera. Il codice non deve valutarlo diversamente, quindi il compilatore non può ottimizzarlo meglio ed essere comunque corretto (a meno che non possa dedurre che la prima istruzione sarà sempre vera).
rubenvb,
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.