web-dev-qa-db-fra.com

Un saut coûteux avec GCC 5.4.0

J'avais une fonction qui ressemblait à ceci (ne montrant que la partie importante):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

Écrit comme ceci, la fonction a pris ~ 34ms sur ma machine. Après avoir changé la condition pour bool multiplier (en donnant au code l'apparence suivante):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

le temps d'exécution a diminué à ~ 19 ms.

Le compilateur utilisé était GCC 5.4.0 avec -O3 et après avoir vérifié le code asm généré à l'aide de godbolt.org, j'ai découvert que le premier exemple générait un saut, contrairement au second. J'ai décidé d'essayer GCC 6.2.0, qui génère également une instruction de saut lors de l'utilisation du premier exemple, mais GCC 7 ne semble plus en générer.

Découvrir cette façon d’accélérer le code était plutôt macabre et a pris un certain temps. Pourquoi le compilateur se comporte-t-il de cette façon? Est-ce prévu et est-ce quelque chose que les programmeurs devraient rechercher? Y a-t-il d'autres choses semblables à cela?

EDIT: lien vers godbolt https://godbolt.org/g/5lKPF

170
Jakub Jůza

L'opérateur logique AND (&&) utilise l'évaluation de court-circuit, ce qui signifie que le deuxième test n'est effectué que si la première comparaison est vraie. C'est souvent exactement la sémantique dont vous avez besoin. Par exemple, considérons le code suivant:

if ((p != nullptr) && (p->first > 0))

Vous devez vous assurer que le pointeur est non nul avant de le déréférencer. Si cela n'était pas une évaluation de court-circuit, vous auriez un comportement indéfini car vous déréférenceriez un pointeur nul.

Il est également possible que l’évaluation des courts-circuits apporte un gain de performance dans les cas où l’évaluation des conditions est un processus coûteux. Par exemple:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

Si DoLengthyCheck1 échoue, il est inutile d'appeler DoLengthyCheck2.

Cependant, dans le binaire résultant, une opération de court-circuit a souvent pour résultat deux branches, car il s’agit du moyen le plus simple pour le compilateur de préserver cette sémantique. (C’est pourquoi, de l’autre côté de la médaille, une évaluation de court-circuit peut parfois inhiber le potentiel d’optimisation .) Vous pouvez le voir en regardant le partie pertinente du code objet générée pour votre instruction if par GCC 5.4:

    movzx   r13d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L5

    cmp     ax, 478           ; (l[i + shift] < 479)
    ja      .L5

    add     r8d, 1            ; nontopOverlap++

Vous voyez ici les deux comparaisons (instructions cmp) suivies chacune d'un saut/branche conditionnel distinct (ja ou saut si ci-dessus).

En règle générale, les branches sont lentes et doivent donc être évitées dans les boucles serrées. C’est le cas de la quasi-totalité des processeurs x86, de l’humble 8088 (dont les temps de récupération lents et de la file d’attente de pré-extraction extrêmement réduite [comparable à un cache d’instructions], combinée à une absence totale de prédiction de branche, signifiait que les branches prises nécessitaient le vidage du cache ) aux implémentations modernes (dont les longs pipelines rendent les branches mal prédites aussi coûteuses). Notez la petite mise en garde que j'ai glissé là. Les processeurs modernes depuis le Pentium Pro sont dotés de moteurs de prédiction de branche avancés conçus pour minimiser le coût des branches. Si la direction de la succursale peut être correctement prédite, le coût est minime. La plupart du temps, cela fonctionne bien, mais si vous tombez dans des cas pathologiques où le prédicteur de branche n'est pas de votre côté, votre code peut devenir extrêmement lent . C'est probablement là où vous vous trouvez, puisque vous dites que votre tableau n'est pas trié.

Vous dites que les tests ont confirmé que le remplacement du && avec un * rend le code sensiblement plus rapide. La raison en est évidente lorsque nous comparons la partie pertinente du code de l'objet:

    movzx   r13d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    xor     r15d, r15d        ; (curr[i] < 479)
    cmp     r13w, 478
    setbe   r15b

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     ax, 478
    setbe   r14b

    imul    r14d, r15d        ; meld results of the two comparisons

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

Il est un peu contre-intuitif de penser que cela pourrait être plus rapide, car il y a plus d'instructions ici, mais c'est ainsi que l'optimisation fonctionne parfois. Vous voyez les mêmes comparaisons (cmp) en cours ici, mais chacune d’elles est précédée d’un xor et suivie d’un setbe. Le XOR n'est qu'une astuce standard pour effacer un registre. Le setbe est une instruction x86 qui définit un bit en fonction de la valeur d'un drapeau et est souvent utilisée pour implémenter code sans branche. Ici, setbe est l'inverse de ja. Il définit le registre de destination sur 1 si la comparaison est inférieure ou égale (étant donné que le registre a été mis à zéro, il sera 0 sinon), alors que ja est ramifié si la comparaison est supérieure, une fois ces deux valeurs obtenues dans le r15b et r14b _ registres, ils sont multipliés ensemble en utilisant imul. La multiplication était traditionnellement une opération relativement lente, mais elle est extrêmement rapide sur les processeurs modernes, et ce sera particulièrement rapide, car elle ne multiplie que les valeurs de deux octets.

Vous auriez tout aussi bien pu remplacer la multiplication par l'opérateur AND au niveau du bit (&), qui ne fait pas d’évaluation de court-circuit. Cela rend le code beaucoup plus clair et constitue un motif généralement reconnu par les compilateurs. Mais lorsque vous faites cela avec votre code et que vous le compilez avec GCC 5.4, il continue à émettre la première branche:

    movzx   r13d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

Il n'y a pas de raison technique pour laquelle il a dû émettre le code de cette manière, mais pour une raison quelconque, son heuristique interne lui dit que c'est plus rapide. Il serait probablement plus rapide si le prédicteur de branche était de votre côté, mais il serait probablement plus lent si la prédiction de branche échoue plus souvent qu'elle ne réussit.

Les nouvelles générations du compilateur (et d'autres compilateurs, comme Clang) connaissent cette règle et l'utilisent parfois pour générer le même code que celui que vous auriez recherché manuellement. Je vois régulièrement Clang traduire && expressions dans le même code qui aurait été émis si j'aurais utilisé &. Ce qui suit est la sortie pertinente de GCC 6.2 avec votre code en utilisant le && opérateur:

    movzx   r13d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

Notez à quel point astucieux ceci ! Il utilise des conditions signées (jg et setle) par opposition à des conditions non signées (ja et setbe), mais ce n'est pas important. Vous pouvez voir que la comparaison avec la première condition est toujours identique à celle de l'ancienne version et qu'elle utilise la même instruction setCC pour générer du code sans branche pour la deuxième condition, mais elle est devenue beaucoup plus efficace. dans comment il fait l'incrément. Au lieu de faire une deuxième comparaison redondante pour définir les indicateurs pour une opération sbb, il utilise la connaissance selon laquelle r14d sera 1 ou 0 pour simplement ajouter inconditionnellement cette valeur à nontopOverlap. Si r14d est égal à 0, l’ajout est donc nul; sinon, il ajoute 1, exactement comme il est censé le faire.

GCC 6.2 produit réellement plus de code lorsque vous utilisez le court-circuitage && _ opérateur que le bit & opérateur:

    movzx   r13d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

La branche et le jeu conditionnel sont toujours là, mais maintenant, il retourne à la manière moins intelligente d’incrémenter nontopOverlap. Ceci est une leçon importante sur la raison pour laquelle vous devez faire attention lorsque vous essayez de sur-compiler votre compilateur!

Mais si vous pouvez prouver avec des points de repère que le code de branchement est en réalité plus lent, alors il peut être rentable d'essayer de sur-habiller votre compilateur. Vous devez simplement le faire avec une inspection minutieuse du désassemblage et soyez prêt à réévaluer vos décisions lorsque vous effectuez une mise à niveau vers une version ultérieure du compilateur. Par exemple, le code que vous avez pourrait être réécrit comme suit:

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

Il n’ya pas d’instruction if ici, et la grande majorité des compilateurs ne penseront jamais à émettre du code de branchement pour cela. GCC ne fait pas exception. toutes les versions génèrent quelque chose qui ressemble à ce qui suit:

    movzx   r14d, Word PTR [rbp+rcx*2]
    movzx   eax,  Word PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

Si vous avez suivi les exemples précédents, cela devrait vous paraître très familier. Les deux comparaisons sont effectuées sans branche, les résultats intermédiaires sont anded ensemble, puis ce résultat (qui sera 0 ou 1) est added à nontopOverlap. Si vous voulez du code sans succursale, cela vous permettra pratiquement de l'obtenir.

GCC 7 est devenu encore plus intelligent. Il génère maintenant un code pratiquement identique (à l’exception d’un léger réarrangement des instructions) pour l’astuce ci-dessus en tant que code original. Donc, la réponse à votre question "Pourquoi le compilateur se comporte-t-il de cette façon?" , c'est probablement parce qu'ils ne sont pas parfaits! Ils essaient d'utiliser des méthodes heuristiques pour générer le code le plus optimal possible, mais ils ne prennent pas toujours les meilleures décisions. Mais au moins, ils peuvent devenir plus intelligents avec le temps!

Une façon de voir cette situation est que le code de branchement présente la meilleure meilleure performance . Si la prédiction de branche réussit, le fait de sauter des opérations inutiles se traduira par une durée d'exécution légèrement plus rapide. Cependant, le code sans branche a la meilleure performance dans le pire des cas . Si la prédiction de branche échoue, l'exécution de quelques instructions supplémentaires afin d'éviter une branche sera définitivement plus rapide qu'une branche mal prédite. Même les compilateurs les plus intelligents et les plus intelligents auront du mal à faire ce choix.

Et pour ce qui est de savoir si les programmeurs doivent faire attention à cela, la réponse est presque certainement non, sauf dans certaines boucles critiques que vous essayez d'accélérer via des micro-optimisations. Ensuite, vous vous asseyez avec le démontage et trouvez les moyens de le modifier. Et, comme je l’ai déjà dit, soyez prêt à revoir ces décisions lorsque vous mettrez à jour une version plus récente du compilateur, car cela peut faire quelque chose de stupide avec votre code compliqué ou peut-être avoir suffisamment modifié son système heuristique d'optimisation pour pouvoir revenir en arrière. à utiliser votre code d'origine. Commentez bien!

261
Cody Gray

Une chose importante à noter est que

(curr[i] < 479) && (l[i + shift] < 479)

et

(curr[i] < 479) * (l[i + shift] < 479)

ne sont pas sémantiquement équivalents! En particulier, si vous avez un jour la situation où:

  • 0 <= i Et i < curr.size() sont tous deux vrais
  • curr[i] < 479 Est faux
  • i + shift < 0 Ou i + shift >= l.size() est vrai

alors l'expression (curr[i] < 479) && (l[i + shift] < 479) est garantie d'être une valeur booléenne bien définie. Par exemple, cela ne cause pas d'erreur de segmentation.

Cependant, dans ces circonstances, l'expression (curr[i] < 479) * (l[i + shift] < 479) Est comportement non défini; il est est autorisé à causer une erreur de segmentation.

Cela signifie que, par exemple, pour l'extrait de code d'origine, le compilateur ne peut pas écrire une boucle effectuant les deux comparaisons et effectuant une opération and, à moins que le compilateur puisse également prouver que l[i + shift] ne jamais causer de segfault dans une situation à ne pas faire.

En bref, le code d'origine offre moins d'opportunités d'optimisation que ces dernières. (bien sûr, que le compilateur reconnaisse ou non l'opportunité est une question totalement différente)

Vous pouvez réparer la version originale en faisant plutôt

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...
23
Hurkyl

L'opérateur && Implémente l'évaluation des courts-circuits. Cela signifie que le deuxième opérande n'est évalué que si le premier est évalué à true. Cela entraîne certainement un saut dans ce cas.

Vous pouvez créer un petit exemple pour montrer ceci:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

La sortie de l'assembleur peut être trouvée ici .

Vous pouvez voir que le code généré appelle d'abord f(x), puis vérifie la sortie et passe à l'évaluation de g(x) alors qu'il s'agissait de true. Sinon, il quitte la fonction.

L'utilisation de la multiplication "booléenne" force l'évaluation des deux opérandes à chaque fois et ne nécessite donc pas de saut.

En fonction des données, le saut peut provoquer un ralentissement, car il perturbe le pipeline de la CPU et d'autres tâches, telles que l'exécution spéculative. Normalement, la prédiction de branche est utile, mais si vos données sont aléatoires, vous ne pouvez pas en prédire beaucoup.

17
Jens