Voici deux fonctions qui, selon moi, font exactement la même chose:
bool fast(int x)
{
return x & 4242;
}
bool slow(int x)
{
return x && (x & 4242);
}
Logiquement, ils font la même chose, et juste pour être sûr à 100%, j'ai écrit un test qui a exécuté les quatre milliards d'entrées possibles sur les deux, et ils correspondaient. Mais le code d'assemblage est une autre histoire:
fast:
andl $4242, %edi
setne %al
ret
slow:
xorl %eax, %eax
testl %edi, %edi
je .L3
andl $4242, %edi
setne %al
.L3:
rep
ret
J'ai été surpris que GCC ne puisse pas faire le saut de la logique pour éliminer le test redondant. J'ai essayé g ++ 4.4.3 et 4.7.2 avec -O2, -O3 et -Os, qui ont tous généré le même code. La plate-forme est Linux x86_64.
Quelqu'un peut-il expliquer pourquoi GCC ne devrait pas être suffisamment intelligent pour générer le même code dans les deux cas? J'aimerais aussi savoir si d'autres compilateurs peuvent faire mieux.
Modifier pour ajouter un faisceau de test:
#include <cstdlib>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
// make vector filled with numbers starting from argv[1]
int seed = atoi(argv[1]);
vector<int> v(100000);
for (int j = 0; j < 100000; ++j)
v[j] = j + seed;
// count how many times the function returns true
int result = 0;
for (int j = 0; j < 100000; ++j)
for (int i : v)
result += slow(i); // or fast(i), try both
return result;
}
J'ai testé ce qui précède avec clang 5.1 sur Mac OS avec -O3. Cela a pris 2,9 secondes en utilisant fast()
et 3,8 secondes en utilisant slow()
. Si j'utilise à la place un vecteur de tous les zéros, il n'y a pas de différence significative de performances entre les deux fonctions.
Vous avez raison de penser que cela semble être une déficience, et peut-être un bogue pur et simple, dans l'optimiseur.
Considérer:
bool slow(int x)
{
return x && (x & 4242);
}
bool slow2(int x)
{
return (x & 4242) && x;
}
Assemblage émis par GCC 4.8.1 (-O3):
slow:
xorl %eax, %eax
testl %edi, %edi
je .L2
andl $4242, %edi
setne %al
.L2:
rep ret
slow2:
andl $4242, %edi
setne %al
ret
En d'autres termes, slow2
est mal nommé.
Je n'ai apporté le patch occasionnel qu'à GCC, donc si mon point de vue a du poids, c'est discutable :-). Mais il est certainement étrange, à mon avis, que GCC optimise l'un de ces éléments et non l'autre. Je suggère dépôt d'un rapport de bogue .
[Mettre à jour]
Étonnamment, de petits changements semblent faire une grande différence. Par exemple:
bool slow3(int x)
{
int y = x & 4242;
return y && x;
}
... génère à nouveau du code "lent". Je n'ai aucune hypothèse pour ce comportement.
Vous pouvez expérimenter tout cela sur plusieurs compilateurs ici .
Exactement pourquoi devrait être en mesure d'optimiser le code? Vous supposez que toute transformation qui fonctionne sera effectuée. Ce n'est pas du tout ainsi que fonctionnent les optimiseurs. Ce ne sont pas des intelligences artificielles. Ils fonctionnent simplement en remplaçant paramétriquement les motifs connus. Par exemple. la "Common Subexpression Elimination" scanne une expression pour rechercher des sous-expressions communes et les déplace vers l'avant si cela ne change pas les effets secondaires.
(BTW, CSE montre que les optimiseurs sont déjà tout à fait conscients du mouvement de code autorisé en présence possible d'effets secondaires. Ils savent que vous devez être prudent avec &&
. Qu'il s'agisse expr && expr
peut être optimisé pour le CSE ou non dépend des effets secondaires de expr
.)
Donc, en résumé: selon vous, quel modèle s'applique ici?
C'est à quoi ressemble votre code dans ARM qui devrait faire fonctionner slow
plus rapidement quand il est entré 0.
fast(int):
movw r3, #4242
and r3, r0, r3
adds r0, r3, #0
movne r0, #1
bx lr
slow(int):
cmp r0, #0
bxeq lr
movw r3, #4242
and r3, r0, r3
adds r0, r3, #0
movne r0, #1
bx lr
Cependant, GCC serait très bien optimisé lorsque vous commencerez à utiliser de telles fonctions triviales de toute façon.
bool foo() {
return fast(4242) && slow(42);
}
devient
foo():
mov r0, #1
bx lr
Mon point est parfois qu'un tel code nécessite plus de contexte pour être optimisé davantage, alors pourquoi les implémenteurs d'optimiseurs (améliorateurs!) Devraient-ils s'embêter?
Un autre exemple:
bool bar(int c) {
if (fast(c))
return slow(c);
}
devient
bar(int):
movw r3, #4242
and r3, r0, r3
cmp r3, #0
movne r0, #1
bxne lr
bx lr
Pour effectuer cette optimisation, il faut étudier l'expression pour deux cas distincts: x == 0
, simplifiant en false
et x != 0
, simplifiant en x & 4242
. Et puis soyez assez intelligent pour voir que la valeur de la deuxième expression donne également la valeur correcte même pour x == 0
.
Imaginons que le compilateur effectue une étude de cas et trouve des simplifications.
Si x != 0
, l'expression se simplifie en x & 4242
.
Si x == 0
, l'expression se simplifie en false
.
Après simplification, nous obtenons deux expressions complètement indépendantes. Pour les réconcilier, le compilateur doit poser des questions non naturelles:
Si x != 0
, false
peut-il être utilisé à la place de x & 4242
en tous cas ? [Non]
Si x == 0
, pouvez x & 4242
être utilisé au lieu de false
de toute façon? [Oui]
Le dernier compilateur sur lequel j'ai travaillé n'a pas fait ce genre d'optimisations. L'écriture d'un optimiseur pour tirer parti des optimisations liées à la combinaison d'opérateurs binaires et logiques n'accélérera pas les applications. La raison principale en est que les gens n'utilisent pas très souvent les opérateurs binaires comme ça. Beaucoup de gens ne se sentent pas à l'aise avec les opérateurs binaires et ceux qui le font n'écrivent généralement pas d'opérations inutiles qui doivent être optimisées.
Si je me donne la peine d'écrire
return (x & 4242)
et je comprends ce que cela signifie pourquoi je m'embêterais avec l'étape supplémentaire. Pour la même raison, je n'écrirais pas ce code sous-optimal
if (x==0) return false;
if (x==1) return true;
if (x==0xFFFEFD6) return false;
if (x==4242) return true;
return (x & 4242)
Il y a juste une meilleure utilisation du temps de développement du compilateur que pour optimiser des choses qui ne font aucune différence. Il y a tellement de gros poissons à faire frire dans l'optimisation du compilateur.
Il est légèrement intéressant de noter que cette optimisation n'est pas valable sur toutes les machines. Plus précisément, si vous exécutez sur une machine qui utilise la représentation du complément à un des nombres négatifs, alors:
-0 & 4242 == true
-0 && ( -0 & 4242 ) == false
GCC n'a jamais pris en charge de telles représentations, mais elles sont autorisées par la norme C.
C impose moins de restrictions sur le comportement des types intégraux signés que sur les types intégraux non signés. Les valeurs négatives en particulier peuvent légalement faire des choses étranges avec les opérations sur les bits. Si des arguments possibles de l'opération de bit ont un comportement juridiquement non contraint, le compilateur ne peut pas les supprimer.
Par exemple, "x/y == 1 ou true" peut planter le programme si vous divisez par zéro, de sorte que le compilateur ne peut pas ignorer l'évaluation de la division. Les valeurs signées négatives et les opérations sur les bits ne font jamais vraiment des choses comme ça sur un système commun, mais je ne suis pas sûr que la définition du langage l'exclue.
Vous devriez essayer le code avec des entiers non signés et voir si cela aide. Si c'est le cas, vous saurez que c'est un problème avec les types et non avec l'expression.