web-dev-qa-db-fra.com

Pourquoi les optimiseurs C ++ ont-ils des problèmes avec ces variables temporaires ou plutôt pourquoi `v []` devrait être évité dans les boucles serrées?

Dans ce extrait de code , je compare les performances de deux boucles fonctionnellement identiques:

for (int i = 1; i < v.size()-1; ++i) {
  int a = v[i-1];
  int b = v[i];
  int c = v[i+1];

  if (a < b  &&  b < c)
    ++n;
}

et

for (int i = 1; i < v.size()-1; ++i) 
  if (v[i-1] < v[i]  &&  v[i] < v[i+1])
    ++n;

Le premier s'exécute beaucoup plus lentement que le second sur un certain nombre de compilateurs C++ différents avec l'indicateur d'optimisation défini sur O2:

  • la deuxième boucle est environ 330% plus lente maintenant avec Clang 3.7.0
  • la deuxième boucle est environ 2% plus lente avec gcc 4.9.3
  • la deuxième boucle est environ 2% plus lente avec Visual C++ 2015

Je suis perplexe que les optimiseurs C++ modernes aient des problèmes à gérer ce cas. Des indices pourquoi? Dois-je écrire du code laid sans utiliser de variables temporaires pour obtenir les meilleures performances?

L'utilisation de variables temporaires accélère le code, parfois de façon spectaculaire, maintenant. Que se passe-t-il?

Le code complet que j'utilise est fourni ci-dessous:

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) {
    int a = v[i-1];
    int b = v[i];
    int c = v[i+1];

    if (a < b  &&  b < c)
      ++n;
  }

  return n;
}


int f1()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
    if (v[i-1] < v[i]  &&  v[i] < v[i+1])
      ++n;

  return n;
}


int main()
{
  auto benchmark = [](int (*f)()) {
    const int N = 100;

    volatile long long result = 0;
    vector<long long>  timings(N);

    for (int i = 0; i < N; ++i) {
      auto t0 = high_resolution_clock::now(); 
      result += f();
      auto t1 = high_resolution_clock::now(); 

      timings[i] = duration_cast<nanoseconds>(t1-t0).count();
    }

    sort(timings.begin(), timings.end());
    cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
    cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
  };

  mt19937                    generator   (31415);   // deterministic seed
  uniform_int_distribution<> distribution(0, 1023);

  for (auto& e: v) 
    e = distribution(generator);

  benchmark(f0);
  benchmark(f1);

  cout << "\ndone\n";

  return 0;
}
62
Paul Jurczak

Il semble que le compilateur manque de connaissances sur la relation entre std::vector<>::size() et la taille du tampon vectoriel interne. Considérez std::vector Comme notre objet vectoriel personnalisé bugged_vector Avec un léger bug - sa ::size() peut parfois être supérieure à la taille du tampon interne n, mais seulement puis v[n-2] >= v[n-1].

Ensuite, deux extraits ont à nouveau une sémantique différente: le premier a un comportement indéfini, car nous accédons à l'élément v[v.size() - 1]. Le second, cependant, n'a pas: en raison de la nature de court-circuit de &&, Nous ne lisons jamais v[v.size() - 1] lors de la dernière itération.

Donc, si le compilateur ne peut pas prouver que notre v n'est pas un bugged_vector, Il doit court-circuiter, ce qui introduit un saut supplémentaire dans un code machine.

En regardant la sortie Assembly de clang, nous pouvons voir que cela se produit réellement.

De Godbolt Compiler Explorer , avec clang 3.7.0 -O2, la boucle dans f0 Est:

### f0: just the loop
.LBB1_2:                                # =>This Inner Loop Header: Depth=1
    mov     edi, ecx
    cmp     edx, edi
    setl    r10b
    mov     ecx, dword ptr [r8 + 4*rsi + 4]
    lea     rsi, [rsi + 1]
    cmp     edi, ecx
    setl    dl
    and     dl, r10b
    movzx   edx, dl
    add     eax, edx
    cmp     rsi, r9
    mov     edx, edi
    jb      .LBB1_2

Et pour f1:

### f1: just the loop
.LBB2_2:                                # =>This Inner Loop Header: Depth=1
    mov     esi, r10d
    mov     r10d, dword ptr [r9 + 4*rdi]
    lea     rcx, [rdi + 1]
    cmp     esi, r10d
    jge     .LBB2_4                     # <== This is Extra Jump
    cmp     r10d, dword ptr [r9 + 4*rdi + 4]
    setl    dl
    movzx   edx, dl
    add     eax, edx
.LBB2_4:                                # %._crit_Edge.3
    cmp     rcx, r8
    mov     rdi, rcx
    jb      .LBB2_2

J'ai souligné le saut supplémentaire dans f1. Et comme nous le savons (espérons-le), les sauts conditionnels dans des boucles serrées sont mauvais pour les performances. (Voir les guides de performances dans le wiki de balise x86 pour plus de détails.)

GCC et Visual Studio savent que std::vector Se comporte bien et produisent un assembly presque identique pour les deux extraits.  Modifier . Il s'avère que clang fait mieux d'optimiser le code. Les trois compilateurs ne peuvent pas prouver qu'il est sûr de lire v[i + 1] Avant la comparaison dans le deuxième exemple (ou choisissez de ne pas le faire), mais seul clang parvient à optimiser le premier exemple avec le supplément informations indiquant que la lecture de v[i + 1] est valide ou UB.

Une différence de performance de 2% est négligeable peut s'expliquer par un ordre différent ou le choix de certaines instructions.

50
deniss

Voici des informations supplémentaires pour développer la réponse de @deniss, qui a correctement diagnostiqué le problème.

Soit dit en passant, cela est lié à le Q&A C++ le plus populaire de tous les temps "Pourquoi le traitement d'un tableau trié est-il plus rapide qu'un tableau non trié?" .

Le problème principal est que le compilateur doit honorer l'opérateur logique ET (&&) et ne pas charger à partir de v [i + 1] sauf si la première condition est vraie. Ceci est une conséquence de la sémantique de l'opérateur logique ET ainsi que de la sémantique du modèle de mémoire resserrée introduite avec C++ 11, les clauses pertinentes du projet de norme sont les suivantes:

5.14 Opérateur ET logique [expr.log.and]

Contrairement à & , && garantit une évaluation de gauche à droite: la seconde l'opérande n'est pas évalué si le premier opérande est faux .
Norme ISO C++ 14 (projet N3797)

et pour les lectures spéculatives

1.10 Exécutions multithread et courses de données [intro.multithread]

23 [ Remarque: Les transformations qui introduisent une lecture spéculative d'un emplacement de mémoire potentiellement partagée peuvent ne pas conserver la sémantique du programme C++ tel que défini dans cette norme, car ils introduisent potentiellement une course aux données. Cependant, ils sont généralement valides dans le contexte d'un compilateur d'optimisation qui cible une machine spécifique avec une sémantique bien définie pour les courses de données. Ils seraient invalides pour une machine hypothétique qui ne tolère pas les courses ou qui fournit une détection de course matérielle. - note de fin]
Norme ISO C++ 14 (projet N3797)

Je suppose que les optimiseurs sont prudents et choisissent actuellement de ne pas émettre de charges spéculatives dans la mémoire potentiellement partagée plutôt que dans le cas spécial de chaque processeur cible, si la charge spéculative pourrait introduire une course de données détectable pour cette cible.

Afin de l'implémenter, le compilateur génère une branche conditionnelle. Habituellement, cela n'est pas perceptible car les processeurs modernes ont une prédiction de branche très sophistiquée, et le taux d'erreurs de prédiction est généralement très faible. Cependant, les données ici sont aléatoires - cela tue la prédiction de branche. Le coût d'une mauvaise prévision est de 10 à 20 cycles de CPU, étant donné que le CPU retire généralement 2 instructions par cycle, ce qui équivaut à 20 à 40 instructions. Si le taux de prédiction est de 50% (aléatoire), chaque itération a une pénalité de mauvais pronostic équivalente à 10 à 20 instructions - [~ # ~] énorme [~ # ~] .

Remarque: Le compilateur pourrait prouver que les éléments v[0] À v[v.size()-2] seront référencés, dans cet ordre, indépendamment de les valeurs qu'ils contiennent. Cela permettrait au compilateur dans ce cas de générer du code qui charge inconditionnellement tout sauf le dernier élément du vecteur. Le dernier élément du vecteur, à v [v.size () - 1], ne peut être chargé qu'à la dernière itération de la boucle et uniquement si la première condition est vraie. Le compilateur pourrait donc générer du code pour la boucle sans la branche de court-circuit jusqu'à la dernière itération, puis utiliser un code différent avec la branche de court-circuit pour la dernière itération - cela nécessiterait que le compilateur sache que les données sont aléatoires et que la prédiction de branche est inutile et donc qu'il vaut la peine de s'en préoccuper - les compilateurs ne sont pas si sophistiqués - pour l'instant.

Pour éviter la branche conditionnelle générée par le ET logique (&&) et éviter de charger les emplacements de mémoire dans des variables locales, nous pouvons changer l'opérateur ET logique en un ET au niveau du bit, extrait de code ici , le résultat est presque 4x plus rapide lorsque les données sont aléatoires

int f2()
{
  int n = 0;

  for (int i = 1; i < v.size()-1; ++i) 
     n += (v[i-1] < v[i])  &  (v[i] < v[i+1]); // Bitwise AND

  return n;
}

Production

3.642443ms min
3.779982ms median
Result: 166634

3.725968ms min
3.870808ms median
Result: 166634

1.052786ms min
1.081085ms median
Result: 166634


done

Le résultat sur gcc 5.3 est 8x plus rapide ( live in Coliru here )

g++ --version
g++ -std=c++14  -O3 -Wall -Wextra -pedantic -pthread -pedantic-errors main.cpp -lm  && ./a.out
g++ (GCC) 5.3.0
Copyright (C) 2015 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

3.761290ms min
4.025739ms median
Result: 166634

3.823133ms min
4.050742ms median
Result: 166634

0.459393ms min
0.505011ms median
Result: 166634


done

Vous vous demandez peut-être comment le compilateur peut évaluer la comparaison v[i-1] < v[i] sans en générant une branche conditionnelle. La réponse dépend de la cible, pour x86, cela est possible grâce à l'instruction SETcc, qui génère un résultat d'un octet, 0 ou 1, selon une condition du registre EFLAGS, la même condition qui pourrait être utilisée dans une branche conditionnelle, mais sans ramification. Dans le code généré par @deniss, vous pouvez voir setl généré, qui définit le résultat sur 1 si la condition "inférieur à" est remplie, ce qui est évalué par l'instruction de comparaison précédente:

cmp     edx, edi       ; a < b ?
setl    r10b           ; r10b = a < b ? 1 : 0
mov     ecx, dword ptr [r8 + 4*rsi + 4] ; c = v[i+1]
lea     rsi, [rsi + 1] ; ++i
cmp     edi, ecx       ; b < c ?
setl    dl             ; dl = b < c ? 1 : 0
and     dl, r10b       ; dl &= r10b
movzx   edx, dl        ; edx = zero extended dl
add     eax, edx       ; n += edx
41
amdn

f0 et f1 sont sémantiquement différents.

x() && y() implique un court-circuit dans le cas où x() est faux comme nous le savons. Cela signifie que si x() est faux, alors y() ne doit pas être évalué.

Cela empêche la prélecture des données afin d'évaluer y() et (au moins sur clang) provoque l'insertion d'un saut conditionnel, ce qui entraîne des échecs de prédicteur de branche.

L'ajout de 2 autres tests prouve le point.

#include <algorithm>
#include <chrono>
#include <random>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;
using namespace std::chrono;

vector<int> v(1'000'000);

int f0()
{
    int n = 0;

    for (int i = 1; i < v.size()-1; ++i) {
        int a = v[i-1];
        int b = v[i];
        int c = v[i+1];

        if (a < b  &&  b < c)
            ++n;
    }

    return n;
}


int f1()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
        if (v[i-1] < v[i]  &&  v[i] < v[i+1])
            ++n;

    return n;
}

int f2()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        auto t1 = v[i-1] < v[i];
        auto t2 = v[i] < v[i+1];
        if (t1 && t2)
            ++n;
    }

    return n;
}

int f3()
{
    int n = 0;

    auto s = v.size() - 1;
    for (size_t i = 1; i < s; ++i)
    {
        n += 1 * (v[i-1] < v[i]) * (v[i] < v[i+1]);
    }

    return n;
}



int main()
{
    auto benchmark = [](int (*f)()) {
        const int N = 100;

        volatile long long result = 0;
        vector<long long>  timings(N);

        for (int i = 0; i < N; ++i) {
            auto t0 = high_resolution_clock::now();
            result += f();
            auto t1 = high_resolution_clock::now();

            timings[i] = duration_cast<nanoseconds>(t1-t0).count();
        }

        sort(timings.begin(), timings.end());
        cout << fixed << setprecision(6) << timings.front()/1'000'000.0 << "ms min\n";
        cout << timings[timings.size()/2]/1'000'000.0 << "ms median\n" << "Result: " << result/N << "\n\n";
    };

    mt19937                    generator   (31415);   // deterministic seed
    uniform_int_distribution<> distribution(0, 1023);

    for (auto& e: v) 
        e = distribution(generator);

    benchmark(f0);
    benchmark(f1);
    benchmark(f2);
    benchmark(f3);

    cout << "\ndone\n";

    return 0;
}

résultats (Apple clang, -O2):

1.233948ms min
1.320545ms median
Result: 166850

3.366751ms min
3.493069ms median
Result: 166850

1.261948ms min
1.361748ms median
Result: 166850

1.251434ms min
1.353653ms median
Result: 166850
7
Richard Hodges

Jusqu'à présent, aucune des réponses n'a donné une version de f() que gcc ou clang peut optimiser pleinement. Ils génèrent tous asm qui fait à la fois compare chaque itération. Voir le code avec la sortie asm sur Godbolt Compiler Explorer . (Connaissances de base importantes pour prédire les performances à partir de la sortie asm: guide de microarchitecture d'Agner Fog , et d'autres liens sur le wiki de la balise x86 . compteurs pour trouver des stands.)

v[i-1] < v[i] Est un travail que nous avons déjà effectué lors de la dernière itération, lorsque nous avons évalué v[i] < v[i+1]. En théorie, aider le compilateur à grogner pour mieux l'optimiser (voir f3()). En pratique, cela finit par vaincre la vectorisation automatique dans certains cas, et gcc émet du code avec des blocages de registres partiels, même avec -mtune=core2 Où c'est un énorme problème.

Hisser manuellement la v.size() - 1 hors de la vérification de la limite supérieure de la boucle semble aider. f0 Et f1 De l'OP ne recalculent pas réellement v.size() à partir des pointeurs de début/fin dans v, mais en quelque sorte, il optimise toujours moins bien que lors du calcul d'une size_t upper = v.size() - 1 en dehors de la boucle (f2() et f4()).

Un autre problème est que l'utilisation d'un compteur de boucles int avec une borne supérieure size_t Signifie que la boucle est potentiellement infinie. Je ne sais pas quel impact cela a sur d'autres optimisations.


Conclusion: les compilateurs sont des bêtes complexes . Il n'est pas du tout évident ou simple de prédire quelle version sera optimisée.


Résultats sur Ubuntu 15.10 64 bits, sur Core2 E6600 (microarchitecture Merom/Conroe).

clang++-3.8 -O3 -march=core2   |   g++ 5.2 -O3 -march=core2         | gcc 5.2 -O2 (default -mtune=generic)
f0    1.825ms min(1.858 med)   |   5.008ms min(5.048 med)           | 5.000 min(5.028 med)
f1    4.637ms min(4.673 med)   |   4.899ms min(4.952 med)           | 4.894 min(4.931 med)
f2    1.292ms min(1.323 med)   |   1.058ms min(1.088 med) (autovec) | 4.888 min(4.912 med)
f3    1.082ms min(1.117 med)   |   2.426ms min(2.458 med)           | 2.420 min(2.465 med)
f4    1.291ms min(1.341 med)   |   1.022ms min(1.052 med) (autovec) | 2.529 min(2.560 med)

Les résultats seraient différents sur le matériel de la famille Intel SnB, en particulier. IvyBridge et plus tard, où il n'y aurait aucun ralentissement partiel du registre. Core2 est limité par des charges lentes non alignées et une seule charge par cycle. Les boucles peuvent être suffisamment petites pour que le décodage ne soit pas un problème, cependant.


f0 Et f1:

gcc 5.2: Les OP f0 et f1 font tous deux des boucles branchées et ne se vectoriseront pas automatiquement. f0 N'utilise cependant qu'une seule branche et utilise un étrange setl sil/cmp sil, 1/sbb eax, -1 Pour effectuer la deuxième moitié de la comparaison de court-circuit. Il fait donc toujours les deux comparaisons à chaque itération.

clang 3.8: f0: une seule charge par itération, mais les compare et ands ensemble. f1: Les deux comparent chaque itération, une avec une branche pour préserver la sémantique C. Deux charges par itération.


int f2() {
  int n = 0;
  size_t upper = v.size()-1;   // difference from f0: hoist upper bound and use size_t loop counter
  for (size_t i = 1; i < upper; ++i) {
    int a = v[i-1], b = v[i], c = v[i+1];
    if (a < b  &&  b < c)
      ++n;
  }
  return n;
}

gcc 5.2 -O3: vectorisation automatique, avec trois charges pour obtenir les trois vecteurs de décalage nécessaires pour produire un vecteur de 4 résultats de comparaison. De plus, après avoir combiné les résultats de deux instructions pcmpgtd, les compare avec un vecteur de zéro, puis les masque. Zéro est déjà l'élément d'identité à ajouter, donc c'est vraiment idiot.

clang 3.8 -O3: déroule: chaque itération effectue deux chargements, trois cmp/setcc, deux ands et deux adds.


int f4() {
  int n = 0;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int a = v[i-1], b = v[i], c = v[i+1];
      bool ab_lt = a < b;
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);  // some really minor code-gen differences from f2: auto-vectorizes to better code that runs slightly faster even for this large problem size
  }

  return n;
}
  • gcc 5.2 -O3: autovectorise comme f2, mais sans extra pcmpeqd.
  • gcc 5.2 -O2: n'a pas cherché à savoir pourquoi cela est deux fois plus rapide que f2.
  • clang -O3: à peu près le même code que f2.

Tentative de tenue de main du compilateur

int f3() {
  int n = 0;
  int a = v[0], b = v[1];   // These happen before checking v.size, defeating the loop vectorizer or something
  bool ab_lt = a < b;

  size_t upper = v.size()-1;
  for (size_t i = 1; i < upper; ++i) {
      int c = v[i+1];       // only one load and compare inside the loop
      bool bc_lt = b < c;

      n += (ab_lt & bc_lt);

      ab_lt = bc_lt;
      a = b;                // unused inside the loop, only the compare result is needed
      b = c;
  }
  return n;
}
  • clang 3.8 -O3: Déroule avec 4 charges à l'intérieur de la boucle (clang aime généralement se dérouler par 4 lorsqu'il n'y a pas de dépendances complexes portées par la boucle).
    4 cmp/setcc, 4x et/movzx, 4x add. Clang a donc fait exactement ce que j'espérais et a créé un code scalaire presque optimal. C'était la version non vectorisée la plus rapide , et (sur core2 où movups les charges non alignées sont lentes) est aussi rapide que les versions vectorisées de gcc .

  • gcc 5.2 -O3: Échec de la vectorisation automatique. Ma théorie à ce sujet est que l'accès au tableau en dehors de la boucle confond l'auto-vectoriseur. Peut-être parce que nous le faisons avant de vérifier v.size(), ou peut-être juste en général.

    Compile le code scalaire que nous espérons, avec une charge, un cmp/setcc et un and par itération. Mais gcc crée un décrochage à registre partiel , même avec -mtune=core2 Où c'est un énorme problème (décrochage de 2 à 3 cycles pour insérer une fusion uop lors de la lecture d'un large reg après avoir écrit seulement une partie de celui-ci). (setcc est uniquement disponible avec une taille d'opérande 8 bits, ce qu'IMO aurait dû changer lors de la conception de l'AMA AMD64.) C'est la principale raison pour laquelle le code de gcc s'exécute 2,5 fois plus lentement que celui de clang.

## the loop in f3(), from gcc 5.2 -O3 (same code with -O2)
.L31:
    add     rcx, 1    # i,
    mov     edi, DWORD PTR [r10+rcx*4]        # a, MEM[base: _19, index: i_13, step: 4, offset: 0]
    cmp     edi, r8d  # a, a                 # gcc's verbose-asm comments are a bit bogus here: one of these `a`s is from the last iteration, so this is really comparing c, b
    mov     r8d, edi  # a, a
    setg    sil     #, tmp124
    and     edx, esi  # D.111089, tmp124     # PARTIAL-REG STALL: reading esi after writing sil
    movzx   edx, dl                          # using movzx to widen sil to esi would have solved the problem, instead of doing it after the and
    add     eax, edx  # n, D.111085          # n += ...
    cmp     r9, rcx   # upper, i
    mov     edx, esi  # ab_lt, tmp124
    jne     .L31      #,
    ret

2
Peter Cordes