61 votes

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

Dans cet extrait de code, je suis une comparaison des performances de deux fonctionnellement identiques boucles:

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 est significativement plus lent que la seconde à travers un certain nombre de différents compilateurs C++ avec optimisation de l'indicateur a la valeur O2:

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

Je suis intrigué par le fait que C++ moderne optimiseurs ont des problèmes pour traiter ce cas. Des indices pourquoi? Dois-je écrire laide de code sans utiliser des variables temporaires afin d'obtenir les meilleures performances?

À l'aide de variables temporaires rend le code plus rapide, parfois de façon spectaculaire, maintenant. Ce qui se passe?

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;
}

50voto

deniss Points 2705

Il semble que le compilateur ne dispose pas des connaissances sur la relation entre std::vector<>::size() interne et vecteur de taille de la mémoire tampon. Envisager std::vector étant notre coutume bugged_vector vecteur-comme l'objet, avec un léger bug de son ::size() peut parfois être un de plus que la taille du tampon interne n, mais seulement ensuite, v[n-2] >= v[n-1].

Ensuite, deux extraits ont des sémantiques différentes de nouveau: la première a un comportement indéfini, comme nous l'accès de l'élément v[v.size() - 1]. Le second, cependant, n'ont pas: à cause d'un court-circuit de la nature de l' &&, nous n'avons jamais lu v[v.size() - 1] sur la dernière itération.

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

En regardant l'assemblée de sortie à partir d' clang, nous pouvons voir que ce qui se passe réellement.

À partir de la Godbolt Compilateur Explorer, avec clang 3.7.0 -O2, la boucle en 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 supplément de saut en f1. Et comme nous l'avons (heureusement nous) savons, conditionnel sauts en boucles serrées sont mauvais pour la performance. (Voir la performance de guides dans la balise wiki pour plus de détails.)

GCC et Visual Studio sont conscients qu' std::vector est bien comporté, et de produire presque à l'identique de l'assemblée pour les deux extraits. Edit. Il s'avère clang n'meilleur travail d'optimisation du code. Tous les trois compilateurs ne peuvent pas prouver qu'il est sécuritaire de le lire en v[i + 1] avant la comparaison dans le deuxième exemple (ou de choisir de ne pas le faire), mais seulement clang réussit à optimiser le premier exemple avec les informations supplémentaires que la lecture de v[i + 1] est soit valide ou UB.

Une différence de performance de 2%, est négligeable, ce qui peut être expliqué par ordre différent ou le choix de certaines instructions.

40voto

amdn Points 5491

Voici des informations supplémentaires pour se développer sur @deniss réponse, qui diagnostiqué correctement la question.

D'ailleurs, ceci est lié à la plus populaire C++ Q&A de tous les temps "Pourquoi le traitement d'un tableau trié de plus qu'un tableau non trié?".

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

5.14 Logique ET l'opérateur [expr.journal.et]

Contrairement &, && garanties de gauche à droite de l'évaluation: la deuxième opérande n'est pas évalué si le premier opérande est faux.
ISO C++14 Standard (projet de N3797)

et à des fins de spéculation lit

1.10 Multi-thread exécutions et les données courses [intro.multithread]

23 [ Note: les Transformations qui introduisent une spéculation lire potentiellement partagé emplacement de mémoire ne peut pas conserver la sémantique du programme C++ tel que défini dans la présente norme, car elles peuvent introduire des données course. Cependant, ils sont généralement valables dans le contexte d'un compilateur optimisant pour une machine spécifique avec une sémantique bien définie pour les données courses. Ils seraient invalides pour une hypothétique machine qui n'est pas tolérant de courses ou fournit du matériel de course de détection. - la note de fin ]
ISO C++14 Standard (projet de N3797)

Ma conjecture est que les optimiseurs de jouer la sécurité et actuellement choisir de ne pas délivrer de spéculation charges potentiellement mémoire partagée plutôt que de cas particulier pour chaque processeur cible de savoir si la spéculation charge pourrait introduire un détectable données course pour la cible.

Pour ce faire, le compilateur génère une branche conditionnelle. Généralement ce n'est pas perceptible car les processeurs modernes ont très sophistiqué direction de la prévision, et les erreurs de prédiction de taux est généralement très faible. Cependant, les données ici est aléatoire - ce qui tue la direction de la prévision. Le coût des erreurs de prédiction est de 10 à 20 cycles CPU, considérant que le CPU est généralement à la retraite 2 instructions par cycle, c'est l'équivalent de 20 à 40 instructions. Si la prédiction taux est de 50% (aléatoire) puis, à chaque itération a un mispredict peine l'équivalent de 10 à 20 instructions - ÉNORME.

Remarque: Le compilateur pourrait prouver que les éléments v[0] de v[v.size()-2] sera référencé, dans cet ordre, quelles que soient les valeurs qu'ils contiennent. Cela permettrait au compilateur dans ce cas à générer du code qui inconditionnelle des charges de tous, mais le dernier élément du vecteur. Le dernier élément du vecteur, à v[v. size()-1], ne peut être chargé dans la dernière itération de la boucle, et que si la première condition est vraie. Le compilateur pourrait donc générer du code pour la boucle, sans le court-circuit de la branche jusqu'à la dernière itération, puis utiliser un code différent avec le court-circuit de la branche pour la dernière itération - qui exigerait que le compilateur sachant que les données sont aléatoires et la direction de la prévision est inutile et que par conséquent, il vaut la peine de s'embêter avec que - les compilateurs ne sont pas aussi sophistiqué encore.

Pour éviter la branche conditionnelle générées par la Logique ET (&&) et éviter de charger les emplacements de mémoire dans les variables locales, nous pouvons changer la Logique ET à l'exploitant dans un bit à Bit ET, de l' 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;
}

Sortie

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 (vivre dans Coliru ici)

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 pourriez vous demander comment le compilateur peut évaluer la comparaison v[i-1] < v[i] sans générer une branche conditionnelle. La réponse dépend de la cible, pour x86 cela est possible en raison de l' SETcc instruction, ce qui génère un un octet résultat, 0 ou 1, selon un état dans le registre EFLAGS, la même condition qui pourrait être utilisé dans une branche conditionnelle, mais sans ramification. Dans le code généré donné par @deniss vous pouvez voir setl généré, qui définit le résultat 1 si la condition "moins" est rencontré, qui est évalué par la précédente comparer instruction:

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

7voto

Richard Hodges Points 1972

f0 et f1 sont sémantiquement différents.

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

Cela empêche le pré-chargement des données dans le but d'évaluer (y) et (au moins sur clang) est à l'origine de l'insertion d'un saut conditionnel, ce qui se traduit dans la branche prédicteur de justesse.

Ajouter un autre 2 tests le prouve.

#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;
}

les 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

2voto

Peter Cordes Points 1375

Aucune réponse à ce jour ont donné une version de f() gcc ou clang peut pleinement optimiser. Ils génèrent de l'asm qui assure à la fois la compare à chaque itération. Voir avec le code de sortie asm sur le Godbolt Compilateur Explorer. (Il est Important de connaissances de base pour la prédiction de la performance de l'asm de sortie: Agner le Brouillard de la microarchitecture du guide, et d'autres liens sur le balise wiki. Comme toujours, il fonctionne généralement mieux pour le profil avec les compteurs de performances pour trouver des étals.)

v[i-1] < v[i] est le travail que nous avons déjà fait la dernière itération, lorsque nous avons évalué v[i] < v[i+1]. En théorie, aider le compilateur connaît que le laisserais mieux optimiser (voir f3()). Dans la pratique, cela finit par vaincre auto-vectorisation dans certains cas, et gcc émet code partielle-s'inscrire dans les stalles, même avec -mtune=core2 où c'est un énorme problème.

Manuellement de levage de l' v.size() - 1 hors de la boucle de la limite supérieure de vérifier semble aider. L'OP f0 et f1 ne fait pas de re-calculer, v.size() depuis le début/la fin des pointeurs en v, mais de toute façon il optimise encore moins bien que pour le calcul de la size_t upper = v.size() - 1 en dehors de la boucle (f2() et f4()).

Une question distincte est que l'utilisation d'un int compteur de boucle avec un size_t limite supérieure signifie que la boucle est potentiellement infinie. Je ne suis pas sûr de l'impact que cela a sur d'autres optimisations.


Bottom line: les compilateurs sont des choses complexes. La prédiction de la version permettra d'optimiser le bien n'est pas du tout évident ou simple.


Les résultats sur 64 bits Ubuntu 15.10, sur Core 2 E6600 (Merom/Conroe microarchitecture).

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 les processeurs Intel de la Bns-quincaillerie familiale, esp. IvyBridge et plus tard, où il n'y aurait pas partielle registre des ralentissements. Core2 est limitée par la lenteur des non alignés charges, et une seule charge par cycle. Les boucles peuvent être assez petit pour décoder n'est pas un problème, cependant.


f0 et f1:

gcc 5.2: L'OP f0 et f1 à la fois de rendre branchu boucles, et ne sont pas auto-vectorisation. f0 seulement utilise une direction générale, cependant, et utilise un étrange setl sil / cmp sil, 1 / sbb eax, -1 pour la deuxième moitié du court-circuit de comparer. C'est donc toujours faire les deux comparaisons à chaque itération.

clang 3.8: f0: une seule charge par itération, mais ne les deux et compare ands ensemble. f1: les deux compare chaque itération, l'un avec une branche de préserver le C de la sémantique. 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: auto-vectorizes, avec trois charges d'obtenir les trois vecteurs de décalage nécessaire pour produire un vecteur de 4 de comparer les résultats. Aussi, après en combinant les résultats de deux pcmpgtd instructions, les compare à l'encontre d'un vecteur de tous les zéro et les masques. Le zéro est déjà l'identité de l'élément de plus, donc, c'est vraiment idiot.

clang 3.8 -O3: déroule: chaque itération n'deux charges, 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: autovectorizes comme f2, mais sans les extra - pcmpeqd.
  • gcc 5.2 -O2: ne pas chercher à savoir pourquoi c'est deux fois plus rapide que l' f2.
  • clang -O3: sur le même code que f2.

Tentative de compilateur se tenir par la main

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: se Déroule avec 4 charges à l'intérieur de la boucle (clang typiquement aime se dérouler en 4 quand il n'y a pas de complexe boucle-effectuer les dépendances).
    4 cmp/setcc, 4x et/movzx, 4x ajouter. Donc clang a fait exactement ce que j'espérais, et de fait quasi-optimale scalaire code. Ce fut le plus rapide non-vectorisé version, et (sur core2 où movups non alignés charges sont lentes) est aussi rapide que gcc est vectorisé versions.

  • gcc 5.2 -O3: Échec de l'auto-vectorisation. Ma théorie est que l'accès à la matrice de l'extérieur de la boucle confond l'auto-vectorizer. Peut-être parce que nous ne elle avant de vérifier l' v.size(), ou peut-être juste en général.

    Compile le scalaire code nous espérer, avec une charge, un cmp/setcc, et un and par itération. Mais gcc crée un partiel-registre de décrochage, même avec -mtune=core2 où c'est un énorme problème (2 à 3 cycle de décrochage pour insérer une fusion uop lors de la lecture d'un large reg après avoir écrit une partie seulement de celui-ci). (setcc est uniquement disponible avec un 8-bit opérande de la taille, de l'OMI est quelque chose d'AMD devrait avoir changé quand ils ont conçu le AMD64 ISA.) C'est la raison principale pourquoi du ccg code s'exécute 2,5 x plus lent que 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

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X