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.
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.
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__.
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.
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.