Qual è il vantaggio di __builtin_expect di GCC nelle dichiarazioni if ​​else?


144

Mi sono imbattuto in un #definein cui usano __builtin_expect.

La documentazione dice:

Funzione integrata: long __builtin_expect (long exp, long c)

È possibile utilizzare __builtin_expectper fornire al compilatore informazioni sulla previsione del ramo. In generale, dovresti preferire utilizzare il feedback del profilo effettivo per questo ( -fprofile-arcs), poiché i programmatori sono notoriamente cattivi nel predire il rendimento effettivo dei loro programmi. Tuttavia, ci sono applicazioni in cui questi dati sono difficili da raccogliere.

Il valore restituito è il valore di exp, che dovrebbe essere un'espressione integrale. La semantica del built-in è che ci si aspetta che exp == c. Per esempio:

      if (__builtin_expect (x, 0))
        foo ();

indicherebbe che non prevediamo di chiamare foo, poiché prevediamo xdi essere pari a zero.

Quindi perché non usare direttamente:

if (x)
    foo ();

invece della complicata sintassi con __builtin_expect?



3
Penso che il tuo codice diretto avrebbe dovuto essere if ( x == 0) {} else foo();... o semplicemente if ( x != 0 ) foo();equivalente al codice della documentazione di GCC.
Nawaz,

Risposte:


187

Immagina il codice assembly che verrebbe generato da:

if (__builtin_expect(x, 0)) {
    foo();
    ...
} else {
    bar();
    ...
}

Immagino che dovrebbe essere qualcosa del tipo:

  cmp   $x, 0
  jne   _foo
_bar:
  call  bar
  ...
  jmp   after_if
_foo:
  call  foo
  ...
after_if:

Puoi vedere che le istruzioni sono disposte in un ordine tale che il barcaso precede il foocaso (al contrario del codice C). Questo può utilizzare meglio la pipeline della CPU, poiché un salto interrompe le istruzioni già recuperate.

Prima di eseguire il salto, le istruzioni sottostanti (il barcaso) vengono inviate alla pipeline. Dal momento che il foocaso è improbabile, è improbabile anche il salto, quindi è improbabile che si blocchi il gasdotto.


1
Funziona davvero così? Perché la definizione foo non può venire prima? L'ordine delle definizioni delle funzioni è irrilevante, per quanto riguarda un prototipo, giusto?
kingsmasher1,

63
Non si tratta di definizioni di funzioni. Si tratta di riorganizzare il codice macchina in un modo che causi una probabilità minore per la CPU di recuperare istruzioni che non verranno eseguite.
Blagovest Buyukliev,

4
Ohh ho capito. Quindi vuoi dire dal momento che c'è un'alta probabilità per x = 0cui la barra viene data per prima. E poi, è definito più tardi poiché le sue possibilità (piuttosto usa la probabilità) è inferiore, giusto?
kingsmasher1,

1
Ahhh..thanks. Questa è la migliore spiegazione. Il codice assembly ha davvero funzionato :)
kingsmasher1

5
Ciò può anche includere suggerimenti per il predittore di diramazioni della CPU , migliorando le
tubazioni

50

Decompiliamo per vedere cosa fa GCC 4.8

Blagovest ha menzionato l'inversione delle filiali per migliorare la pipeline, ma gli attuali compilatori lo fanno davvero? Scopriamolo!

Senza __builtin_expect

#include "stdio.h"
#include "time.h"

int main() {
    /* Use time to prevent it from being optimized away. */
    int i = !time(NULL);
    if (i)
        puts("a");
    return 0;
}

Compilare e decompilare con GCC 4.8.2 x86_64 Linux:

gcc -c -O3 -std=gnu11 main.c
objdump -dr main.o

Produzione:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       75 0a                   jne    1a <main+0x1a>
  10:       bf 00 00 00 00          mov    $0x0,%edi
                    11: R_X86_64_32 .rodata.str1.1
  15:       e8 00 00 00 00          callq  1a <main+0x1a>
                    16: R_X86_64_PC32       puts-0x4
  1a:       31 c0                   xor    %eax,%eax
  1c:       48 83 c4 08             add    $0x8,%rsp
  20:       c3                      retq

L'ordine delle istruzioni in memoria era invariato: prima il putse poi il retqritorno.

Con __builtin_expect

Ora sostituisci if (i)con:

if (__builtin_expect(i, 0))

e otteniamo:

0000000000000000 <main>:
   0:       48 83 ec 08             sub    $0x8,%rsp
   4:       31 ff                   xor    %edi,%edi
   6:       e8 00 00 00 00          callq  b <main+0xb>
                    7: R_X86_64_PC32        time-0x4
   b:       48 85 c0                test   %rax,%rax
   e:       74 07                   je     17 <main+0x17>
  10:       31 c0                   xor    %eax,%eax
  12:       48 83 c4 08             add    $0x8,%rsp
  16:       c3                      retq
  17:       bf 00 00 00 00          mov    $0x0,%edi
                    18: R_X86_64_32 .rodata.str1.1
  1c:       e8 00 00 00 00          callq  21 <main+0x21>
                    1d: R_X86_64_PC32       puts-0x4
  21:       eb ed                   jmp    10 <main+0x10>

È putsstato spostato fino alla fine della funzione, il retqritorno!

Il nuovo codice è sostanzialmente lo stesso di:

int i = !time(NULL);
if (i)
    goto puts;
ret:
return 0;
puts:
puts("a");
goto ret;

Questa ottimizzazione non è stata eseguita -O0.

Ma buona fortuna a scrivere un esempio che corre più veloce __builtin_expectche senza, le CPU sono davvero intelligenti in quei giorni . I miei ingenui tentativi sono qui .

C ++ 20 [[likely]]e[[unlikely]]

C ++ 20 ha standardizzato quegli built-in C ++: Come usare l'attributo probabile / improbabile di C ++ 20 nell'istruzione if-else Probabilmente (un gioco di parole!) Farà la stessa cosa.


1
Dai un'occhiata alla funzione dispatch_once di libdispatch, che utilizza __builtin_expect per una pratica ottimizzazione. Il percorso lento viene eseguito una sola volta e sfrutta __builtin_expect per suggerire al predittore del ramo che deve essere eseguito il percorso veloce. Il percorso veloce scorre senza usare alcun blocco! mikeash.com/pyblog/…
Adam Kaplan,

Non sembra fare alcuna differenza in GCC 9.2: gcc.godbolt.org/z/GzP6cx (in realtà, già in 8.1)
Ruslan,

40

L'idea di __builtin_expectè di dire al compilatore che di solito troverai che l'espressione restituisce c, in modo che il compilatore possa ottimizzare per quel caso.

Immagino che qualcuno pensasse che fossero intelligenti e che stessero accelerando le cose facendo questo.

Sfortunatamente, a meno che la situazione non sia ben compresa (è probabile che non abbiano fatto nulla del genere), potrebbe anche aver peggiorato le cose. La documentazione dice anche:

In generale, dovresti preferire utilizzare il feedback del profilo effettivo per questo ( -fprofile-arcs), poiché i programmatori sono notoriamente cattivi nel predire il rendimento effettivo dei loro programmi. Tuttavia, ci sono applicazioni in cui questi dati sono difficili da raccogliere.

In generale, non dovresti usare a __builtin_expectmeno che:

  • Hai un vero problema di prestazioni
  • Hai già ottimizzato gli algoritmi nel sistema in modo appropriato
  • Hai dati sulle prestazioni per sostenere la tua affermazione che un caso particolare è il più probabile

7
@Michael: Questa non è in realtà una descrizione della previsione del ramo.
Oliver Charlesworth,

3
"la maggior parte dei programmatori è MALE" o comunque non migliore del compilatore. Qualsiasi idiota può dire che in un ciclo for è probabile che la condizione di continuazione sia vera, ma anche il compilatore lo sa, quindi non c'è alcun vantaggio nel dirlo. Se per qualche motivo hai scritto un ciclo che si interrompe quasi sempre immediatamente e se non puoi fornire i dati del profilo al compilatore per PGO, allora forse il programmatore sa qualcosa che il compilatore non sa.
Steve Jessop,

15
In alcune situazioni, non importa quale ramo sia più probabile, ma piuttosto quale ramo conta. Se il ramo inatteso porta ad abort (), la probabilità non ha importanza e al ramo atteso dovrebbe essere data la priorità di prestazione durante l'ottimizzazione.
Neowizard,

1
Il problema con la tua affermazione è che le ottimizzazioni che la CPU può eseguire rispetto alla probabilità del ramo sono praticamente limitate a una: previsione del ramo, e questa ottimizzazione avviene sia che tu lo usi __builtin_expecto meno . D'altro canto, il compilatore può eseguire molte ottimizzazioni in base alla probabilità della diramazione, ad esempio l'organizzazione del codice in modo tale che il percorso attivo sia contiguo, che è improbabile che il codice venga spostato ulteriormente o ridurne le dimensioni, prendendo decisioni su quali rami vettorializzare, migliore pianificazione del percorso attivo e così via.
BeeOnRope,

1
... senza informazioni dallo sviluppatore, è cieco e sceglie una strategia neutrale. Se lo sviluppatore ha ragione sulle probabilità (e in molti casi è banale capire che un ramo di solito è preso / non preso) - ottieni questi vantaggi. Se non lo sei, ricevi una penalità, ma non è in qualche modo molto più grande dei vantaggi e, cosa più critica, nulla di tutto ciò sovrascrive la previsione del ramo della CPU.
BeeOnRope,

13

Bene, come dice la descrizione, la prima versione aggiunge un elemento predittivo alla costruzione, dicendo al compilatore che il x == 0ramo è il più probabile - cioè, è il ramo che verrà preso più spesso dal tuo programma.

Tenendo presente ciò, il compilatore può ottimizzare il condizionale in modo che richieda la minima quantità di lavoro quando sussiste la condizione prevista, a spese forse di dover fare più lavoro in caso di condizione imprevista.

Dai un'occhiata a come vengono implementati i condizionali durante la fase di compilazione, e anche nell'assemblaggio risultante, per vedere come un ramo potrebbe funzionare meno dell'altro.

Tuttavia, mi aspetto che questa ottimizzazione abbia un effetto evidente se il condizionale in questione fa parte di un circuito interno stretto che viene chiamato molto , poiché la differenza nel codice risultante è relativamente piccola. E se lo ottimizzi nel modo sbagliato, potresti ridurre le tue prestazioni.


Ma alla fine si tratta di controllare le condizioni dal compilatore, vuoi dire che il compilatore assume sempre questo ramo e procede, e in seguito se non c'è una corrispondenza allora? Che succede? Penso che ci sia qualcosa di più su questa roba di previsione del ramo nella progettazione del compilatore e su come funziona.
kingsmasher1,

2
Questa è veramente una micro-ottimizzazione. Guarda come vengono implementati i condizionali, c'è un piccolo orientamento verso un ramo. Come esempio ipotetico, supponiamo che un condizionale diventi un test più un salto nell'assieme. Quindi il ramo saltante è più lento di quello non saltante, quindi preferiresti rendere il ramo atteso quello non saltante.
Kerrek SB,

Grazie, tu e Michael penso che abbiamo punti di vista simili ma in parole diverse :-) Capisco l'esatto interno del compilatore su Test-and-branch non è possibile spiegare qui :)
kingsmasher1

Sono anche molto facili da imparare cercando su Internet :-)
Kerrek SB

È meglio che torni al mio libro del college di compiler design - Aho, Ullmann, Sethi:-)
kingsmasher1 l'

1

Non vedo nessuna delle risposte che rispondono alla domanda che penso tu stia ponendo, parafrasando:

Esiste un modo più portatile di suggerire la previsione del ramo al compilatore.

Il titolo della tua domanda mi ha fatto pensare di farlo in questo modo:

if ( !x ) {} else foo();

Se il compilatore presuppone che "true" sia più probabile, potrebbe ottimizzare per non chiamare foo().

Il problema qui è solo che, in generale, non sai cosa assumerà il compilatore, quindi qualsiasi codice che utilizza questo tipo di tecnica dovrebbe essere misurato con attenzione (e possibilmente monitorato nel tempo se il contesto cambia).


Questo potrebbe, in effetti, essere stato esattamente ciò che l'OP aveva inizialmente inteso digitare (come indicato dal titolo) - ma per qualche ragione l'uso di è elsestato lasciato fuori dal corpo del post.
Brent Bradburn,

1

Lo collaudo su Mac secondo @Blagovest Buyukliev e @Ciro. Gli assemblaggi sembrano chiari e aggiungo commenti;

I comandi sono gcc -c -O3 -std=gnu11 testOpt.c; otool -tVI testOpt.o

Quando uso -O3 , sembra uguale indipendentemente dal fatto che __builtin_expect (i, 0) esista o meno.

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp     
0000000000000001    movq    %rsp, %rbp    // open function stack
0000000000000004    xorl    %edi, %edi       // set time args 0 (NULL)
0000000000000006    callq   _time      // call time(NULL)
000000000000000b    testq   %rax, %rax   // check time(NULL)  result
000000000000000e    je  0x14           //  jump 0x14 if testq result = 0, namely jump to puts
0000000000000010    xorl    %eax, %eax   //  return 0   ,  return appear first 
0000000000000012    popq    %rbp    //  return 0
0000000000000013    retq                     //  return 0
0000000000000014    leaq    0x9(%rip), %rdi  ## literal pool for: "a"  // puts  part, afterwards
000000000000001b    callq   _puts
0000000000000020    xorl    %eax, %eax
0000000000000022    popq    %rbp
0000000000000023    retq

Quando compilato con -O2 , sembra diverso con e senza __builtin_expect (i, 0)

Prima senza

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp
0000000000000001    movq    %rsp, %rbp
0000000000000004    xorl    %edi, %edi
0000000000000006    callq   _time
000000000000000b    testq   %rax, %rax
000000000000000e    jne 0x1c       //   jump to 0x1c if not zero, then return
0000000000000010    leaq    0x9(%rip), %rdi ## literal pool for: "a"   //   put part appear first ,  following   jne 0x1c
0000000000000017    callq   _puts
000000000000001c    xorl    %eax, %eax     // return part appear  afterwards
000000000000001e    popq    %rbp
000000000000001f    retq

Ora con __builtin_expect (i, 0)

testOpt.o:
(__TEXT,__text) section
_main:
0000000000000000    pushq   %rbp
0000000000000001    movq    %rsp, %rbp
0000000000000004    xorl    %edi, %edi
0000000000000006    callq   _time
000000000000000b    testq   %rax, %rax
000000000000000e    je  0x14   // jump to 0x14 if zero  then put. otherwise return 
0000000000000010    xorl    %eax, %eax   // return appear first 
0000000000000012    popq    %rbp
0000000000000013    retq
0000000000000014    leaq    0x7(%rip), %rdi ## literal pool for: "a"
000000000000001b    callq   _puts
0000000000000020    jmp 0x10

Per riassumere, __builtin_expect funziona nell'ultimo caso.

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.