web-dev-qa-db-fra.com

Pourquoi GCC ne peut-il pas optimiser la paire logique ET binaire dans "x && (x & 4242)" en "x & 4242"?

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.

66
John Zwinck

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 .

33
Nemo

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?

50
MSalters

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
13
auselen

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]

8
Yves Daoust

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.

6
Marc

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.

5
andrew.punnett

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.

4
eggcrook