Pourquoi le compilateur ne peut-il pas (ou ne pas) optimiser une boucle d'addition prévisible dans une multiplication?

133

C'est une question qui m'est venue à l'esprit en lisant la brillante réponse de Mysticial à la question: pourquoi est-il plus rapide de traiter un tableau trié qu'un tableau non trié ?

Contexte des types concernés:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

Dans sa réponse, il explique que le compilateur Intel (ICC) optimise ceci:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

... en quelque chose d'équivalent à ceci:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

L'optimiseur reconnaît que celles-ci sont équivalentes et échange donc les boucles , déplaçant la branche hors de la boucle interne. Très intelligent!

Mais pourquoi ne fait-il pas cela?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

Espérons que Mysticial (ou n'importe qui d'autre) puisse donner une réponse tout aussi brillante. Je n'ai jamais entendu parler des optimisations évoquées dans cette autre question auparavant, donc j'en suis vraiment reconnaissant.

Jhabbott
la source
14
C'est quelque chose que seul Intel sait probablement. Je ne sais pas dans quel ordre il exécute son optimisation. Et apparemment, il n'exécute pas une passe de réduction de boucle après un échange de boucle.
Mysticial
7
Cette optimisation n'est valide que si les valeurs contenues dans le tableau de données sont immuables. Par exemple, si la mémoire est mappée à un périphérique d'entrée / sortie chaque fois que vous lisez des données, [0] produira une valeur différente ...
Thomas CG de Vilhena
2
De quel type de données s'agit-il, entier ou virgule flottante? L'addition répétée en virgule flottante donne des résultats très différents de la multiplication.
Ben Voigt
6
@Thomas: Si les données l'étaient volatile, alors l'échange de boucle serait également une optimisation invalide.
Ben Voigt
3
GNAT (compilateur Ada avec GCC 4.6) ne changera pas les boucles en O3, mais si les boucles sont commutées, il le convertira en multiplication.
prosfilaes

Réponses:

105

Le compilateur ne peut généralement pas transformer

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

dans

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

car ce dernier pourrait entraîner un débordement d'entiers signés alors que le premier ne le fait pas. Même avec un comportement de bouclage garanti pour le dépassement des entiers du complément à deux signés, cela changerait le résultat (si data[c]c'est 30000, le produit deviendrait -1294967296pour les 32 bits typiques intavec un bouclage, tandis que 100000 fois en ajoutant 30000 au sumserait, si cela ne déborde pas, augmente sumde 3000000000). Notez que la même chose vaut pour les quantités non signées, avec des nombres différents, un dépassement de 100000 * data[c]capacité introduirait généralement un modulo de réduction 2^32qui ne doit pas apparaître dans le résultat final.

Cela pourrait le transformer en

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

cependant, si, comme d'habitude, long longest suffisamment plus grand que int.

Pourquoi ça ne fait pas ça, je ne peux pas dire, je suppose que c'est ce que Mysticial a dit , "apparemment, il n'exécute pas de passe de boucle après échange de boucle".

Notez que l'échange de boucle lui-même n'est généralement pas valide (pour les entiers signés), car

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

peut conduire à un débordement où

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

pas. C'est casher ici, car la condition garantit data[c]que tout ce qui est ajouté a le même signe, donc si l'un déborde, les deux le font.

Je ne serais pas trop sûr que le compilateur ait pris cela en compte (@Mysticial, pourriez-vous essayer avec une condition comme data[c] & 0x80ou alors qui peut être vraie pour les valeurs positives et négatives?). J'ai demandé à des compilateurs de faire des optimisations non valides (par exemple, il y a quelques années, j'avais une conversion ICC (11.0, iirc) avec une conversion signée-32-bit-int-to-double dans 1.0/nnétait un unsigned int. Était environ deux fois plus rapide que gcc Mais faux, beaucoup de valeurs étaient plus grandes que 2^31, oups.).

Daniel Fischer
la source
4
Je me souviens d'une version du compilateur MPW qui a ajouté une option pour autoriser les cadres de pile plus grands que 32K [les versions antérieures étaient limitées en utilisant @ A7 + adressage int16 pour les variables locales]. Il avait tout ce qu'il fallait pour les trames de pile inférieures à 32K ou plus de 64K, mais pour une trame de pile de 40K, il l'utiliserait ADD.W A6,$A000, en oubliant que les opérations de mot avec des registres d'adresses signent le mot à 32 bits avant l'ajout. Il a fallu un certain temps pour dépanner, car la seule chose que le code a faite entre cela ADDet la prochaine fois qu'il a sorti A6 de la pile était de restaurer les registres de l'appelant qu'il a enregistrés dans cette trame ...
supercat
3
... et le seul registre dont l'appelant se souciait était l'adresse [constante de temps de chargement] d'un tableau statique. Le compilateur savait que l'adresse du tableau était sauvegardée dans un registre afin qu'il puisse optimiser en fonction de cela, mais le débogueur connaissait simplement l'adresse d'une constante. Ainsi, avant une instruction, MyArray[0] = 4;je pouvais vérifier l'adresse de MyArray, et regarder cet emplacement avant et après l'instruction exécutée; ça ne changerait pas. Le code était quelque chose comme move.B @A3,#4et A3 était censé toujours pointer vers MyArraychaque fois que l'instruction s'exécutait, mais ce n'était pas le cas. Amusement.
supercat
alors pourquoi clang effectue-t-il ce type d'optimisation?
Jason S
Le compilateur pourrait effectuer cette réécriture dans ses représentations intermédiaires internes, car il est autorisé à avoir un comportement moins indéfini dans ses représentations intermédiaires internes.
user253751 le
48

Cette réponse ne s'applique pas au cas spécifique lié, mais elle s'applique au titre de la question et peut intéresser les futurs lecteurs:

En raison de la précision finie, une addition à virgule flottante répétée n'est pas équivalente à une multiplication . Considérer:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

Démo

Ben Voigt
la source
10
Ce n'est pas une réponse à la question posée. Malgré des informations intéressantes (et un must pour tout programmeur C / C ++), ce n'est pas un forum et n'a pas sa place ici.
orlp
30
@nightcracker: L'objectif déclaré de StackOverflow est de créer une bibliothèque consultable de réponses utiles aux futurs utilisateurs. Et c'est une réponse à la question posée ... il se trouve que certaines informations non déclarées font que cette réponse ne s'applique pas à l'affiche originale. Il peut toujours s'appliquer pour d'autres avec la même question.
Ben Voigt
12
Cela pourrait être une réponse au titre de la question , mais pas à la question, non.
orlp
7
Comme je l'ai dit, ce sont des informations intéressantes . Pourtant, il me semble toujours erroné que nota bene la première réponse à la question ne réponde pas à la question telle quelle, maintenant . Ce n'est tout simplement pas la raison pour laquelle le compilateur Intel a décidé de ne pas optimiser, basta.
orlp
4
@nightcracker: Il me semble également faux que ce soit la meilleure réponse. J'espère que quelqu'un publiera une très bonne réponse pour le cas entier qui surpasse celui-ci en score. Malheureusement, je ne pense pas qu'il y ait une réponse pour "ne peut pas" pour le cas des nombres entiers, car la transformation serait légale, nous nous retrouvons donc avec "pourquoi ça ne marche pas", ce qui va en fait à l'encontre du " trop localisée "raison proche, car elle est propre à une version particulière du compilateur. La question à laquelle j'ai répondu est la plus importante, l'OMI.
Ben Voigt
6

Le compilateur contient plusieurs passes qui effectuent l'optimisation. Habituellement, à chaque passe, une optimisation des instructions ou des optimisations de boucle sont effectuées. À l'heure actuelle, il n'y a pas de modèle qui effectue une optimisation du corps de la boucle basée sur les en-têtes de boucle. Ceci est difficile à détecter et moins courant.

L'optimisation qui a été effectuée était un mouvement de code invariant en boucle. Cela peut être fait en utilisant un ensemble de techniques.

chevalier
la source
4

Eh bien, je suppose que certains compilateurs pourraient faire ce type d'optimisation, en supposant que nous parlons d'arithmétique entière.

Dans le même temps, certains compilateurs peuvent refuser de le faire car le remplacement de l'addition répétitive par la multiplication peut modifier le comportement de débordement du code. Pour les types entiers non signés, cela ne devrait pas faire de différence car leur comportement de débordement est entièrement spécifié par le langage. Mais pour ceux signés, cela pourrait (probablement pas sur la plate-forme complémentaire de 2). Il est vrai que le débordement signé conduit en fait à un comportement indéfini en C, ce qui signifie qu'il devrait être parfaitement OK d'ignorer complètement cette sémantique de débordement, mais tous les compilateurs ne sont pas assez courageux pour le faire. Il suscite souvent beaucoup de critiques de la part du "C est juste un langage d'assemblage de plus haut niveau". (Vous vous souvenez de ce qui s'est passé lorsque GCC a introduit des optimisations basées sur une sémantique d'aliasing strict?)

Historiquement, GCC s'est montré comme un compilateur qui a ce qu'il faut pour prendre des mesures aussi drastiques, mais d'autres compilateurs pourraient préférer s'en tenir au comportement perçu «destiné à l'utilisateur» même s'il n'est pas défini par le langage.

Fourmi
la source
Je préfère savoir si je dépends accidentellement d'un comportement non défini, mais je suppose que le compilateur n'a aucun moyen de savoir car le débordement serait un problème d'exécution: /
jhabbott
2
@jhabbott: ssi le débordement se produit, alors il y a un comportement indéfini. On ne sait pas si le comportement est défini jusqu'à l'exécution (en supposant que les nombres sont saisis à l'exécution): P.
orlp
3

C'est le cas maintenant - du moins, clang fait :

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

compile avec -O1 pour

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

Le débordement d'entier n'a rien à voir avec cela; s'il y a un débordement d'entier qui provoque un comportement indéfini, cela peut se produire dans les deux cas. Voici le même type de fonction utilisant intau lieu delong :

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

compile avec -O1 pour

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret
Jason S
la source
2

Il y a une barrière conceptuelle à ce type d'optimisation. Les auteurs de compilateurs consacrent beaucoup d'efforts à la réduction de la force - par exemple, en remplaçant les multiplications par des ajouts et des décalages. Ils s'habituent à penser que les multiplications sont mauvaises. Donc, un cas où l'on devrait aller dans l'autre sens est surprenant et contre-intuitif. Personne ne pense donc à le mettre en œuvre.

zwol
la source
3
Remplacer une boucle par un calcul de forme fermée est également une réduction de la résistance, n'est-ce pas?
Ben Voigt
Formellement, oui, je suppose, mais je n'ai jamais entendu personne en parler de cette façon. (Je suis un peu dépassé sur la littérature, cependant.)
zwol
1

Les personnes qui développent et maintiennent des compilateurs ont un temps et une énergie limités à consacrer à leur travail, ils veulent donc généralement se concentrer sur ce qui intéresse le plus leurs utilisateurs: transformer un code bien écrit en code rapide. Ils ne veulent pas passer leur temps à essayer de trouver des moyens de transformer un code idiot en code rapide - c'est à cela que sert la révision du code. Dans un langage de haut niveau, il peut y avoir du code «idiot» qui exprime une idée importante, ce qui vaut le temps des développeurs pour faire cela rapidement - par exemple, la déforestation raccourcie et la fusion de flux permettent aux programmes Haskell structurés autour de certains types de paresseux produit des structures de données à compiler en boucles serrées qui n'allouent pas de mémoire. Mais ce genre d'incitation ne s'applique tout simplement pas à transformer l'addition en boucle en multiplication. Si vous voulez que ce soit rapide,

dfeuer
la source