Pourquoi GCC génère-t-il un assemblage si radicalement différent pour presque le même code C?

184

En écrivant une ftolfonction optimisée , j'ai trouvé un comportement très étrange dans GCC 4.6.1. Permettez-moi de vous montrer d'abord le code (pour plus de clarté, j'ai marqué les différences):

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

Semble la même chose non? Eh bien, GCC n'est pas d'accord. Après la compilation avec gcc -O3 -S -Wall -o test.s test.cceci est la sortie de l'assembly:

fast_trunc_one, généré:

_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, généré:

_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

C'est une différence extrême . Cela apparaît également sur le profil, fast_trunc_onec'est environ 30% plus rapide que fast_trunc_two. Maintenant ma question: qu'est-ce qui cause cela?

orlp
la source
1
À des fins de test, j'ai créé un résumé ici où vous pouvez facilement copier / coller la source et voir si vous pouvez reproduire le bogue sur d'autres systèmes / versions de GCC.
orlp
12
Placez les cas de test dans leur propre répertoire. Compilez-les avec -S -O3 -da -fdump-tree-all. Cela créera de nombreux instantanés de la représentation intermédiaire. Parcourez-les (ils sont numérotés) côte à côte et vous devriez pouvoir trouver l'optimisation manquante dans le premier cas.
zwol
1
Suggestion deux: changez tout inten unsigned intet voyez si la différence disparaît.
zwol
5
Les deux fonctions semblent faire des calculs légèrement différents. Bien que les résultats puissent être identiques, l'expression (r + shifted) ^ signn'est pas la même que r + (shifted ^ sign). Je suppose que cela déroute l'optimiseur? FWIW, MSVC 2010 (16.00.40219.01) produit des listes qui sont presque identiques les unes aux autres: gist.github.com/2430454
DCoder
1
@DCoder: Oh putain! Je n'ai pas remarqué cela. Ce n'est cependant pas l'explication de la différence. Permettez-moi de mettre à jour la question avec une nouvelle version où cela est exclu.
orlp

Réponses:

256

Mis à jour pour se synchroniser avec l'édition du PO

En bricolant le code, j'ai réussi à voir comment GCC optimise le premier cas.

Avant de pouvoir comprendre pourquoi ils sont si différents, nous devons d'abord comprendre comment GCC optimise fast_trunc_one().

Croyez-le ou non, fast_trunc_one()est optimisé pour cela:

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

Cela produit exactement le même assemblage que les fast_trunc_one()noms de registre d' origine et tout.

Notez qu'il n'y a pas de xors dans l'assemblage pour fast_trunc_one(). C'est ce qui me l'a révélé.


Comment?


Étape 1: sign = -sign

Jetons d'abord un coup d'œil à la signvariable. Depuis sign = i & 0x80000000;, il n'y a que deux valeurs possibles qui signpeuvent prendre:

  • sign = 0
  • sign = 0x80000000

Maintenant reconnaître que dans les deux cas, sign == -sign. Par conséquent, lorsque je change le code d'origine en ceci:

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

Il produit exactement le même assemblage que l'original fast_trunc_one(). Je vous épargnerai l'assemblage, mais c'est identique - les noms de registre et tout.


Étape 2: Réduction mathématique:x + (y ^ x) = y

signne peut prendre qu'une des deux valeurs, 0ou 0x80000000.

  • Quand x = 0, alors c'est x + (y ^ x) = ytrivial.
  • L'ajout et le xoring 0x80000000sont identiques. Il retourne le bit de signe. C'est donc x + (y ^ x) = yaussi vrai quand x = 0x80000000.

Par conséquent, x + (y ^ x)réduit à y. Et le code se simplifie à ceci:

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

Encore une fois, cela compile exactement le même assembly - noms de registre et tout.


Cette version ci-dessus se réduit finalement à ceci:

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

ce qui est à peu près exactement ce que GCC génère dans l'assemblage.


Alors pourquoi le compilateur n'optimise-t-il pas fast_trunc_two() pas la même chose?

La partie clé fast_trunc_one()est l' x + (y ^ x) = yoptimisation. Dans fast_trunc_two()lex + (y ^ x) expression est divisée à travers la branche.

Je soupçonne que cela pourrait suffire à confondre GCC pour ne pas faire cette optimisation. (Il faudrait soulever le ^ -signhors de la branche et le fusionner dans ler + sign à la fin.)

Par exemple, cela produit le même assemblage que 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 */
}
Mysticial
la source
4
Edit, il semble que j'ai répondu à la deuxième révision. La révision actuelle a inversé les deux exemples et a changé un peu le code ... c'est déroutant.
Mysticial
2
@nightcracker Pas de soucis. J'ai mis à jour ma réponse pour la synchroniser avec la version actuelle.
Mysticial
1
@Mysticial: votre déclaration finale n'est plus vraie avec la nouvelle version, rendant votre réponse nulle (elle ne répond pas à la question la plus importante, "Pourquoi GCC génère-t-il un assemblage aussi radicalement différent" .)
orlp
11
Réponse mise à jour à nouveau. Je ne sais pas si c'est assez satisfaisant. Mais je ne pense pas que je puisse faire beaucoup mieux sans savoir exactement comment fonctionne l'optimisation GCC pertinente.
Mysticial
4
@Mysticial: Strictement parlant, tant que le type signé est mal utilisé dans ce code, à peu près toutes les transformations que le compilateur effectue ici sont dans les cas où le comportement est indéfini ...
R .. GitHub STOP AIDING ICE
63

C'est la nature des compilateurs. Il est tout à fait faux de supposer qu'ils emprunteront le chemin le plus rapide ou le meilleur. Quiconque implique que vous n'avez rien à faire pour optimiser votre code parce que les "compilateurs modernes" remplissent le vide, font le meilleur travail, créent le code le plus rapide, etc. En fait, j'ai vu gcc empirer de 3.x à 4.x sur le bras au moins. 4.x a peut-être rattrapé 3.x à ce stade, mais au début, il a produit un code plus lent. Avec de la pratique, vous pouvez apprendre à écrire votre code afin que le compilateur n'ait pas à travailler aussi dur et par conséquent produit des résultats plus cohérents et attendus.

Le bug ici est vos attentes de ce qui sera produit, pas ce qui a été réellement produit. Si vous souhaitez que le compilateur génère la même sortie, fournissez-lui la même entrée. Pas mathématiquement le même, pas un peu le même, mais en fait le même, pas de chemins différents, pas de partage ou de distribution d'opérations d'une version à l'autre. C'est un bon exercice pour comprendre comment écrire votre code et voir ce que les compilateurs en font. Ne faites pas l'erreur de supposer que parce qu'une version de gcc pour une cible de processeur a produit un jour un certain résultat que c'est une règle pour tous les compilateurs et tout le code. Vous devez utiliser de nombreux compilateurs et de nombreuses cibles pour avoir une idée de ce qui se passe.

gcc est assez méchant, je vous invite à regarder derrière le rideau, à regarder les tripes de gcc, à essayer d'ajouter une cible ou à modifier quelque chose vous-même. Il est à peine maintenu par du ruban adhésif et du fil de sauvetage. Une ligne de code supplémentaire ajoutée ou supprimée aux endroits critiques et elle s'effondre. Le fait qu'il ait produit du code utilisable est quelque chose dont il faut se réjouir, au lieu de s'inquiéter de savoir pourquoi il n'a pas répondu à d'autres attentes.

avez-vous regardé ce que les différentes versions de gcc produisent? 3.x et 4.x en particulier 4,5 vs 4,6 vs 4,7, etc.? et pour différents processeurs cibles, x86, arm, mips, etc. ou différentes saveurs de x86 si c'est le compilateur natif que vous utilisez, 32 bits contre 64 bits, etc.? Et puis llvm (clang) pour différentes cibles?

Mystical a fait un excellent travail dans le processus de réflexion nécessaire pour résoudre le problème de l'analyse / l'optimisation du code, en s'attendant à ce qu'un compilateur propose tout cela n'est, bien, pas attendu de tout "compilateur moderne".

Sans entrer dans les propriétés mathématiques, le code de cette forme

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

va conduire le compilateur vers A: implémentez-le sous cette forme, effectuez le if-then-else puis convergez vers le code commun pour finir et retourner. ou B: enregistre une branche car c'est la fin de la fonction. Aussi pas la peine d'utiliser ou d'enregistrer r.

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

Ensuite, vous pouvez entrer comme Mystical l'a souligné, la variable de signe disparaît tous ensemble pour le code tel qu'il est écrit. Je ne m'attendrais pas à ce que le compilateur voie la variable sign disparaître, vous auriez donc dû le faire vous-même et ne pas forcer le compilateur à essayer de le comprendre.

C'est une occasion parfaite pour fouiller dans le code source de gcc. Il semble que vous ayez trouvé un cas où l'optimiseur a vu une chose dans un cas puis une autre chose dans un autre cas. Ensuite, passez à l'étape suivante et voyez si vous ne pouvez pas obtenir gcc pour voir ce cas. Chaque optimisation est là parce qu'une personne ou un groupe a reconnu l'optimisation et l'a mise intentionnellement. Pour que cette optimisation soit là et fonctionne chaque fois que quelqu'un doit la mettre là-bas (puis la tester, puis la maintenir dans le futur).

Ne supposez certainement pas que moins de code est plus rapide et plus de code est plus lent, il est très facile de créer et de trouver des exemples de ce qui n'est pas vrai. Il se peut que le plus souvent, moins de code soit plus rapide que plus de code. Comme je l'ai démontré depuis le début, vous pouvez créer plus de code pour enregistrer le branchement dans ce cas ou la boucle, etc. et avoir le résultat net comme un code plus rapide.

En bout de ligne, vous avez alimenté une source différente du compilateur et vous attendiez les mêmes résultats. Le problème n'est pas la sortie du compilateur mais les attentes de l'utilisateur. Il est assez facile de démontrer, pour un compilateur et un processeur particuliers, l'ajout d'une ligne de code qui ralentit considérablement toute une fonction. Par exemple, pourquoi changer a = b + 2; à a = b + c + 2; cause _fill_in_the_blank_compiler_name_ générer un code radicalement différent et plus lent? La réponse est bien sûr que le compilateur a reçu un code différent sur l'entrée, il est donc parfaitement valide pour le compilateur de générer une sortie différente. (encore mieux lorsque vous permutez deux lignes de code indépendantes et que la sortie change radicalement) Il n'y a pas de relation attendue entre la complexité et la taille de l'entrée et la complexité et la taille de la sortie.

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

Il a produit entre 60 et 100 lignes d'assembleur. Il a déroulé la boucle. Je n'ai pas compté les lignes, si vous y réfléchissez, il faut ajouter, copier le résultat à l'entrée de l'appel de fonction, faire l'appel de fonction, trois opérations minimum. donc selon la cible c'est probablement 60 instructions au moins, 80 si quatre par boucle, 100 si cinq par boucle, etc.

old_timer
la source
Pourquoi avez-vous vandalisé votre réponse? Oded semblait également en désaccord avec la modification ;-).
Peter - Réintègre Monica
@ PeterA.Schneider toutes ses réponses semblent avoir été vandalisées à la même date. Je pense que quelqu'un avec ses données de compte (volées?) L'a fait.
trinity420
23

Mysticial a déjà donné une bonne explication, mais j'ai pensé ajouter, FWIW, qu'il n'y a vraiment rien de fondamental à savoir pourquoi un compilateur ferait l'optimisation pour l'un et pas pour l'autre.

Le clangcompilateur de LLVM , par exemple, donne le même code pour les deux fonctions (sauf pour le nom de la fonction), donnant:

_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

Ce code n'est pas aussi court que la première version de gcc de l'OP, mais pas aussi long que la seconde.

Le code d'un autre compilateur (que je ne nommerai pas), compilé pour x86_64, produit ceci pour les deux fonctions:

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                         

ce qui est fascinant en ce qu'il calcule les deux côtés du if et utilise ensuite un mouvement conditionnel à la fin pour choisir le bon.

Le compilateur Open64 produit les éléments suivants:

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                  

et un code similaire, mais non identique, pour fast_trunc_two.

Quoi qu'il en soit, quand il s'agit d'optimisation, c'est une loterie - c'est ce que c'est ... Il n'est pas toujours facile de savoir pourquoi votre code est compilé d'une manière particulière.

Charphacy
la source
10
Est-ce que vous ne nommerez pas le compilateur un supercompilateur top secret?
orlp
4
le compilateur Top Secret est probablement Intel icc. Je n'ai que la variante 32 bits mais elle produit un code très similaire à celui-ci.
Janus Troelsen
5
Je crois aussi que c'est ICC. Le compilateur sait que le processeur est capable de parallélisme de niveau d'instruction et ainsi les deux branches peuvent être calculées simultanément. La surcharge du déplacement conditionnel est bien inférieure à celle de la fausse prédiction de branche.
Filip Navara