34 votes

Pourquoi g ++ tire-t-il les calculs dans une boucle chaude?

J'ai un très étrange comportement du compilateur G++ tire des calculs dans une chaude de boucle, sévèrement réduire les performances du code résultant. Ce qui se passe ici?

Prenons cette fonction:

#include <cstdint>

constexpr bool noLambda = true;

void funnyEval(const uint8_t* columnData, uint64_t dataOffset, uint64_t dictOffset, int32_t iter, int32_t limit, int32_t* writer,const int32_t* dictPtr2){
   // Computation X1
   const int32_t* dictPtr = reinterpret_cast<const int32_t*>(columnData + dictOffset);
   // Computation X2
   const uint16_t* data = (const uint16_t*)(columnData + dataOffset);
   // 1. The less broken solution without lambda
   if (noLambda) {
        for (;iter != limit;++iter){
            int32_t t=dictPtr[data[iter]];
            *writer = t;
            writer++;
        }
   }
   // 2. The totally broken solution with lambda
   else {
        auto loop = [=](auto body) mutable { for (;iter != limit;++iter){ body(iter); } };
        loop([=](unsigned index) mutable {
            int32_t t=dictPtr[data[index]];
            *writer = t;
            writer++;
        });
   }
}

Le problème ici est que G++ en quelque sorte adore tirer les calculs X1 et X2 dans le chaud boucle principale, la réduction de la performance. Voici les détails:

La fonction simplement parcourt un tableau data, recherche une valeur dans un dictionnaire, dictPtr et l'écrit sur une cible à l'emplacement de la mémoire writer. data et dictPtr sont calculées au début de la fonction. Il dispose de deux saveurs pour le faire: l'une avec un lambda, l'autre sans.

(Notez que cette fonction est seulement un minimum de travail exemple de beaucoup plus compliqué code. Merci donc de s'abstenir de tout commentaire que le lambda est inutile ici. Je suis conscient de ce fait et dans le code d'origine, il est nécessaire, malheureusement.)

Le problème lors de la compilation avec la dernière g++ (essayé 8.1 et 7.2, même problème avec les anciens g++s comme vous pouvez le voir dans la godbolt liens fournis) avec le haut niveau d'optimisation (-O3 -std=c++14) est la suivante:

La Solution 2. (noLambda=false) génère de très mauvais code de la boucle, encore pire que la "naïve" de la solution, car il suppose que c'est une bonne idée de retirer les Calculs X1 et X2, qui sont en dehors de la super chaud boucle principale, dans le super chaud boucle principale, faisant environ 25% plus lent sur mon CPU.

https://godbolt.org/g/MzbxPN

.L3:
        movl    %ecx, %eax          # unnecessary extra work
        addl    $1, %ecx
        addq    $4, %r9             # separate loop counter (pointer increment)
        leaq    (%rdi,%rax,2), %rax # array indexing with an LEA
        movzwl  (%rax,%rsi), %eax   # rax+rsi is Computation X2, pulled into the loop!
        leaq    (%rdi,%rax,4), %rax # rax+rdx is Computation X1, pulled into the loop!
        movl    (%rax,%rdx), %eax   
        movl    %eax, -4(%r9)
        cmpl    %ecx, %r8d
        jne     .L3

Lors de l'utilisation d'une habitude pour la boucle (noLambda=true), puis le code est mieux que X2 n'est plus tiré dans la boucle, mais X1 est toujours!:

https://godbolt.org/g/eVG75m

.L3:
        movzwl  (%rsi,%rax,2), %ecx
        leaq    (%rdi,%rcx,4), %rcx
        movl    (%rcx,%rdx), %ecx # This is Computation X1, pulled into the loop!
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %r8
        jne     .L3

Vous pouvez essayer, c'est vraiment X1 dans la boucle en remplaçant dictPtr (le calcul X1) dans la boucle avec la dictPtr2 (un paramètre), l'instruction va disparaître:

https://godbolt.org/g/nZ7TjJ

.L3:
        movzwl  (%rdi,%rax,2), %ecx
        movl    (%r10,%rcx,4), %ecx
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %rdx
        jne     .L3

C'est finalement la boucle que j'ai envie de l'avoir. Une simple boucle qui charge les valeurs et stocke le résultat sans aléatoires tirant les calculs en elle.

Donc, ce qui se passe ici? C'est rarement une bonne idée de tirer un calcul dans une chaude en boucle, mais G++ semble le penser ici. Cela me coûte réelle de la performance. Le lambda aggrave la situation dans son ensemble; elle conduit G++ pour tirer encore plus de calculs dans la boucle.

Ce qui rend ce problème si grave, c'est que c'est vraiment trivial de code C++ sans caractéristiques de fantaisie. Si je ne peux pas compter sur mon compilateur produisant parfait code pour un exemple trivial, j'ai besoin de vérifier l'assemblée de toutes les boucles dans mon code pour s'assurer que tout est aussi rapide comme il pourrait l'être. Cela signifie également qu'il y a probablement un grand nombre de programmes concernés par cette.

8voto

H. Guijt Points 1715

Vous utilisez un entier non signé de 32 bits de type pour les index du tableau (ligne 21). Cela force le compilateur à considérer à chaque étape à travers la boucle que vous pourriez avoir a débordé de ses gamme disponible, auquel cas il faut remonter au début du tableau. Le code supplémentaire que vous voyez est liée à cette case! Il y a au moins trois façons d'éviter cette approche prudente par le compilateur:

  1. Utilisez la version 64-bits type d'index sur la ligne 21. Maintenant que le compilateur sait que vous ne serez jamais enrouler autour de la matrice, et de générer le même code sans le lambda.
  2. Utiliser un signé de 32 bits type d'index sur la ligne 21. Maintenant que le compilateur ne plus se soucie de dépassement de capacité: signé le dépassement est considéré comme l'UB, et donc ignoré. Je considère cela comme un bug dans l'interprétation de la norme, mais les opinions diffèrent sur ce point.
  3. Préciser au compilateur que le dépassement ne peut se produire, par l'ajout d'une ligne "int32_t iter = 0;" au début de la fonction, et la suppression d'iter à partir de la déclaration. Clairement, cela ne résout pas votre problème, mais il illustre la manière dont c'est le débordement de l'analyse qui provoque le code supplémentaire pour être généré.

Vous n'êtes pas à se plaindre le code avant la boucle commence, mais ici vous avez le même problème. Juste faire de l'iter et de limiter int64_t, et vous verrez qu'il devient beaucoup plus courte que le compilateur ne considère plus la possibilité de tableau de débordement.

Donc, pour résumer: il n'est pas le calcul de X1 et X2 qui est déplacé dans la boucle qui provoque la taille d'un ballon, mais l'utilisation d'un mal tapé tableau d'indice variable.

5voto

Peter Cordes Points 1375

Félicitations, vous avez trouvé un bug de gcc. La principale solution est de déclarer à la GCC de bugzilla avec le "raté-optimisation de mot-clé. Votre MCVEs sont déjà grands cas de test pour le bug, donc il ne devrait pas prendre trop de temps à écrire un. Copier/coller le code et une description. Un lien vers ce Q&A, et un lien vers le code de la http://godbolt.org/, serait une bonne chose, aussi.

Parfois il y a de subtiles microstructure raisons d'utiliser "extra" les instructions, comme xor-réinitialisation de la destination de l' popcnt/lzcnt ou bsf afin d'éviter une fausse dépendance sur les Processeurs Intel, mais ce n'est pas le cas ici. C'est dommage; l' movl %ecx, %eax à l'intérieur de la boucle peut être le résultat de l'utilisation d'un type non signé plus étroit que d'un pointeur, mais même ce qui pourrait être fait de manière plus efficace; c'est un raté de l'optimisation, trop.

Je n'ai pas regardé du CCG GIMPLE ou RTL dumps (représentations internes) pour plus de détails. La seule utilisation des valeurs calculées sont à l'intérieur de la boucle, donc je peux imaginer que le compilateur de la représentation interne de la logique du programme est peut-être perdu la différence entre l'intérieur / à l'extérieur de la boucle lors de la transformation. Normalement les choses qui n'ont pas besoin d'être dans la boucle sont hissés ou coulé hors de la boucle.

Mais malheureusement il n'est pas rare pour gcc laisser un extra - mov d'instructions à l'intérieur d'une boucle pour la configuration de code en dehors de la boucle. Surtout quand il peut prendre de multiples instructions à l'extérieur de la boucle pour obtenir le même effet. C'est généralement un mauvais compromis lors de l'optimisation de la performance au lieu de code de taille. Je n'ai pas regardé à l'asm de sortie de Profil de l'Optimisation orientée autant que je le voudrais, pour voir le code où gcc sait boucles qui sont vraiment chauds et déroule eux. Mais la plupart du code est construit sans PGO, malheureusement, de code-gen sans -fprofile-use est encore très important.


Cependant, le noyau de cette question n'est pas comment obtenir cet exemple en particulier, aussi rapides que possible. Au lieu de cela, je suis plutôt atonished comment le compilateur peut produire de tels deoptimizations dans un simple extrait de code. Mon principal problème est maintenant: j'ai en quelque sorte perdu la foi dans mon compilateur, donc je veux comprendre comment cela peut se produire afin que je puisse le retrouver.

N'ont pas la foi dans gcc! C'est une pièce très complexe de machines qui donne souvent de bons résultats, mais rarement produit les meilleurs résultats.

Ce cas est l'un des plus évidente et la plus simple de mauvais choix, j'ai vu son optimiseur de faire (et qui est assez décevant), si. Généralement manqué optimisations sont un peu plus subtiles (et de la charnière sur la microstructure des détails comme mode d'adressage choix et uops / exécution de ports), ou du moins pas évidemment trivial éviter. (Palan qu'une instruction sans modifier l'allocation de registres pour l'ensemble de la boucle.)

Mais de nombreuses boucles de goulot d'étranglement sur la mémoire, pas uop débit. Les Processeurs modernes sont conçus de le mâcher à travers le gaspillage d'instructions que les compilateurs, surtout JIT-compilateurs, générer. C'est pourquoi manqué-optimisations comme ce n'est généralement pas un grand impact à l'échelle macro, et pourquoi les cas où ils ont leur importance (par exemple, les encodeurs vidéo ou de la matrice multiplie) souvent encore l'utilisation de blocs de écrite à la main à l'asm.

Souvent, il est possible de main-tenir la gcc dans la fabrication de nice asm par la mise en œuvre de votre source d'une manière structurée comme l'asm vous le souhaitez. (Comme ce cas: Quel est le moyen efficace de compter les bits à une position ou inférieur?, et de voir Pourquoi ce code C++ plus vite que mon écrit à la main et assemblée pour l'examen de la conjecture de Collatz?, pour une réponse d'ordre général sur la façon d'aider le compilateur vs battre le compilateur écrite à la main à l'asm.)

Mais quand votre compilateur est braindead comme ça, il n'y a rien que vous pouvez faire. Eh bien, à l'exception de chercher des solutions de contournement, ou des trucs comme éviter d' unsigned des entiers plus étroit que des pointeurs qui certains autres réponses ont souligné l'importance.


Fait intéressant, le pire des cas (2 extra LEA instructions dans la boucle, en plus en utilisant des compteurs de boucle) se produit uniquement avec votre if (noLambda).

Si vous faites 2 versions distinctes de la fonction et de supprimer l' if, l' nolambda version fait un bien propre de la boucle (mais manque d'auto-vectorisation de les rassembler, ce qui serait une victoire lors de la compilation avec -march=skylake)

J'ai mis ton code sur le Godbolt compilateur explorer. (Aussi intéressante, utilisez -funroll-loops pour voir quelles parties sont repris tous les déroulé itération de boucle, et qui sont simplement à l'intérieur de la boucle une fois.)

# gcc7.2:  the nolamba side of the if, with no actual if()
.L3:
    movzwl  (%rsi,%rax,2), %ecx
    movl    (%rdx,%rcx,4), %ecx
    movl    %ecx, (%r9,%rax,4)   # indexed store: no port 7
    addq    $1, %rax             # gcc8 -O3 -march=skylake uses inc to save a code byte here.
    cmpq    %rax, %r8
    jne     .L3

Sur Intel Sandybridge-famille, ce décode à 5 uop. (Macro-fusion de la cmp/ccc de tours qui paire en 1 uop. Les autres instructions sont tous mono-uop; movzwl est un pur charge et n'a pas besoin d'un ALU de port).

Le magasin de l'onu-stratifiés sur SnB/IvB (à un coût supplémentaire uop pour les 4 à l'échelle de problème stade, l'un des principaux avant la fin de l'goulets d'étranglement), mais peut rester soudés sur le DSJ/SKL. Il ne peut pas utiliser le port 7, mais (parce qu'elle est indexée), dont la signification DSJ/SKL serait limitée à 2 mémoire ops par cycle d'horloge, pas de 3.

Les goulots d'étranglement:

  • Avant la fin de l'émission la bande passante de 4 fusionnée de domaine uop par cycle d'horloge. La boucle est de 5 uop, et peut émettre à près de 1 itération par 1.25. (Non multiple de 4 boucles ne sont pas parfaites, mais 5 uop est bien géré sur Haswell/Skylake au moins. Peut-être pas Sandybridge.)

  • load / store exécution de ports: Haswell et, plus tard, peut exécuter 2 charges + 1 magasin par cycle d'horloge, mais seulement lorsque le magasin évite indexé mode d'adressage de sorte que le magasin à l'adresse uop pouvez exécuter sur le port 7.


le lambda version obtient un 2ème compteur de boucle de pointeur (incrément) et un idiot movl %ecx, %eax, mais le LEA instructions de rester en dehors de la boucle.

Mais ce n'est pas le supplément de calcul proprement dit, c'est le total de l'uop le débit qui est probablement nuire à votre boucle. Si le dictionnaire pour la plupart des séjours chaud dans le cache, un Haswell ou version ultérieure PROCESSEUR


J'allais écrire plus, mais je n'ai pas fini. Poste maintenant car le générique de début/milieu de partie est apparemment ce que la question est vraiment. Ne pas aveuglément confiance gcc.

Et ne pas s'attendre qu'il aura un code optimal, la plupart du temps. Vous pouvez souvent gagner 10 ou 20% juste en modifiant le code source en C (et parfois beaucoup plus). Parfois, gcc ne semble pas avoir un indice, comme l'utilisation d'extra - leas pour aucune raison apparente lors de déroulage, au lieu d'utiliser un déplacement dans un mode d'adressage. Je pense que son adressage en mode modèle de coût ne doit pas être exacts, au moins pour l' -march=haswell / -march=skylake.

2voto

Paolo Crosetto Points 716

J'ai essayé de l'exécution de votre code et... surprise: les instructions exécutées lorsque vous êtes dans la boucle ne sont pas ceux que vous voyez dans le compilateur explorer le lien que vous avez posté. Check this out (j'ai ajouté une fonction principale) https://godbolt.org/g/PPYtQa Les instructions exécutées pendant que vous êtes dans la boucle sont 162-167, c'est à dire

.L15:
        movzwl  25(%rbx,%rdx), %ecx
        movl    5(%rbx,%rcx,4), %ecx
        movl    %ecx, 0(%rbp,%rdx,2)
        addq    $2, %rdx
        cmpq    $180, %rdx
        jne     .L15

Vous pouvez vérifier cela en compilation sur votre machine

g++ test.cpp -std=c++1z -g -O3

et en cours d'exécution avec gdb

> gdb a.out
(gdb) break funnyEval
(gdb) layout split #shows assebly
(gdb) stepi #steps to the next instruction

Le compilateur génère un autre non-inline version de funnyEval (qui est celui que vous avez vu dans la démonté sortie), même si celui qui est réellement utilisé est incorporé. Je n'ai aucune idée (encore) pourquoi les deux sont différents, mais je suppose que si vous êtes touché par une pénalité sur les performances, vous pouvez réparer en faisant en sorte que funnyEval obtient inline: soit la définition dans un fichier d'en-tête ou par la compilation et la liaison avec le lien d'optimisation (-flto). Je vais l'essayer, pour voir ce qui se passe quand funnyEval est dans une autre unité de traduction...

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X