Perché GCC genera un assembly così radicalmente diverso per quasi lo stesso codice C?


184

Durante la scrittura di una ftolfunzione ottimizzata ho trovato un comportamento molto strano in GCC 4.6.1. Lascia che ti mostri prima il codice (per chiarezza ho segnato le differenze):

fast_trunc_one, C:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;                       /* diff */
    } else {
        r = mantissa >> exponent;                        /* diff */
    }

    return (r ^ -sign) + sign;                           /* diff */
}

fast_trunc_two, C:

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent) ^ -sign;             /* diff */
    } else {
        r = (mantissa >> exponent) ^ -sign;              /* diff */
    }

    return r + sign;                                     /* diff */
}

Sembra lo stesso giusto? Bene GCC non è d'accordo. Dopo aver compilato gcc -O3 -S -Wall -o test.s test.cquesto è l'output dell'assembly:

fast_trunc_one, generato:

_fast_trunc_one:
LFB0:
    .cfi_startproc
    movl    4(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %edx
    andl    $8388607, %edx
    sarl    $23, %eax
    orl $8388608, %edx
    andl    $255, %eax
    subl    %eax, %ecx
    movl    %edx, %eax
    sarl    %cl, %eax
    testl   %ecx, %ecx
    js  L5
    rep
    ret
    .p2align 4,,7
L5:
    negl    %ecx
    movl    %edx, %eax
    sall    %cl, %eax
    ret
    .cfi_endproc

fast_trunc_two, generato:

_fast_trunc_two:
LFB1:
    .cfi_startproc
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_offset 3, -8
    movl    8(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %ebx
    movl    %eax, %edx
    sarl    $23, %ebx
    andl    $8388607, %edx
    andl    $255, %ebx
    orl $8388608, %edx
    andl    $-2147483648, %eax
    subl    %ebx, %ecx
    js  L9
    sarl    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_remember_state
    .cfi_def_cfa_offset 4
    .cfi_restore 3
    ret
    .p2align 4,,7
L9:
    .cfi_restore_state
    negl    %ecx
    sall    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_restore 3
    .cfi_def_cfa_offset 4
    ret
    .cfi_endproc

Questa è una differenza estrema . Questo in realtà appare anche sul profilo, fast_trunc_oneè circa il 30% più veloce di fast_trunc_two. Ora la mia domanda: cosa sta causando questo?


1
A scopo di verifica ho creato una sostanza qui , dove si può facilmente copiare / incollare la fonte e vedere se è possibile riprodurre il bug su altri sistemi / versioni di GCC.
orlp,

12
Metti i casi di test in una directory a parte. Compilali con -S -O3 -da -fdump-tree-all. Ciò creerà molte istantanee della rappresentazione intermedia. Cammina attraverso di loro (sono numerati) fianco a fianco e dovresti riuscire a trovare l'ottimizzazione mancante nel primo caso.
zwol,

1
Suggerimento due: cambia tutto intin unsigned inte vedi se la differenza svanisce.
zwol,

5
Le due funzioni sembrano fare una matematica leggermente diversa. Mentre i risultati potrebbero essere gli stessi, l'espressione (r + shifted) ^ signnon è la stessa di r + (shifted ^ sign). Immagino che questo confonda l'ottimizzatore? FWIW, MSVC 2010 (16.00.40219.01) produce elenchi quasi identici tra loro: gist.github.com/2430454
DCoder

1
@DCoder: Oh dannazione! Non l'ho notato. Tuttavia, non è la spiegazione della differenza. Vorrei aggiornare la domanda con una nuova versione in cui questa è esclusa.
orlp,

Risposte:


256

Aggiornato per la sincronizzazione con la modifica del PO

Armeggiando con il codice, sono riuscito a vedere come GCC ottimizza il primo caso.

Prima di capire perché sono così diversi, dobbiamo prima capire come GCC ottimizza fast_trunc_one().

Che ci crediate o no, fast_trunc_one()viene ottimizzato per questo:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

Questo produce lo stesso identico assieme dell'originale fast_trunc_one()- registra i nomi e tutto il resto.

Si noti che non ci sono messaggi xornell'assieme per fast_trunc_one(). Questo è ciò che mi ha regalato.


Come mai?


Passo 1: sign = -sign

Innanzitutto, diamo un'occhiata alla signvariabile. Da allora sign = i & 0x80000000;, ci sono solo due possibili valori che signpossono assumere:

  • sign = 0
  • sign = 0x80000000

Ora riconoscere che in entrambi i casi, sign == -sign. Pertanto, quando cambio il codice originale in questo:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;
    } else {
        r = mantissa >> exponent;
    }

    return (r ^ sign) + sign;
}

Produce esattamente lo stesso assieme dell'originale fast_trunc_one(). Ti risparmierò l'assemblea, ma è identico - registra i nomi e tutto il resto.


Passaggio 2: riduzione matematica:x + (y ^ x) = y

signpuò assumere solo uno dei due valori 0o 0x80000000.

  • Quando x = 0, quindi, x + (y ^ x) = yvale la pena.
  • L'aggiunta e il xoring di 0x80000000sono gli stessi. Capovolge il bit del segno. Pertanto x + (y ^ x) = yvale anche quando x = 0x80000000.

Pertanto, x + (y ^ x)riduce a y. E il codice semplifica a questo:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent);
    } else {
        r = (mantissa >> exponent);
    }

    return r;
}

Ancora una volta, questo viene compilato nello stesso identico assembly - registra i nomi e tutti.


Questa versione precedente alla fine si riduce a questo:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

che è praticamente esattamente ciò che GCC genera nell'assembly.


Quindi perché il compilatore non si ottimizza fast_trunc_two()sulla stessa cosa?

La parte fondamentale fast_trunc_one()è l' x + (y ^ x) = yottimizzazione. In fast_trunc_two()l' x + (y ^ x)espressione viene suddiviso su ramo.

Sospetto che potrebbe essere abbastanza per confondere GCC per non effettuare questa ottimizzazione. (Dovrebbe sollevare il ^ -signramo e unirlo r + signalla fine.)

Ad esempio, questo produce lo stesso assieme di fast_trunc_one():

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = ((mantissa << -exponent) ^ -sign) + sign;             /* diff */
    } else {
        r = ((mantissa >> exponent) ^ -sign) + sign;              /* diff */
    }

    return r;                                     /* diff */
}

4
Modifica, sembra che abbia risposto alla revisione due. L'attuale revisione ha capovolto i due esempi e ha cambiato un po 'il codice ... questo è confuso.
Mistico il

2
@nightcracker Nessun problema. Ho aggiornato la mia risposta per la sincronizzazione con la versione corrente.
Mistico il

1
@Mysticial: la tua dichiarazione finale non è più vera con la nuova versione, rendendo nulla la tua risposta (non risponde alla domanda più importante, "Perché GCC genera un assemblaggio così radicalmente diverso" .)
orlp

11
Risposta aggiornata di nuovo. Non sono sicuro che sia abbastanza soddisfacente. Ma non credo di poter fare molto meglio senza sapere esattamente come funzionano i passaggi di ottimizzazione GCC pertinenti.
Mistico il

4
@Mysticial: A rigor di termini, a patto che tipo firmato è erroneamente utilizzato in questo codice, praticamente tutte le trasformazioni del compilatore sta facendo qui ci sono i casi in cui è indefinito il comportamento ...
R .. GitHub smettere di aiutare ICE

63

Questa è la natura dei compilatori. Supponendo che prenderanno il percorso più veloce o migliore, è abbastanza falso. Chiunque implichi che non è necessario fare nulla per ottimizzare il codice perché "compilatori moderni" riempiono lo spazio vuoto, fanno il lavoro migliore, rendono il codice più veloce, ecc. In realtà ho visto gcc peggiorare da 3.xa 4.x sul braccio almeno. 4.x potrebbe aver raggiunto la 3.x a questo punto, ma all'inizio ha prodotto un codice più lento. Con la pratica puoi imparare a scrivere il tuo codice in modo che il compilatore non debba lavorare sodo e di conseguenza produca risultati più coerenti e attesi.

Il bug qui è le tue aspettative su ciò che verrà prodotto, non su ciò che è stato effettivamente prodotto. Se si desidera che il compilatore generi lo stesso output, alimentarlo con lo stesso input. Non matematicamente lo stesso, non un po 'lo stesso, ma in realtà lo stesso, nessun percorso diverso, nessuna condivisione o distribuzione delle operazioni da una versione all'altra. Questo è un buon esercizio per capire come scrivere il codice e vedere cosa fanno i compilatori. Non commettere l'errore di presumere che un giorno una versione di gcc per una destinazione del processore ha prodotto un certo risultato che è una regola per tutti i compilatori e tutto il codice. Devi usare molti compilatori e molti target per avere un'idea di ciò che sta succedendo.

gcc è piuttosto brutto, ti invito a guardare dietro il sipario, guardare le viscere di gcc, provare ad aggiungere un obiettivo o modificare qualcosa da te. È a malapena tenuto insieme da nastro isolante e filo da cauzione. Una riga aggiuntiva di codice aggiunta o rimossa in luoghi critici e si sbriciola. Il fatto che abbia prodotto del codice utilizzabile è qualcosa di cui essere contenti, invece di preoccuparsi del perché non ha soddisfatto altre aspettative.

hai visto quali diverse versioni di gcc producono? 3.xe 4.x in particolare 4.5 contro 4.6 contro 4.7, ecc.? e per processori target diversi, x86, arm, mips, ecc. o diversi gusti di x86 se quello è il compilatore nativo che usi, 32 bit contro 64 bit, ecc? E poi llvm (clang) per obiettivi diversi?

Mystical ha svolto un lavoro eccellente nel processo di pensiero richiesto per risolvere il problema dell'analisi / ottimizzazione del codice, aspettandosi che un compilatore si inventasse qualcosa di tutto ciò che non è previsto da nessun "compilatore moderno".

Senza entrare nelle proprietà matematiche, codice di questo modulo

if (exponent < 0) {
  r = mantissa << -exponent;                       /* diff */
} else {
  r = mantissa >> exponent;                        /* diff */
}
return (r ^ -sign) + sign;                           /* diff */

sta per condurre il compilatore su A: implementarlo in quel formato, eseguire if-then-else e poi convergere su codice comune per finire e restituire. oppure B: salva un ramo poiché questa è la coda della funzione. Inoltre, non preoccuparti di usare o salvare r.

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}

Quindi puoi entrare come Mystical ha sottolineato che la variabile del segno scompare tutti insieme per il codice come scritto. Non mi aspetterei che il compilatore vedesse sparire la variabile sign, quindi avresti dovuto farlo da solo e non forzare il compilatore a provare a capirlo.

Questa è l'occasione perfetta per scavare nel codice sorgente di gcc. Sembra che tu abbia trovato un caso in cui l'ottimizzatore ha visto una cosa in un caso e un'altra in un altro caso. Quindi fai il passo successivo e vedi se non riesci a ottenere gcc per vedere quel caso. Ogni ottimizzazione è presente perché alcuni individui o gruppi hanno riconosciuto l'ottimizzazione e l'hanno intenzionalmente messa lì. Perché questa ottimizzazione sia presente e funzioni ogni volta che qualcuno deve metterlo lì (e quindi testarlo, e quindi mantenerlo nel futuro).

Sicuramente non dare per scontato che meno codice sia più veloce e più codice sia più lento, è molto facile creare e trovare esempi di ciò che non è vero. Il più delle volte potrebbe essere il caso che meno codice sia più veloce di più codice. Come ho dimostrato fin dall'inizio, puoi creare più codice per salvare la ramificazione in quel caso o il loop, ecc. E avere il risultato netto essere un codice più veloce.

La linea di fondo è che hai fornito a un compilatore una fonte diversa e ti aspettavi gli stessi risultati. Il problema non è l'output del compilatore ma le aspettative dell'utente. È abbastanza facile dimostrare per un particolare compilatore e processore, l'aggiunta di una riga di codice che rende l'intera funzione notevolmente più lenta. Ad esempio, perché cambiare a = b + 2; a a = b + c + 2; causa _fill_in_the_blank_compiler_name_ genera codice radicalmente diverso e più lento? La risposta ovviamente è che il compilatore ha ricevuto un codice diverso sull'input, quindi è perfettamente valido per il compilatore generare un output diverso. (ancora meglio è quando si scambiano due righe di codice non correlate e si fa cambiare drasticamente l'output) Non vi è alcuna relazione attesa tra la complessità e le dimensioni dell'input con la complessità e le dimensioni dell'output.

for(ra=0;ra<20;ra++) dummy(ra);

Ha prodotto da qualche parte tra 60-100 linee di assemblatore. Ha srotolato il circuito. Non ho contato le linee, se ci pensate, deve aggiungere, copiare il risultato nell'input alla chiamata di funzione, effettuare la chiamata di funzione, minimo tre operazioni. quindi a seconda del target che è probabilmente 60 istruzioni almeno, 80 se quattro per loop, 100 se cinque per loop, ecc.


Perché hai vandalizzato la tua risposta? Anche Oded sembrava non essere d'accordo con la modifica ;-).
Peter - Ripristina Monica il

@ PeterA.Schneider tutte le sue risposte sembrano essere state vandalizzate nella stessa data. Penso che qualcuno con i suoi dati dell'account (rubati?) L'abbia fatto.
trinity420,

23

Mysticial ha già fornito una grande spiegazione, ma ho pensato di aggiungere, FWIW, che non c'è davvero nulla di fondamentale sul perché un compilatore avrebbe fatto l'ottimizzazione per l'uno e non per l'altro.

Il clangcompilatore di LLVM , ad esempio, fornisce lo stesso codice per entrambe le funzioni (ad eccezione del nome della funzione), fornendo:

_fast_trunc_two:                        ## @fast_trunc_one
        movl    %edi, %edx
        andl    $-2147483648, %edx      ## imm = 0xFFFFFFFF80000000
        movl    %edi, %esi
        andl    $8388607, %esi          ## imm = 0x7FFFFF
        orl     $8388608, %esi          ## imm = 0x800000
        shrl    $23, %edi
        movzbl  %dil, %eax
        movl    $150, %ecx
        subl    %eax, %ecx
        js      LBB0_1
        shrl    %cl, %esi
        jmp     LBB0_3
LBB0_1:                                 ## %if.then
        negl    %ecx
        shll    %cl, %esi
LBB0_3:                                 ## %if.end
        movl    %edx, %eax
        negl    %eax
        xorl    %esi, %eax
        addl    %edx, %eax
        ret

Questo codice non è breve come la prima versione di gcc dall'OP, ma non quanto la seconda.

Il codice di un altro compilatore (che non nominerò), compilando per x86_64, produce questo per entrambe le funzioni:

fast_trunc_one:
        movl      %edi, %ecx        
        shrl      $23, %ecx         
        movl      %edi, %eax        
        movzbl    %cl, %edx         
        andl      $8388607, %eax    
        negl      %edx              
        orl       $8388608, %eax    
        addl      $150, %edx        
        movl      %eax, %esi        
        movl      %edx, %ecx        
        andl      $-2147483648, %edi
        negl      %ecx              
        movl      %edi, %r8d        
        shll      %cl, %esi         
        negl      %r8d              
        movl      %edx, %ecx        
        shrl      %cl, %eax         
        testl     %edx, %edx        
        cmovl     %esi, %eax        
        xorl      %r8d, %eax        
        addl      %edi, %eax        
        ret                         

che è affascinante in quanto calcola entrambi i lati ife quindi usa una mossa condizionale alla fine per scegliere quella giusta.

Il compilatore Open64 produce quanto segue:

fast_trunc_one: 
    movl %edi,%r9d                  
    sarl $23,%r9d                   
    movzbl %r9b,%r9d                
    addl $-150,%r9d                 
    movl %edi,%eax                  
    movl %r9d,%r8d                  
    andl $8388607,%eax              
    negl %r8d                       
    orl $8388608,%eax               
    testl %r8d,%r8d                 
    jl .LBB2_fast_trunc_one         
    movl %r8d,%ecx                  
    movl %eax,%edx                  
    sarl %cl,%edx                   
.Lt_0_1538:
    andl $-2147483648,%edi          
    movl %edi,%eax                  
    negl %eax                       
    xorl %edx,%eax                  
    addl %edi,%eax                  
    ret                             
    .p2align 5,,31
.LBB2_fast_trunc_one:
    movl %r9d,%ecx                  
    movl %eax,%edx                  
    shll %cl,%edx                   
    jmp .Lt_0_1538                  

e codice simile, ma non identico, per fast_trunc_two.

Comunque, quando si tratta di ottimizzazione, è una lotteria - è quello che è ... Non è sempre facile sapere perché il codice viene compilato in un modo particolare.


10
Il compilatore che non nominerai un supercompiler top-secret?
orlp,

4
il compilatore Top Secret è probabilmente Intel icc. Ho solo la variante a 32 bit ma produce un codice molto simile a questo.
Janus Troelsen,

5
Credo anche che sia ICC. Il compilatore sa che il processore è in grado di parallelismo a livello di istruzione e quindi entrambi i rami possono essere calcolati contemporaneamente. Il sovraccarico del movimento condizionale è molto più basso del sovraccarico della previsione di falsi rami.
Filip Navara,
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.