Come funzionano le macro probabili / improbabili nel kernel Linux e quali sono i loro vantaggi?


348

Ho cercato alcune parti del kernel Linux e ho trovato chiamate come questa:

if (unlikely(fd < 0))
{
    /* Do something */
}

o

if (likely(!err))
{
    /* Do something */
}

Ho trovato la loro definizione:

#define likely(x)       __builtin_expect((x),1)
#define unlikely(x)     __builtin_expect((x),0)

So che sono per l'ottimizzazione, ma come funzionano? E quanta riduzione di prestazioni / dimensioni ci si può aspettare dal loro utilizzo? E vale la seccatura (e probabilmente la perdita della portabilità) almeno nel codice del collo di bottiglia (nello spazio utente, ovviamente).


7
Questo non è specifico per il kernel Linux o per le macro, ma un'ottimizzazione del compilatore. Questo dovrebbe essere ricodificato per riflettere quello?
Cody Brocious,

11
L'articolo che ogni programmatore dovrebbe sapere sulla memoria (p. 57) contiene una spiegazione approfondita.
Torsten Marek,

2
vedi ancheBOOST_LIKELY
Ruggero Turra il


13
Non ci sono problemi di portabilità. Puoi fare banalmente cose come #define likely(x) (x)e #define unlikely(x) (x)su piattaforme che non supportano questo tipo di suggerimenti.
David Schwartz,

Risposte:


329

Stanno suggerendo al compilatore di emettere istruzioni che indurranno la previsione del ramo a favorire il lato "probabile" di un'istruzione di salto. Questa può essere una grande vittoria, se la previsione è corretta significa che l'istruzione di salto è sostanzialmente gratuita e richiederà zero cicli. D'altra parte se la previsione è errata, significa che la pipeline del processore deve essere svuotata e può costare diversi cicli. Fintanto che la previsione è corretta per la maggior parte del tempo, questo tenderà ad essere buono per le prestazioni.

Come tutte queste ottimizzazioni delle prestazioni, dovresti farlo solo dopo una profilatura approfondita per garantire che il codice sia davvero un collo di bottiglia, e probabilmente data la micro natura, che viene eseguito in un ciclo stretto. Generalmente gli sviluppatori Linux sono piuttosto esperti quindi immagino che lo avrebbero fatto. A loro non importa molto della portabilità poiché prendono di mira solo gcc e hanno un'idea molto stretta dell'assemblaggio che vogliono che generi.


3
Queste macro sono state principalmente utilizzate per il controllo degli errori. Perché l'errore lascia meno probabilmente del normale funzionamento. Alcune persone eseguono la profilazione o il calcolo per decidere la foglia più usata ...
gavenkoa

51
Per quanto riguarda il frammento "[...]that it is being run in a tight loop", molte CPU hanno un predittore di diramazione , quindi l'utilizzo di queste macro aiuta solo la prima esecuzione del codice temporale o quando la tabella della cronologia viene sovrascritta da un ramo diverso con lo stesso indice nella tabella di ramificazione. In un ciclo stretto, e supponendo che un ramo vada in un modo per la maggior parte del tempo, il predittore di ramo probabilmente inizierà a indovinare il ramo corretto molto rapidamente. - il tuo amico in pedanteria.
Ross Rogers,

8
@RossRogers: Quello che succede veramente è che il compilatore organizza i rami, quindi il caso comune è quello non preso. Questo è più veloce anche quando la previsione del ramo funziona. I rami presi sono problematici per il recupero e la decodifica delle istruzioni anche quando sono previsti perfettamente. Alcune CPU prevedono staticamente rami che non si trovano nella loro tabella di cronologia, di solito con ipotesi non prese per i rami forward. Le CPU Intel non funzionano in questo modo: non provano a verificare che la voce della tabella predittore sia per questo ramo, ma lo usano comunque. Un ramo caldo e un ramo freddo potrebbero alias la stessa voce ...
Peter Cordes,

12
Questa risposta è per lo più obsoleta poiché l'affermazione principale è che aiuta la previsione dei rami e, come sottolinea @PeterCordes, nella maggior parte dell'hardware moderno non esiste una previsione dei rami statici implicita o esplicita. In realtà il suggerimento viene utilizzato dal compilatore per ottimizzare il codice, sia che si tratti di suggerimenti di diramazione statici, sia di qualsiasi altro tipo di ottimizzazione. Per la maggior parte delle architetture di oggi, è "qualsiasi altra ottimizzazione" che conta, ad esempio, rendere contigui i percorsi caldi, pianificare meglio il percorso caldo, ridurre al minimo le dimensioni del percorso lento, vettorializzare solo il percorso previsto, ecc.
Ecc

3
@BeeOnRope a causa del prefetch della cache e della dimensione della parola, c'è ancora un vantaggio nell'esecuzione di un programma in modo lineare. La posizione di memoria successiva verrà già recuperata e nella cache, la destinazione del ramo forse o forse no. Con una CPU a 64 bit si ottengono almeno 64 bit alla volta. A seconda dell'interleave DRAM, possono essere catturati 2x 3x o più bit.
Bryce,

88

Decompiliamo per vedere cosa fa GCC 4.8

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)
        printf("%d\n", 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 14                   jne    24 <main+0x24>
  10:       ba 01 00 00 00          mov    $0x1,%edx
  15:       be 00 00 00 00          mov    $0x0,%esi
                    16: R_X86_64_32 .rodata.str1.1
  1a:       bf 01 00 00 00          mov    $0x1,%edi
  1f:       e8 00 00 00 00          callq  24 <main+0x24>
                    20: R_X86_64_PC32       __printf_chk-0x4
  24:       bf 00 00 00 00          mov    $0x0,%edi
                    25: R_X86_64_32 .rodata.str1.1+0x4
  29:       e8 00 00 00 00          callq  2e <main+0x2e>
                    2a: R_X86_64_PC32       puts-0x4
  2e:       31 c0                   xor    %eax,%eax
  30:       48 83 c4 08             add    $0x8,%rsp
  34:       c3                      retq

L'ordine delle istruzioni in memoria era invariato: prima il printfe poi putse 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 11                   je     21 <main+0x21>
  10:       bf 00 00 00 00          mov    $0x0,%edi
                    11: R_X86_64_32 .rodata.str1.1+0x4
  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
  21:       ba 01 00 00 00          mov    $0x1,%edx
  26:       be 00 00 00 00          mov    $0x0,%esi
                    27: R_X86_64_32 .rodata.str1.1
  2b:       bf 01 00 00 00          mov    $0x1,%edi
  30:       e8 00 00 00 00          callq  35 <main+0x35>
                    31: R_X86_64_PC32       __printf_chk-0x4
  35:       eb d9                   jmp    10 <main+0x10>

Il printf(compilato in __printf_chk) è stato spostato alla fine della funzione, dopoputs e il ritorno per migliorare la previsione del ramo come menzionato da altre risposte.

Quindi è sostanzialmente lo stesso di:

int main() {
    int i = !time(NULL);
    if (i)
        goto printf;
puts:
    puts("a");
    return 0;
printf:
    printf("%d\n", i);
    goto puts;
}

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 questi giorni . I miei ingenui tentativi sono qui .

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

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


71

Queste sono macro che danno suggerimenti al compilatore su come può andare un ramo. Le macro si espandono in estensioni specifiche di GCC, se disponibili.

GCC li utilizza per ottimizzare la previsione delle filiali. Ad esempio, se hai qualcosa di simile al seguente

if (unlikely(x)) {
  dosomething();
}

return x;

Quindi può ristrutturare questo codice per essere qualcosa di più simile a:

if (!x) {
  return x;
}

dosomething();
return x;

Il vantaggio di ciò è che quando il processore prende una succursale per la prima volta, c'è un notevole sovraccarico, perché potrebbe essere stato speculativamente caricando ed eseguendo il codice più avanti. Quando determina che prenderà il ramo, deve invalidarlo e iniziare dalla destinazione del ramo.

La maggior parte dei processori moderni ora ha una sorta di previsione del ramo, ma ciò aiuta solo quando si è già passati attraverso il ramo e il ramo è ancora nella cache di previsione del ramo.

Esistono diverse altre strategie che il compilatore e il processore possono utilizzare in questi scenari. Puoi trovare maggiori dettagli su come funzionano i predittori di filiali su Wikipedia: http://en.wikipedia.org/wiki/Branch_predictor


3
Inoltre, influisce sull'impronta di icache, mantenendo frammenti di codice improbabili fuori dal percorso attivo.
fche

2
Più precisamente, si può farlo con gotos senza ripetere il return x: stackoverflow.com/a/31133787/895245
Ciro Santilli郝海东冠状病六四事件法轮功

7

Fanno sì che il compilatore emetta i suggerimenti di diramazione appropriati dove l'hardware li supporta. Questo di solito significa solo modificare alcuni bit nel codice operativo dell'istruzione, quindi la dimensione del codice non cambierà. La CPU inizierà a recuperare le istruzioni dalla posizione prevista, svuota la pipeline e ricomincia da capo se ciò risulta essere errato quando viene raggiunto il ramo; nel caso in cui il suggerimento sia corretto, questo renderà il ramo molto più veloce - precisamente quanto molto più veloce dipenderà dall'hardware; e quanto ciò influirà sulle prestazioni del codice dipenderà dalla percentuale di suggerimento temporale corretta.

Ad esempio, su una CPU PowerPC un ramo non suggerito può richiedere 16 cicli, uno correttamente suggerito 8 e uno erroneamente suggerito 24. Nei cicli più interni, un buon suggerimento può fare un'enorme differenza.

La portabilità non è in realtà un problema - presumibilmente la definizione è in un'intestazione per piattaforma; puoi semplicemente definire "probabile" e "improbabile" per nulla per le piattaforme che non supportano i suggerimenti sui rami statici.


3
Per la cronaca, x86 occupa spazio aggiuntivo per i suggerimenti sui rami. Devi avere un prefisso di un byte sui rami per specificare il suggerimento appropriato. D'accordo, tuttavia, il suggerimento è una buona cosa (TM).
Cody Brocious,

2
Dang CPU CISC e le loro istruzioni a lunghezza variabile;)
moonshadow

3
Dang CPU RISC - Stai lontano dalle mie istruzioni a 15 byte;)
Cody Brocious,

7
@CodyBrocious: il suggerimento del ramo è stato introdotto con P4, ma è stato abbandonato insieme a P4. Tutte le altre CPU x86 ignorano semplicemente quei prefissi (perché i prefissi vengono sempre ignorati in contesti in cui sono privi di significato). Queste macro non fanno sì che gcc emetta effettivamente prefissi di suggerimento di ramo su x86. Ti aiutano a ottenere gcc per strutturare la tua funzione con meno rami presi sul percorso veloce.
Peter Cordes,

5
long __builtin_expect(long EXP, long C);

Questo costrutto dice al compilatore che l'espressione EXP avrà molto probabilmente il valore C. Il valore restituito è EXP. __builtin_expect è pensato per essere usato in un'espressione condizionale. In quasi tutti i casi verrà utilizzato nel contesto di espressioni booleane, nel qual caso è molto più conveniente definire due macro di supporto:

#define unlikely(expr) __builtin_expect(!!(expr), 0)
#define likely(expr) __builtin_expect(!!(expr), 1)

Queste macro possono quindi essere utilizzate come in

if (likely(a > 1))

Riferimento: https://www.akkadia.org/drepper/cpumemory.pdf


1
Come è stato chiesto in un commento a un'altra risposta: qual è il motivo della doppia inversione nelle macro (cioè perché usare __builtin_expect(!!(expr),0)invece di solo __builtin_expect((expr),0)?
Michael Firth

1
@MichaelFirth "doppia inversione" !!equivale a lanciare qualcosa in a bool. Ad alcune persone piace scriverlo in questo modo.
Ben XO,

2

(commento generale - altre risposte coprono i dettagli)

Non c'è motivo per cui tu debba perdere la portabilità usandoli.

Hai sempre la possibilità di creare un semplice "inline" o macro che ti permetterà di compilare su altre piattaforme con altri compilatori.

Non otterrai il vantaggio dell'ottimizzazione se ti trovi su altre piattaforme.


1
Non usi la portabilità: le piattaforme che non le supportano semplicemente le definiscono per espandersi in stringhe vuote.
sharptooth,

2
Penso che voi due siate davvero d'accordo l'uno con l'altro - è solo formulato in modo confuso. (A quanto pare, il commento di Andrew sta dicendo "puoi usarli senza perdere la portabilità" ma sharptooth ha pensato che avesse detto "non usarli perché non sono portatili" e ha obiettato.)
Miral

2

Secondo il commento di Cody , questo non ha nulla a che fare con Linux, ma è un suggerimento per il compilatore. Cosa accadrà dipenderà dall'architettura e dalla versione del compilatore.

Questa particolare funzionalità di Linux è in qualche modo utilizzata male nei driver. Come sottolinea osgx nella semantica dell'attributo hot , qualsiasi hoto coldfunzione chiamata con in un blocco può automaticamente suggerire che la condizione è probabile o no. Ad esempio, dump_stack()è contrassegnato in coldmodo che sia ridondante,

 if(unlikely(err)) {
     printk("Driver error found. %d\n", err);
     dump_stack();
 }

Le versioni future di gccpotrebbero incorporare selettivamente una funzione basata su questi suggerimenti. Ci sono stati anche suggerimenti che non lo sono boolean, ma un punteggio come nella maggior parte dei casi , ecc. In generale, dovrebbe essere preferito usare alcuni meccanismi alternativi come cold. Non vi è alcun motivo per usarlo in qualsiasi luogo tranne che per i percorsi caldi. Ciò che un compilatore farà su un'architettura può essere completamente diverso su un'altra.


2

In molte versioni di Linux, puoi trovare complier.h in / usr / linux /, puoi includerlo per usarlo semplicemente. E un'altra opinione, improbabile () è più utile che probabile (), perché

if ( likely( ... ) ) {
     doSomething();
}

può essere ottimizzato anche in molti compilatori.

E a proposito, se vuoi osservare il comportamento dettagliato del codice, puoi semplicemente fare come segue:

gcc -c test.c objdump -d test.o> obj.s

Quindi, apri obj.s, puoi trovare la risposta.


1

Sono suggerimenti per il compilatore per generare i prefissi di suggerimento sui rami. Su x86 / x64, occupano un byte, quindi otterrai al massimo un aumento di un byte per ogni ramo. Per quanto riguarda le prestazioni, dipende interamente dall'applicazione: nella maggior parte dei casi, il predittore di diramazione sul processore li ignorerà in questi giorni.

Modifica: dimenticato di un posto in cui possono davvero aiutare. Può consentire al compilatore di riordinare il grafico del flusso di controllo per ridurre il numero di diramazioni prese per il percorso "probabile". Ciò può avere un netto miglioramento nei loop in cui si verificano più casi di uscita.


10
gcc non genera mai suggerimenti sul ramo x86 - almeno tutte le CPU Intel li ignorerebbero comunque. Tuttavia, tenterà di limitare la dimensione del codice in regioni improbabili, evitando inline e srotolamento.
alex strano

1

Queste sono funzioni GCC per il programmatore per dare un suggerimento al compilatore su quale sarà la condizione di ramo più probabile in una data espressione. Ciò consente al compilatore di creare le istruzioni di diramazione in modo che il caso più comune richieda il minor numero di istruzioni da eseguire.

La modalità di creazione delle istruzioni di diramazione dipende dall'architettura del processore.

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.