Warum generiert GCC eine so radikal unterschiedliche Baugruppe für nahezu denselben C-Code?

184

Beim Schreiben einer optimierten ftolFunktion habe ich ein sehr merkwürdiges Verhalten festgestellt GCC 4.6.1. Lassen Sie mich Ihnen zuerst den Code zeigen (aus Gründen der Klarheit habe ich die Unterschiede markiert):

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 */
}

Scheint das gleiche richtig? Nun, GCC ist anderer Meinung. Nach dem Kompilieren ist gcc -O3 -S -Wall -o test.s test.cdies die Assembly-Ausgabe:

fast_trunc_one, generiert:

_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, generiert:

_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

Das ist ein extremer Unterschied. Dies zeigt sich auch im Profil, fast_trunc_oneist rund 30% schneller als fast_trunc_two. Nun meine Frage: Was verursacht das?

orlp
quelle
1
Zu Testzwecken habe ich hier eine Übersicht erstellt , in der Sie die Quelle einfach kopieren / einfügen und prüfen können, ob Sie den Fehler auf anderen Systemen / Versionen von GCC reproduzieren können.
Orlp
12
Legen Sie die Testfälle in einem eigenen Verzeichnis ab. Kompiliere sie mit -S -O3 -da -fdump-tree-all. Dadurch werden viele Schnappschüsse der Zwischendarstellung erstellt. Gehen Sie sie nebeneinander durch (sie sind nummeriert), und Sie sollten im ersten Fall in der Lage sein, die fehlende Optimierung zu finden.
zwol
1
Vorschlag zwei: Ändern Sie alle intin unsigned intund prüfen Sie, ob der Unterschied verschwindet.
zwol
5
Die beiden Funktionen scheinen etwas anders zu rechnen. Während die Ergebnisse möglicherweise dieselben sind, ist der Ausdruck (r + shifted) ^ signnicht derselbe wie r + (shifted ^ sign). Ich denke, das verwirrt den Optimierer? FWIW, MSVC 2010 (16.00.40219.01) erstellt Einträge, die fast identisch sind: gist.github.com/2430454
DCoder
1
@ DCoder: Oh verdammt! Das habe ich nicht bemerkt. Es ist jedoch nicht die Erklärung für den Unterschied. Lassen Sie mich die Frage mit einer neuen Version aktualisieren, bei der dies ausgeschlossen ist.
Orlp

Antworten:

256

Aktualisiert, um mit der Bearbeitung des OP zu synchronisieren

Durch das Basteln mit dem Code konnte ich sehen, wie GCC den ersten Fall optimiert.

Bevor wir verstehen können, warum sie so unterschiedlich sind, müssen wir zuerst verstehen, wie GCC optimiert fast_trunc_one().

Ob Sie es glauben oder nicht, fast_trunc_one()wird darauf optimiert:

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 */
    }
}

Dies ergibt genau die gleiche Baugruppe wie die Originalregisternamen fast_trunc_one()und alles.

Beachten Sie, dass xordie Assembly für keine s enthält fast_trunc_one(). Das hat es für mich verraten.


Wie?


Schritt 1: sign = -sign

Schauen wir uns zunächst die signVariable an. Da sign = i & 0x80000000;gibt es nur zwei mögliche Werte, signdie annehmen können:

  • sign = 0
  • sign = 0x80000000

Erkennen Sie nun, dass in beiden Fällen , sign == -sign. Wenn ich also den Originalcode in folgenden Code ändere:

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;
}

Es wird genau die gleiche Baugruppe wie das Original hergestellt fast_trunc_one(). Ich werde Ihnen die Baugruppe ersparen, aber sie ist identisch - Registernamen und alles.


Schritt 2: Mathematische Reduktion:x + (y ^ x) = y

signkann nur einen von zwei Werten annehmen, 0oder 0x80000000.

  • Wann x = 0, dann x + (y ^ x) = ygilt Triviales.
  • Das Hinzufügen und Xoring von 0x80000000ist dasselbe. Es dreht das Vorzeichenbit um. Daher x + (y ^ x) = ygilt auch wann x = 0x80000000.

Daher x + (y ^ x)reduziert sich auf y. Und der Code vereinfacht dies:

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;
}

Auch dies wird zu genau derselben Assembly kompiliert - Registernamen und alle.


Diese obige Version reduziert sich schließlich auf Folgendes:

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 */
    }
}

Das ist ziemlich genau das, was GCC in der Assembly generiert.


Warum optimiert der Compiler nicht fast_trunc_two()auf dasselbe?

Der Schlüssel dazu fast_trunc_one()ist die x + (y ^ x) = yOptimierung. In fast_trunc_two()dem x + (y ^ x)Ausdruck wird über den Zweig geteilt.

Ich vermute, dass dies ausreichen könnte, um GCC zu verwirren, diese Optimierung nicht vorzunehmen. (Es müsste das ^ -signaus dem Ast herausheben und r + signam Ende in das zusammenführen.)

Dies erzeugt beispielsweise dieselbe Baugruppe wie 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 */
}
Mystisch
quelle
4
Bearbeiten, es sieht so aus, als hätte ich Revision zwei beantwortet. Die aktuelle Version hat die beiden Beispiele umgedreht und den Code ein wenig geändert ... das ist verwirrend.
Mysticial
2
@nightcracker Keine Sorge. Ich habe meine Antwort aktualisiert, um sie mit der aktuellen Version zu synchronisieren.
Mysticial
1
@Mysticial: Ihre endgültige Aussage ist mit der neuen Version nicht mehr wahr, wodurch Ihre Antwort ungültig wird (sie beantwortet nicht die wichtigste Frage: "Warum generiert GCC eine so radikal andere Baugruppe
?
11
Antwort erneut aktualisiert. Ich bin mir nicht sicher, ob es befriedigend genug ist. Aber ich glaube nicht, dass ich es besser machen kann, ohne genau zu wissen, wie die relevanten GCC-Optimierungsdurchläufe funktionieren.
Mysticial
4
@Mysticial: Genau genommen, solange der signierte Typ in diesem Code falsch verwendet wird, sind so ziemlich alle Transformationen, die der Compiler hier
vornimmt,
63

Dies ist die Natur von Compilern. Die Annahme, dass sie den schnellsten oder besten Weg gehen, ist ziemlich falsch. Jeder, der impliziert, dass Sie nichts an Ihrem Code tun müssen, um ihn zu optimieren, weil "moderne Compiler" die Lücke ausfüllen, den besten Job machen, den schnellsten Code erstellen usw. Eigentlich habe ich gesehen, dass gcc von 3.x auf schlechter wird 4.x mindestens am Arm. 4.x hat zu diesem Zeitpunkt möglicherweise bis zu 3.x eingeholt, aber schon früh wurde langsamerer Code erzeugt. Mit etwas Übung können Sie lernen, wie Sie Ihren Code schreiben, damit der Compiler nicht so hart arbeiten muss und dadurch konsistentere und erwartete Ergebnisse erzielt.

Der Fehler hier sind Ihre Erwartungen an das, was produziert wird, nicht an das, was tatsächlich produziert wurde. Wenn Sie möchten, dass der Compiler dieselbe Ausgabe generiert, geben Sie dieselbe Eingabe ein. Nicht mathematisch gleich, nicht irgendwie gleich, aber tatsächlich gleich, keine unterschiedlichen Pfade, keine gemeinsamen oder verteilten Operationen von einer Version zur anderen. Dies ist eine gute Übung, um zu verstehen, wie Sie Ihren Code schreiben und was Compiler damit machen. Machen Sie nicht den Fehler anzunehmen, dass eine Version von gcc für ein Prozessorziel eines Tages ein bestimmtes Ergebnis liefert, dass dies eine Regel für alle Compiler und den gesamten Code ist. Sie müssen viele Compiler und viele Ziele verwenden, um ein Gefühl dafür zu bekommen, was los ist.

gcc ist ziemlich böse, ich lade Sie ein, hinter den Vorhang zu schauen, sich die Eingeweide von gcc anzusehen, zu versuchen, ein Ziel hinzuzufügen oder selbst etwas zu modifizieren. Es wird kaum durch Klebeband und Draht zusammengehalten. Eine zusätzliche Codezeile, die an kritischen Stellen hinzugefügt oder entfernt wird und zusammenbricht. Die Tatsache, dass überhaupt brauchbarer Code erzeugt wurde, ist etwas, über das man sich freuen kann, anstatt sich Gedanken darüber zu machen, warum er andere Erwartungen nicht erfüllt hat.

Hast du dir angesehen, welche verschiedenen Versionen von gcc produzieren? 3.x und 4.x insbesondere 4.5 vs 4.6 vs 4.7, etc? und für verschiedene Zielprozessoren, x86, Arm, Mips usw. oder verschiedene Varianten von x86, wenn dies der native Compiler ist, den Sie verwenden, 32-Bit vs 64-Bit usw.? Und dann llvm (Klirren) für verschiedene Ziele?

Mystical hat in dem Denkprozess, der erforderlich ist, um das Problem der Analyse / Optimierung des Codes zu lösen, hervorragende Arbeit geleistet und erwartet, dass ein Compiler irgendetwas davon entwickelt, was von keinem "modernen Compiler" erwartet wird.

Code dieser Form, ohne auf die mathematischen Eigenschaften einzugehen

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

führt den Compiler zu A: Implementieren Sie ihn in dieser Form, führen Sie das Wenn-Dann-Sonst aus und konvergieren Sie dann auf gemeinsamem Code, um den Vorgang abzuschließen und zurückzukehren. oder B: Speichern Sie einen Zweig, da dies das Ende der Funktion ist. Auch nicht mit r verwenden oder speichern.

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

Dann können Sie darauf eingehen, wie Mystical hervorhob, dass die Vorzeichenvariable für den geschriebenen Code alle zusammen verschwindet. Ich würde nicht erwarten, dass der Compiler sieht, dass die Vorzeichenvariable verschwindet, also hätten Sie das selbst tun sollen und den Compiler nicht gezwungen, es herauszufinden.

Dies ist eine perfekte Gelegenheit, um in den gcc-Quellcode einzutauchen. Anscheinend haben Sie einen Fall gefunden, in dem der Optimierer in einem Fall eine Sache gesehen hat, in einem anderen Fall eine andere. Machen Sie dann den nächsten Schritt und prüfen Sie, ob Sie gcc nicht dazu bringen können, diesen Fall zu sehen. Jede Optimierung ist da, weil eine Einzelperson oder Gruppe die Optimierung erkannt und absichtlich dort platziert hat. Damit diese Optimierung vorhanden ist und jedes Mal funktioniert, wenn jemand sie dort ablegen muss (und sie dann testen und dann in der Zukunft warten muss).

Gehen Sie auf keinen Fall davon aus, dass weniger Code schneller und mehr Code langsamer ist. Es ist sehr einfach, Beispiele dafür zu erstellen und zu finden, die nicht zutreffen. Es kann häufig vorkommen, dass weniger Code schneller ist als mehr Code. Wie ich von Anfang an gezeigt habe, können Sie jedoch mehr Code erstellen, um in diesem Fall Verzweigungen oder Schleifen usw. zu speichern, und das Nettoergebnis ist schnellerer Code.

Unter dem Strich haben Sie einem Compiler eine andere Quelle zugeführt und die gleichen Ergebnisse erwartet. Das Problem ist nicht die Compilerausgabe, sondern die Erwartungen des Benutzers. Für einen bestimmten Compiler und Prozessor ist es ziemlich einfach, das Hinzufügen einer Codezeile zu demonstrieren, wodurch eine ganze Funktion erheblich langsamer wird. Zum Beispiel, warum ändert sich a = b + 2; zu a = b + c + 2; Ursache _fill_in_the_blank_compiler_name_ radikal anderen und langsameren Code generieren? Die Antwort war natürlich, dass dem Compiler ein anderer Code für die Eingabe zugeführt wurde, sodass es für den Compiler vollkommen gültig ist, unterschiedliche Ausgaben zu generieren. (Noch besser ist es, wenn Sie zwei nicht zusammenhängende Codezeilen austauschen und die Ausgabe dramatisch ändern.) Es gibt keine erwartete Beziehung zwischen der Komplexität und Größe der Eingabe und der Komplexität und Größe der Ausgabe.

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

Es produzierte irgendwo zwischen 60-100 Assembler-Linien. Es rollte die Schleife ab. Ich habe die Zeilen nicht gezählt, wenn Sie darüber nachdenken, muss es hinzufügen, das Ergebnis in die Eingabe des Funktionsaufrufs kopieren, den Funktionsaufruf ausführen, mindestens drei Operationen. Abhängig vom Ziel sind dies wahrscheinlich mindestens 60 Anweisungen, 80, wenn vier pro Schleife, 100, wenn fünf pro Schleife usw.

Oldtimer
quelle
Warum hast du deine Antwort zerstört? Oded schien auch mit der Bearbeitung nicht einverstanden zu sein ;-).
Peter - Monica
@ PeterA.Schneider Alle seine Antworten scheinen am selben Tag zerstört worden zu sein. Ich denke, jemand mit seinen (gestohlenen?) Kontodaten hat es getan.
Trinity420
23

Mysticial hat bereits eine großartige Erklärung gegeben, aber ich dachte, ich würde hinzufügen, FWIW, dass es wirklich nichts Grundlegendes gibt, warum ein Compiler die Optimierung für das eine und nicht für das andere vornehmen würde.

Der clangCompiler von LLVM gibt beispielsweise für beide Funktionen denselben Code an (mit Ausnahme des Funktionsnamens) und gibt Folgendes an:

_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

Dieser Code ist nicht so kurz wie die erste gcc-Version aus dem OP, aber nicht so lang wie die zweite.

Code von einem anderen Compiler (den ich nicht nennen werde), der für x86_64 kompiliert wird, erzeugt dies für beide Funktionen:

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                         

Das ist insofern faszinierend, als es beide Seiten des berechnet ifund dann am Ende einen bedingten Zug verwendet, um den richtigen auszuwählen.

Der Open64-Compiler erzeugt Folgendes:

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                  

und ähnlicher, aber nicht identischer Code für fast_trunc_two.

Wenn es um Optimierung geht, ist es eine Lotterie - es ist, was es ist ... Es ist nicht immer einfach zu wissen, warum Ihr Code auf eine bestimmte Weise kompiliert wird.

Charphacy
quelle
10
Ist der Compiler, den Sie nicht als streng geheimen Supercompiler bezeichnen?
Orlp
4
Der streng geheime Compiler ist wahrscheinlich Intel icc. Ich habe nur die 32-Bit-Variante, aber sie erzeugt Code, der diesem sehr ähnlich ist.
Janus Troelsen
5
Ich glaube auch, dass es ICC ist. Der Compiler weiß, dass der Prozessor zur Parallelität auf Befehlsebene fähig ist und somit beide Zweige gleichzeitig berechnet werden können. Der Overhead der bedingten Bewegung ist viel geringer als der Overhead der Vorhersage falscher Zweige.
Filip Navara