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?
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 xor
s dans l'assembly pour fast_trunc_one()
. C'est ce qui me l'a donné.
É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
.
x = 0
, Alors x + (y ^ x) = y
, puis trivial est valable.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 */
}
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.
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.