En écrivant une ftol
fonction 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.c
ceci 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_one
c'est environ 30% plus rapide que fast_trunc_two
. Maintenant ma question: qu'est-ce qui cause cela?
-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.int
enunsigned int
et voyez si la différence disparaît.(r + shifted) ^ sign
n'est pas la même quer + (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/2430454Réponses:
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: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
xor
s dans l'assemblage pourfast_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
sign
variable. Depuissign = i & 0x80000000;
, il n'y a que deux valeurs possibles quisign
peuvent 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: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
sign
ne peut prendre qu'une des deux valeurs,0
ou0x80000000
.x = 0
, alors c'estx + (y ^ x) = y
trivial.0x80000000
sont identiques. Il retourne le bit de signe. C'est doncx + (y ^ x) = y
aussi vrai quandx = 0x80000000
.Par conséquent,
x + (y ^ x)
réduit ày
. Et le code se simplifie à ceci:Encore une fois, cela compile exactement le même assembly - noms de registre et tout.
Cette version ci-dessus se réduit finalement à ceci:
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) = y
optimisation. Dansfast_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
^ -sign
hors de la branche et le fusionner dans ler + sign
à la fin.)Par exemple, cela produit le même assemblage que
fast_trunc_one()
:la source
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
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.
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.
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.
la source
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
clang
compilateur de LLVM , par exemple, donne le même code pour les deux fonctions (sauf pour le nom de la fonction), donnant: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:
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:
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.
la source
icc
. Je n'ai que la variante 32 bits mais elle produit un code très similaire à celui-ci.