Perché i compilatori C ottimizzano switch e se diversamente


9

Di recente stavo lavorando a un progetto personale quando mi sono imbattuto in uno strano problema.

In un ciclo molto stretto ho un numero intero con un valore compreso tra 0 e 15. Ho bisogno di ottenere -1 per i valori 0, 1, 8 e 9 e 1 per i valori 4, 5, 12 e 13.

Mi sono rivolto a godbolt per verificare alcune opzioni e sono rimasto sorpreso dal fatto che sembrava che il compilatore non potesse ottimizzare un'istruzione switch allo stesso modo di una catena if.

Il link è qui: https://godbolt.org/z/WYVBFl

Il codice è:

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}

int b(int num) {
    num &= 0xF;

    if (num == 0 || num == 1 || num == 8 || num == 9) 
        return -1;

    if (num == 4 || num == 5 || num == 12 || num == 13)
        return 1;

    return 0;
}

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
        default:
            return 0;
    }
}

Avrei pensato che bec avrebbe prodotto gli stessi risultati, e speravo di poter leggere da solo i bit-hack per realizzare un'implementazione efficiente poiché la mia soluzione (la dichiarazione switch - in un'altra forma) era piuttosto lenta.

Stranamente, bcompilato per bit-hack mentre cera praticamente non ottimizzato o ridotto in un caso diverso a aseconda dell'hardware di destinazione.

Qualcuno può spiegare perché c'è questa discrepanza? Qual è il modo "corretto" per ottimizzare questa query?

MODIFICARE:

Una precisazione

Voglio che la soluzione switch sia la soluzione più veloce o allo stesso modo "pulita". Tuttavia, quando compilato con ottimizzazioni sulla mia macchina, la soluzione if è significativamente più veloce.

Ho scritto un programma rapido per dimostrare e TIO ha gli stessi risultati che trovo localmente: provalo online!

Con static inlinela tabella di ricerca si accelera un po ': provalo online!


4
Ho il sospetto che la risposta sia "I compilatori non fanno sempre scelte sane". Ho appena compilato il tuo codice in un oggetto con GCC 8.3.0 -O3e ha compilato cqualcosa di probabilmente peggiore di ao b( caveva due salti condizionali più alcune manipolazioni di bit, contro solo un salto condizionale e una manipolazione di bit più semplice per b), ma comunque meglio di un oggetto ingenuo per test articolo. Non sono sicuro di cosa tu stia davvero chiedendo qui; il semplice fatto è che un compilatore ottimizzante può trasformare uno di questi in uno qualsiasi degli altri, se lo desidera, e non ci sono regole rigide e veloci per ciò che farà o non farà.
ShadowRanger,

Il mio problema è che ho bisogno che sia veloce, ma la soluzione if non è eccessivamente mantenibile. Esiste un modo per convincere il compilatore a ottimizzare sufficientemente una soluzione più pulita? Qualcuno può spiegare perché non può farlo in questo caso?
LambdaBeta,

Vorrei iniziare definendo almeno le funzioni come statiche, o anche meglio inserendole.
wildplasser,

@wildplasser lo accelera, ma ifbatte ancora switch(la ricerca stranamente diventa ancora più veloce) [TIO da seguire]
LambdaBeta

@LambdaBeta Non c'è modo di dire a un compilatore di ottimizzare in un modo specifico. Noterai che clang e msvc generano un codice completamente diverso per questi. Se non ti interessa e vuoi solo quello che funziona meglio su gcc, allora scegli quello. Le ottimizzazioni del compilatore si basano sull'euristica e quelle non offrono la soluzione ottimale in tutti i casi; Stanno cercando di essere bravi nel caso medio, non ottimale in tutti i casi.
Cubico,

Risposte:


6

Se si elencano esplicitamente tutti i casi, gcc è molto efficiente:

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
            case 2: case 3: case 6: case 7: case 10: case 11: case 14: case 15: 
        //default:
            return 0;
    }
}

è appena compilato in un semplice ramo indicizzato:

c:
        and     edi, 15
        jmp     [QWORD PTR .L10[0+rdi*8]]
.L10:
        .quad   .L12
        .quad   .L12
        .quad   .L9
        .quad   .L9
        .quad   .L11
        .quad   .L11
        .quad   .L9
        .quad   .L9
        .quad   .L12
etc...

Nota che se non default:è commentato, gcc torna alla sua versione di ramo nidificata.


1
@LambdaBeta Dovresti considerare di accettare la mia risposta e accettarla, perché le moderne CPU Intel possono fare due letture / cicli di memoria indicizzata parallela mentre il throughput del mio trucco è probabilmente 1 ricerca / ciclo. Il rovescio della medaglia, forse il mio hack è più suscettibile alla vettorializzazione a 4 vie con SSE2 pslld/ psrado loro equivalenti AVX2 a 8 vie. Molto dipende dalle altre particolarità del tuo codice.
Iwillnotexist Idonotexist,

4

I compilatori C hanno casi speciali switch, perché si aspettano che i programmatori comprendano il linguaggio di switche lo sfruttino.

Codice come:

if (num == 0 || num == 1 || num == 8 || num == 9) 
    return -1;

if (num == 4 || num == 5 || num == 12 || num == 13)
    return 1;

non passerebbe la revisione da parte di programmatori C competenti; tre o quattro revisori avrebbero simultaneamente esclamato "questo dovrebbe essere un switch!"

Non vale la pena per i compilatori C di analizzare la struttura delle ifistruzioni per la conversione in una tabella di salto. Le condizioni per questo devono essere giuste e la quantità di variazione che è possibile in un mucchio di ifaffermazioni è astronomica. L'analisi è sia complicata che rischia di risultare negativa (come in: "no, non possiamo convertire queste ifs in a switch").


Lo so, è per questo che ho iniziato con l'interruttore. Tuttavia, la soluzione if è significativamente più veloce nel mio caso. Fondamentalmente sto chiedendo se c'è un modo per convincere il compilatore a utilizzare una soluzione migliore per lo switch, poiché è stato in grado di trovare lo schema negli if, ma non lo switch. (Non mi piacciono gli if specificamente perché non sono così chiari o mantenibili)
LambdaBeta

Votato ma non accettato poiché il sentimento è esattamente il motivo per cui ho fatto questa domanda. Io voglio usare l'interruttore, ma è troppo lento nel mio caso, voglio evitare l' ifse possibile.
LambdaBeta,

@LambdaBeta: c'è qualche motivo per evitare la tabella di ricerca? Crealo statice usa gli inizializzatori designati C99 se vuoi rendere un po 'più chiaro ciò che stai assegnando, ed è chiaramente perfettamente perfetto.
ShadowRanger,

1
Comincerei almeno scartando il bit basso in modo che ci sia meno lavoro da fare per l'ottimizzatore.
R .. GitHub smette di aiutare ICE il

@ShadowRanger Purtroppo è ancora più lento di if(vedi modifica). @R .. Ho elaborato la soluzione bit per bit completa per il compilatore, che è quello che sto usando per ora. Sfortunatamente nel mio caso questi sono enumvalori, non interi nudi, quindi gli hack bit per bit non sono molto mantenibili.
LambdaBeta,

4

Il seguente codice calcolerà la ricerca senza branch, LUT, in ~ 3 cicli di clock, ~ 4 istruzioni utili e ~ 13 byte di inlinecodice macchina x86 altamente abilitato.

Dipende dalla rappresentazione del numero intero del complemento di 2.

È necessario, tuttavia, assicurarsi che u32e s32typedefs faccia effettivamente riferimento a tipi interi senza segno e con segno a 32 bit. stdint.htipi uint32_te int32_tsarebbe stato adatto, ma non ho idea se l'intestazione è disponibile per te.

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}


int d(int num){
    typedef unsigned int u32;
    typedef signed   int s32;

    // const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
    // 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
    // Hexadecimal:                   F     0     5     0     F     0     5     0
    const u32 K = 0xF050F050U;

    return (s32)(K<<(num+num)) >> 30;
}

int main(void){
    for(int i=0;i<16;i++){
        if(a(i) != d(i)){
            return !0;
        }
    }
    return 0;
}

Vedi di persona qui: https://godbolt.org/z/AcJWWf


Sulla selezione della costante

La tua ricerca è per 16 costanti molto piccole tra -1 e +1 incluso. Ciascuno si adatta a 2 bit e ce ne sono 16, che possiamo disporre come segue:

// const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
// 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
// Hexadecimal:                   F     0     5     0     F     0     5     0
u32 K = 0xF050F050U;

Posizionandoli con l'indice 0 più vicino al bit più significativo, un singolo spostamento di 2*numposizionerà il bit di segno del numero a 2 bit nel bit di segno del registro. Spostando a destra il numero di 2 bit di 32-2 = segno di 30 bit, lo si estende al massimo int, completando il trucco.


Questo potrebbe essere solo il modo più pulito per farlo con un magiccommento che spiega come rigenerarlo. Potresti spiegare come ti è venuto in mente?
LambdaBeta,

Accettato dal momento che questo può essere reso "pulito" pur essendo veloce. (tramite un po 'di magia preprocessore :) < xkcd.com/541 >)
LambdaBeta

1
Batte il mio tentativo senza rami:!!(12336 & (1<<x))-!!(771 & (1<<x));
technosaurus,

0

Puoi creare lo stesso effetto usando solo l'aritmetica:

// produces : -1 -1 0 0 1 1 0 0 -1 -1 0 0 1 1 0 0 ...
int foo ( int x )
{
    return 1 - ( 3 & ( 0x46 >> ( x & 6 ) ) );
}

Anche se, tecnicamente, questa è ancora una ricerca (bit a bit).

Se quanto sopra sembra troppo arcano, puoi anche fare:

int foo ( int x )
{
    int const y = x & 6;
    return (y == 4) - !y;
}
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.