c++ - Pourquoi g++ transfère-t-il les calculs dans une boucle chaude?




gcc assembly (2)

J'ai essayé de lancer votre code et ... surprise: les instructions exécutées lorsque vous êtes dans la boucle ne sont pas celles que vous voyez dans le lien de l'explorateur du compilateur que vous avez posté. Vérifiez ceci (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 compilant sur votre machine

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

et courir 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 une version différente non en ligne de funnyEval (celle que vous avez vue dans la sortie désassemblée), même si celle qui est réellement utilisée est en ligne. Je ne sais pas (encore) pourquoi les deux sont différents, mais je suppose que si une pénalité de performance vous pénalise, vous pouvez la réparer en vous assurant que funnyEval est en ligne: soit en définissant dans un fichier d'en-tête, soit en compilant et en liant avec optimisation du temps de liaison (-flto). Je vais essayer de voir ce qui se passe quand funnyEval est dans une autre unité de traduction ...

J'ai un comportement de compilateur très étrange où G ++ tire les calculs dans une boucle chaude, réduisant considérablement les performances du code résultant. Qu'est-ce qui se passe ici?

Considérons 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 ++ aime en quelque sorte extraire les calculs X1 et X2 dans la boucle principale chaude, ce qui réduit les performances. Voici les détails:

La fonction itère simplement sur les data un tableau, recherche une valeur dans un dictionnaire dictPtr et l'écrit dans un writer emplacement de mémoire cible. data et dictPtr sont calculés au début de la fonction. Pour ce faire, il a deux saveurs: l’une avec un lambda, l’autre sans.

(Notez que cette fonction est juste un exemple de travail minimal de code beaucoup plus compliqué. Donc, évitez de dire que le lambda est inutile ici. Je suis conscient de ce fait et, malheureusement, dans le code d'origine, il est nécessaire.)

Le problème lors de la compilation avec le dernier g ++ (essayé 8.1 et 7.2, même problème avec les anciens g ++ que vous pouvez voir dans les liens de godbolt fournis) avec un niveau d'optimisation élevé ( -O3 -std=c++14 ) est le suivant :

La solution 2. ( noLambda=false ) génère un très mauvais code pour la boucle, encore pire que la solution "naïve", car elle suppose qu'il est judicieux d'extraire les calculs X1 et X2, qui se trouvent en dehors de la boucle principale super chaude. , dans la boucle principale super chaude, ce qui le rend environ 25% plus lent sur mon processeur.

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

Lorsque vous utilisez une boucle for habituelle ( noLambda=true ), le code est meilleur car X2 n'est plus tiré dans la boucle, mais X1 l'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 qu'il dictPtr vraiment de X1 dans la boucle en remplaçant dictPtr (le calcul X1) dans la boucle par dictPtr2 (un paramètre), l'instruction disparaîtra:

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 je veux avoir. Une boucle simple qui charge les valeurs et stocke le résultat sans y introduire des calculs aléatoires.

Que se passe-t-il? C'est rarement une bonne idée de tirer un calcul dans une boucle chaude, mais G ++ semble le penser ici. Cela me coûte de vraies performances. Le lambda exacerbe toute la situation; cela amène G ++ à introduire encore plus de calculs dans la boucle.

Ce qui rend ce problème si grave, c'est qu'il s'agit d'un code C ++ vraiment trivial, sans fonctionnalités sophistiquées. Si je ne peux pas compter sur le fait que mon compilateur produise un code parfait pour un exemple aussi trivial, je devrai vérifier l'assemblage de toutes les boucles dynamiques de mon code pour m'assurer que tout est aussi rapide que possible. Cela signifie également qu'il y a probablement un très grand nombre de programmes touchés par cela.


Vous utilisez un type 32 bits non signé pour l'index de tableau (ligne 21). Cela oblige le compilateur à considérer à chaque étape de la boucle si vous avez dépassé sa plage disponible, auquel cas il doit revenir au début du tableau. Le code supplémentaire que vous voyez est lié à cette vérification! Il y a au moins trois façons d'éviter cette approche trop prudente de la part du compilateur:

  1. Utilisez un type 64 bits pour l'index sur la ligne 21. Maintenant, le compilateur sait que vous ne bouclerez jamais le tableau et générerez le même code que celui sans le lambda.
  2. Utilisez un type 32 bits signé pour l'index sur la ligne 21. Désormais, le compilateur ne se préoccupe plus du débordement: le débordement signé est considéré comme un UB et donc ignoré. Je considère cela comme un bug dans l'interprétation de la norme, mais les avis divergent à ce sujet.
  3. Indiquez clairement au compilateur qu'un débordement ne se produira jamais, en ajoutant une ligne 'int32_t iter = 0;' au début de la fonction et en supprimant iter de la déclaration. Clairement, cela ne résout pas votre problème, mais cela montre comment c'est l'analyse de débordement qui génère le code supplémentaire.

Vous ne vous plaignez pas du code avant le début de la boucle, mais le même problème se pose. Faites juste iter et limitez int64_t, et vous verrez que cela devient beaucoup plus court car le compilateur ne considère plus la possibilité de débordement de tableau.

Donc, pour résumer: ce n’est pas le calcul de X1 et X2 qui est déplacé dans la boucle qui provoque la taille de la bulle, mais l’utilisation d’une variable d’index de tableau mal typée.





compiler-optimization