web-dev-qa-db-fra.com

L'utilisation de ce pointeur provoque une désoptimisation étrange dans la boucle chaude

J'ai récemment rencontré une étrange désoptimisation (ou plutôt une opportunité d'optimisation manquée).

Considérez cette fonction pour décompresser efficacement des tableaux d'entiers de 3 bits en entiers de 8 bits. Il déballe 16 pouces dans chaque itération de boucle:

void unpack3bit(uint8_t* target, char* source, int size) {
   while(size > 0){
      uint64_t t = *reinterpret_cast<uint64_t*>(source);
      target[0] = t & 0x7;
      target[1] = (t >> 3) & 0x7;
      target[2] = (t >> 6) & 0x7;
      target[3] = (t >> 9) & 0x7;
      target[4] = (t >> 12) & 0x7;
      target[5] = (t >> 15) & 0x7;
      target[6] = (t >> 18) & 0x7;
      target[7] = (t >> 21) & 0x7;
      target[8] = (t >> 24) & 0x7;
      target[9] = (t >> 27) & 0x7;
      target[10] = (t >> 30) & 0x7;
      target[11] = (t >> 33) & 0x7;
      target[12] = (t >> 36) & 0x7;
      target[13] = (t >> 39) & 0x7;
      target[14] = (t >> 42) & 0x7;
      target[15] = (t >> 45) & 0x7;
      source+=6;
      size-=6;
      target+=16;
   }
}

Voici l'assemblage généré pour les parties du code:

 ...
 367:   48 89 c1                mov    rcx,rax
 36a:   48 c1 e9 09             shr    rcx,0x9
 36e:   83 e1 07                and    ecx,0x7
 371:   48 89 4f 18             mov    QWORD PTR [rdi+0x18],rcx
 375:   48 89 c1                mov    rcx,rax
 378:   48 c1 e9 0c             shr    rcx,0xc
 37c:   83 e1 07                and    ecx,0x7
 37f:   48 89 4f 20             mov    QWORD PTR [rdi+0x20],rcx
 383:   48 89 c1                mov    rcx,rax
 386:   48 c1 e9 0f             shr    rcx,0xf
 38a:   83 e1 07                and    ecx,0x7
 38d:   48 89 4f 28             mov    QWORD PTR [rdi+0x28],rcx
 391:   48 89 c1                mov    rcx,rax
 394:   48 c1 e9 12             shr    rcx,0x12
 398:   83 e1 07                and    ecx,0x7
 39b:   48 89 4f 30             mov    QWORD PTR [rdi+0x30],rcx
 ...

Cela semble assez efficace. Simplement un shift right suivi d'un and, puis d'un store dans le tampon target. Mais maintenant, regardez ce qui se passe lorsque je change la fonction en méthode dans une structure:

struct T{
   uint8_t* target;
   char* source;
   void unpack3bit( int size);
};

void T::unpack3bit(int size) {
        while(size > 0){
           uint64_t t = *reinterpret_cast<uint64_t*>(source);
           target[0] = t & 0x7;
           target[1] = (t >> 3) & 0x7;
           target[2] = (t >> 6) & 0x7;
           target[3] = (t >> 9) & 0x7;
           target[4] = (t >> 12) & 0x7;
           target[5] = (t >> 15) & 0x7;
           target[6] = (t >> 18) & 0x7;
           target[7] = (t >> 21) & 0x7;
           target[8] = (t >> 24) & 0x7;
           target[9] = (t >> 27) & 0x7;
           target[10] = (t >> 30) & 0x7;
           target[11] = (t >> 33) & 0x7;
           target[12] = (t >> 36) & 0x7;
           target[13] = (t >> 39) & 0x7;
           target[14] = (t >> 42) & 0x7;
           target[15] = (t >> 45) & 0x7;
           source+=6;
           size-=6;
           target+=16;
        }
}

Je pensais que l'assembly généré devrait être tout à fait le même, mais ce n'est pas le cas. En voici une partie:

...
 2b3:   48 c1 e9 15             shr    rcx,0x15
 2b7:   83 e1 07                and    ecx,0x7
 2ba:   88 4a 07                mov    BYTE PTR [rdx+0x7],cl
 2bd:   48 89 c1                mov    rcx,rax
 2c0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2c3:   48 c1 e9 18             shr    rcx,0x18
 2c7:   83 e1 07                and    ecx,0x7
 2ca:   88 4a 08                mov    BYTE PTR [rdx+0x8],cl
 2cd:   48 89 c1                mov    rcx,rax
 2d0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2d3:   48 c1 e9 1b             shr    rcx,0x1b
 2d7:   83 e1 07                and    ecx,0x7
 2da:   88 4a 09                mov    BYTE PTR [rdx+0x9],cl
 2dd:   48 89 c1                mov    rcx,rax
 2e0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 2e3:   48 c1 e9 1e             shr    rcx,0x1e
 2e7:   83 e1 07                and    ecx,0x7
 2ea:   88 4a 0a                mov    BYTE PTR [rdx+0xa],cl
 2ed:   48 89 c1                mov    rcx,rax
 2f0:   48 8b 17                mov    rdx,QWORD PTR [rdi] // Load, BAD!
 ...

Comme vous le voyez, nous avons introduit un load redondant supplémentaire de la mémoire avant chaque quart de travail (mov rdx,QWORD PTR [rdi]). Il semble que le pointeur target (qui est maintenant un membre au lieu d'une variable locale) doit toujours être rechargé avant de le stocker. Cela ralentit considérablement le code (environ 15% dans mes mesures).

J'ai d'abord pensé que peut-être le modèle de mémoire C++ impose qu'un pointeur membre ne soit pas stocké dans un registre mais doit être rechargé, mais cela semblait être un choix gênant, car cela rendrait impossible de nombreuses optimisations viables. J'ai donc été très surpris que le compilateur ne stocke pas target dans un registre ici.

J'ai essayé de mettre le pointeur membre en cache moi-même dans une variable locale:

void T::unpack3bit(int size) {
    while(size > 0){
       uint64_t t = *reinterpret_cast<uint64_t*>(source);
       uint8_t* target = this->target; // << ptr cached in local variable
       target[0] = t & 0x7;
       target[1] = (t >> 3) & 0x7;
       target[2] = (t >> 6) & 0x7;
       target[3] = (t >> 9) & 0x7;
       target[4] = (t >> 12) & 0x7;
       target[5] = (t >> 15) & 0x7;
       target[6] = (t >> 18) & 0x7;
       target[7] = (t >> 21) & 0x7;
       target[8] = (t >> 24) & 0x7;
       target[9] = (t >> 27) & 0x7;
       target[10] = (t >> 30) & 0x7;
       target[11] = (t >> 33) & 0x7;
       target[12] = (t >> 36) & 0x7;
       target[13] = (t >> 39) & 0x7;
       target[14] = (t >> 42) & 0x7;
       target[15] = (t >> 45) & 0x7;
       source+=6;
       size-=6;
       this->target+=16;
    }
}

Ce code donne également le "bon" assembleur sans magasins supplémentaires. Donc, ma supposition est la suivante: le compilateur n'est pas autorisé à hisser la charge d'un pointeur membre d'une structure, donc un tel "pointeur actif" doit toujours être stocké dans une variable locale.

  • Alors, pourquoi le compilateur est-il incapable d'optimiser ces charges?
  • Est-ce le modèle de mémoire C++ qui l'interdit? Ou est-ce simplement une lacune de mon compilateur?
  • Ma supposition est-elle correcte ou quelle est la raison exacte pour laquelle l'optimisation ne peut pas être effectuée?

Le compilateur utilisé était g++ 4.8.2-19ubuntu1 avec -O3 optimisation. J'ai aussi essayé clang++ 3.4-1ubuntu3 avec des résultats similaires: Clang est même capable de vectoriser la méthode avec le pointeur local target. Cependant, en utilisant le this->target le pointeur donne le même résultat: une charge supplémentaire du pointeur avant chaque magasin.

J'ai vérifié l'assembleur de quelques méthodes similaires et le résultat est le même: Il semble qu'un membre de this doit toujours être rechargé avant un magasin, même si une telle charge peut simplement être hissée en dehors de la boucle. Je vais devoir réécrire beaucoup de code pour me débarrasser de ces magasins supplémentaires, principalement en mettant moi-même le pointeur en cache dans une variable locale qui est déclarée au-dessus du code chaud. Mais j'ai toujours pensé que manipuler des détails tels que la mise en cache d'un pointeur dans une variable locale serait certainement admissible à une optimisation prématurée de nos jours où les compilateurs sont devenus si intelligents. Mais il semble que je me trompe ici. La mise en cache d'un pointeur de membre dans une boucle chaude semble être une technique d'optimisation manuelle nécessaire.

114
gexicide

L'alias de pointeur semble être le problème, ironiquement entre this et this->target. Le compilateur prend en compte la possibilité plutôt obscène que vous avez initialisée:

this->target = &this

Dans ce cas, écrivez à this->target[0] modifierait le contenu de this (et donc, this-> target).

Le problème d'alias de mémoire n'est pas limité à ce qui précède. En principe, toute utilisation de this->target[XX] étant donné une valeur (in) appropriée de XX peut pointer vers this.

Je suis mieux versé en C, où cela peut être résolu en déclarant des variables de pointeur avec le mot clé __restrict__.

97
Peter Boncz

Des règles d'alias strictes permettent char* pour alias tout autre pointeur. Donc this->target peut alias avec this, et dans votre méthode de code, la première partie du code,

target[0] = t & 0x7;
target[1] = (t >> 3) & 0x7;
target[2] = (t >> 6) & 0x7;

est en fait

this->target[0] = t & 0x7;
this->target[1] = (t >> 3) & 0x7;
this->target[2] = (t >> 6) & 0x7;

comme this peut être modifié lorsque vous modifiez this->target contenu.

Une fois que this->target est mis en cache dans une variable locale, l'alias n'est plus possible avec la variable locale.

30
Jarod42

Le problème ici est alias strict qui dit que nous sommes autorisés à alias via un char * et que cela empêche l'optimisation du compilateur dans ton cas. Nous ne sommes pas autorisés à créer un alias via un pointeur d'un type différent qui serait un comportement indéfini, normalement sur SO nous voyons ce problème que les utilisateurs tentent de alias via des types de pointeurs incompatibles .

Il semblerait raisonnable de mettre en œuvre uint8_t comme un caractère non signé et si nous regardez cstdint sur Colir il comprend stdint.h quels types de caractères uint8_t comme suit:

typedef unsigned char       uint8_t;

si vous avez utilisé un autre type non char, le compilateur doit être en mesure d'optimiser.

Ceci est traité dans le projet de norme C++ section 3.10 Lvalues ​​et rvalues ​​ qui dit:

Si un programme tente d'accéder à la valeur stockée d'un objet via une valeur gl autre que l'un des types suivants, le comportement n'est pas défini

et comprend la puce suivante:

  • un type char ou un caractère non signé.

Remarque, j'ai posté un commentaire sur les solutions possibles dans une question qui demande Quand est uint8_t ≠ char non signé? et la recommandation était:

La solution de contournement triviale, cependant, consiste à utiliser le mot clé restrict ou à copier le pointeur dans une variable locale dont l'adresse n'est jamais prise afin que le compilateur n'ait pas à se soucier de savoir si les objets uint8_t peuvent l'alias.

Étant donné que C++ ne prend pas en charge le mot clé restrict ( restrict , vous devez vous fier à l'extension du compilateur, par exemple gcc utilise __restrict __ donc ce n'est pas totalement portable mais l'autre suggestion devrait l'être.

24
Shafik Yaghmour