web-dev-qa-db-fra.com

Pourquoi GCC génère-t-il un assemblage radicalement différent pour à peu près le même code C?

Lors de l'écriture d'une fonction optimisée ftol, j'ai trouvé un comportement très étrange dans GCC 4.6.1. Laissez-moi d'abord vous montrer 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 le même droit? GCC n'est pas d'accord. Après avoir compilé avec gcc -O3 -S -Wall -o test.s test.c ceci est la sortie de l’Assemblée:

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_one est environ 30% plus rapide que fast_trunc_two. Maintenant ma question: qu'est-ce qui cause ceci?

182
orlp

Mise à jour pour la synchronisation 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 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 */
    }
}

Cela produit exactement le même assemblage que l'original fast_trunc_one() - noms de registres et tout le reste.

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


Comment?


Étape 1:sign = -sign

Tout d’abord, examinons la variable sign. Depuis sign = i & 0x80000000;, Il n'y a que deux valeurs possibles que sign peut prendre:

  • sign = 0
  • sign = 0x80000000

Reconnaissez maintenant que dans les deux cas, sign == -sign. Par conséquent, lorsque je remplace le code d'origine par 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’Assemblée, mais c’est la même chose - enregistrer les noms et tout.


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

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

  • Quand x = 0, Alors x + (y ^ x) = y, puis trivial est valable.
  • Ajouter et valider avec 0x80000000 Est la même chose. Il retourne le bit de signe. Par conséquent, x + (y ^ x) = y est également valable lorsque x = 0x80000000.

Par conséquent, x + (y ^ x) se réduit à y. Et le code 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 à la même assemblée - 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 à l'Assemblée.


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

L'élément clé dans fast_trunc_one() est l'optimisation x + (y ^ x) = y. Dans fast_trunc_two(), l'expression x + (y ^ x) est en cours de division dans la branche.

Je suppose que cela pourrait être suffisant pour confondre GCC afin de ne pas effectuer cette optimisation. (Il serait nécessaire de hisser le ^ -sign De la branche et de le fusionner dans le r + 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 */
}
254
Mysticial

C'est la nature des compilateurs. En supposant qu'ils empruntent le chemin le plus rapide ou le meilleur, c'est tout à fait faux. Toute personne qui implique que vous n'avez rien à faire pour optimiser votre code, car les "compilateurs modernes" remplissent les blancs, fait le meilleur travail possible, crée 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 aurait peut-être rattrapé 3.x à ce stade, mais au début, il produisait du code plus lent. Avec de la pratique, vous pouvez apprendre à écrire votre code afin que le compilateur n’ait pas à travailler aussi dur et qu’il produise des résultats plus cohérents et attendus.

Le bogue ici est ce que vous attendez de ce qui sera produit, pas de ce qui a été réellement produit. Si vous voulez que le compilateur génère la même sortie, alimentez-le avec la même entrée. Pas mathématiquement les mêmes, pas les mêmes, mais en fait les mêmes, pas de chemins différents, pas de partage ni de distribution des 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 commettez pas l'erreur de supposer cela, car une version de gcc pour une cible de processeur un jour a donné un certain résultat, à savoir qu'il s'agit d'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 entrailles de gcc, à essayer d’ajouter une cible ou à modifier quelque chose vous-même. Il est à peine maintenu ensemble par du ruban adhésif Une ligne supplémentaire de code ajoutée ou supprimée dans des endroits critiques et qui s'effondre. Le fait qu’il produise du code utilisable est une source de satisfaction, au lieu de s’inquiéter des raisons pour lesquelles il n’a pas répondu aux autres attentes.

avez-vous regardé ce que 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 versions de x86 si c'est le compilateur natif que vous utilisez, 32 bits par rapport à 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/optimisation du code, en s’attendant à ce qu’un compilateur en présente une, c'est-à-dire qu’il n’est pas attendu d’aucun "compilateur moderne".

Sans entrer dans les propriétés mathématiques, 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 à A: implémentez-le sous cette forme, effectuez la convergence si-alors-sinon, puis convergez vers le code commun pour terminer et retourner. ou B: sauvegarder une branche puisqu'il s'agit de la fin de la fonction. Aussi, ne vous embêtez pas avec l'utilisation ou la sauvegarde de r.

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

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

C’est l’occasion parfaite de creuser 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 dans un autre cas. Ensuite, passez à l’étape suivante et voyez si vous ne pouvez pas obtenir gcc pour voir ce cas. Chaque optimisation existe parce qu’un individu ou un groupe a reconnu l’optimisation et l’a intentionnellement mise là. Pour que cette optimisation soit présente et fonctionne à chaque fois, il faut que quelqu'un la pose (puis la teste et la conserve dans le futur).

Ne supposez certainement pas que moins de code est rapide et que plus de code est lent, il est très facile de créer et de trouver des exemples de ce qui n'est pas vrai. Cela peut être le plus souvent le cas où moins de code est 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 les branches dans ce cas ou les boucles, etc., et obtenir un code plus rapide.

En bout de ligne, vous avez alimenté une source différente du compilateur et vous attendez 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 change-t-on 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 étant 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. (C'est encore mieux lorsque vous permutez deux lignes de code non liées et que vous modifiez considérablement la sortie.) 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. Nourris quelque chose comme ça dans Clang:

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 dans l'entrée de l'appel de fonction, effectuer l'appel de fonction, trois opérations minimum. donc, en fonction de la cible, 60 instructions au moins, 80 si quatre par boucle, 100 si cinq par boucle, etc.

64
old_timer

Mysticial a déjà donné une excellente explication, mais je pensais ajouter, FWIW, qu’il n’existe vraiment rien de fondamental sur la raison pour laquelle un compilateur effectuerait l’optimisation pour l’un et pas l’autre.

Le compilateur clang de LLVM, par exemple, donne le même code pour les deux fonctions (à l'exception du 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 dans le sens où il calcule les deux côtés du if puis utilise un coup 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 similaire, mais pas identique, code pour fast_trunc_two.

Quoi qu’il en soit, en matière 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é de manière particulière.

22
Charphacy