std :: régression des performances vectorielles lors de l'activation de C ++ 11

235

J'ai trouvé une régression des performances intéressante dans un petit extrait C ++, lorsque j'active C ++ 11:

#include <vector>

struct Item
{
  int a;
  int b;
};

int main()
{
  const std::size_t num_items = 10000000;
  std::vector<Item> container;
  container.reserve(num_items);
  for (std::size_t i = 0; i < num_items; ++i) {
    container.push_back(Item());
  }
  return 0;
}

Avec g ++ (GCC) 4.8.2 20131219 (version préliminaire) et C ++ 03 j'obtiens:

milian:/tmp$ g++ -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        35.206824 task-clock                #    0.988 CPUs utilized            ( +-  1.23% )
                4 context-switches          #    0.116 K/sec                    ( +-  4.38% )
                0 cpu-migrations            #    0.006 K/sec                    ( +- 66.67% )
              849 page-faults               #    0.024 M/sec                    ( +-  6.02% )
       95,693,808 cycles                    #    2.718 GHz                      ( +-  1.14% ) [49.72%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
       95,282,359 instructions              #    1.00  insns per cycle          ( +-  0.65% ) [75.27%]
       30,104,021 branches                  #  855.062 M/sec                    ( +-  0.87% ) [77.46%]
            6,038 branch-misses             #    0.02% of all branches          ( +- 25.73% ) [75.53%]

      0.035648729 seconds time elapsed                                          ( +-  1.22% )

Avec C ++ 11 activé en revanche, les performances se dégradent considérablement:

milian:/tmp$ g++ -std=c++11 -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        86.485313 task-clock                #    0.994 CPUs utilized            ( +-  0.50% )
                9 context-switches          #    0.104 K/sec                    ( +-  1.66% )
                2 cpu-migrations            #    0.017 K/sec                    ( +- 26.76% )
              798 page-faults               #    0.009 M/sec                    ( +-  8.54% )
      237,982,690 cycles                    #    2.752 GHz                      ( +-  0.41% ) [51.32%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
      135,730,319 instructions              #    0.57  insns per cycle          ( +-  0.32% ) [75.77%]
       30,880,156 branches                  #  357.057 M/sec                    ( +-  0.25% ) [75.76%]
            4,188 branch-misses             #    0.01% of all branches          ( +-  7.59% ) [74.08%]

    0.087016724 seconds time elapsed                                          ( +-  0.50% )

Quelqu'un peut-il expliquer cela? Jusqu'à présent, mon expérience était que la STL devient plus rapide en activant C ++ 11, en particulier. grâce à déplacer la sémantique.

EDIT: Comme suggéré, en utilisant à la container.emplace_back();place les performances sont comparables à la version C ++ 03. Comment la version C ++ 03 peut-elle obtenir la même chose push_back?

milian:/tmp$ g++ -std=c++11 -O3 main.cpp && perf stat -r 10 ./a.out

Performance counter stats for './a.out' (10 runs):

        36.229348 task-clock                #    0.988 CPUs utilized            ( +-  0.81% )
                4 context-switches          #    0.116 K/sec                    ( +-  3.17% )
                1 cpu-migrations            #    0.017 K/sec                    ( +- 36.85% )
              798 page-faults               #    0.022 M/sec                    ( +-  8.54% )
       94,488,818 cycles                    #    2.608 GHz                      ( +-  1.11% ) [50.44%]
  <not supported> stalled-cycles-frontend 
  <not supported> stalled-cycles-backend  
       94,851,411 instructions              #    1.00  insns per cycle          ( +-  0.98% ) [75.22%]
       30,468,562 branches                  #  840.991 M/sec                    ( +-  1.07% ) [76.71%]
            2,723 branch-misses             #    0.01% of all branches          ( +-  9.84% ) [74.81%]

   0.036678068 seconds time elapsed                                          ( +-  0.80% )
milianw
la source
1
Si vous compilez pour l'assemblage, vous pouvez voir ce qui se passe sous le capot. Voir aussi stackoverflow.com/questions/8021874/…
Cogwheel
8
Que se passe-t-il si vous passez push_back(Item())à emplace_back()la version C ++ 11?
Cogwheel
8
Voir ci-dessus, qui "corrige" la régression. Je me demande toujours pourquoi push_back régresse dans les performances entre C ++ 03 et C ++ 11.
milianw
1
@milianw Il s'avère que je compilais le mauvais programme. Ignorez mes commentaires.
2
Avec clang3.4, la version C ++ 11 est plus rapide, 0,047 s contre 0,058 pour la version C ++ 98
Praetorian

Réponses:

247

Je peux reproduire vos résultats sur ma machine avec les options que vous écrivez dans votre message.

Cependant, si j'active également l' optimisation du temps de liaison (je passe également le -fltodrapeau à gcc 4.7.2), les résultats sont identiques:

(Je compile votre code d'origine, avec container.push_back(Item());)

$ g++ -std=c++11 -O3 -flto regr.cpp && perf stat -r 10 ./a.out 

 Performance counter stats for './a.out' (10 runs):

         35.426793 task-clock                #    0.986 CPUs utilized            ( +-  1.75% )
                 4 context-switches          #    0.116 K/sec                    ( +-  5.69% )
                 0 CPU-migrations            #    0.006 K/sec                    ( +- 66.67% )
            19,801 page-faults               #    0.559 M/sec                  
        99,028,466 cycles                    #    2.795 GHz                      ( +-  1.89% ) [77.53%]
        50,721,061 stalled-cycles-frontend   #   51.22% frontend cycles idle     ( +-  3.74% ) [79.47%]
        25,585,331 stalled-cycles-backend    #   25.84% backend  cycles idle     ( +-  4.90% ) [73.07%]
       141,947,224 instructions              #    1.43  insns per cycle        
                                             #    0.36  stalled cycles per insn  ( +-  0.52% ) [88.72%]
        37,697,368 branches                  # 1064.092 M/sec                    ( +-  0.52% ) [88.75%]
            26,700 branch-misses             #    0.07% of all branches          ( +-  3.91% ) [83.64%]

       0.035943226 seconds time elapsed                                          ( +-  1.79% )



$ g++ -std=c++98 -O3 -flto regr.cpp && perf stat -r 10 ./a.out 

 Performance counter stats for './a.out' (10 runs):

         35.510495 task-clock                #    0.988 CPUs utilized            ( +-  2.54% )
                 4 context-switches          #    0.101 K/sec                    ( +-  7.41% )
                 0 CPU-migrations            #    0.003 K/sec                    ( +-100.00% )
            19,801 page-faults               #    0.558 M/sec                    ( +-  0.00% )
        98,463,570 cycles                    #    2.773 GHz                      ( +-  1.09% ) [77.71%]
        50,079,978 stalled-cycles-frontend   #   50.86% frontend cycles idle     ( +-  2.20% ) [79.41%]
        26,270,699 stalled-cycles-backend    #   26.68% backend  cycles idle     ( +-  8.91% ) [74.43%]
       141,427,211 instructions              #    1.44  insns per cycle        
                                             #    0.35  stalled cycles per insn  ( +-  0.23% ) [87.66%]
        37,366,375 branches                  # 1052.263 M/sec                    ( +-  0.48% ) [88.61%]
            26,621 branch-misses             #    0.07% of all branches          ( +-  5.28% ) [83.26%]

       0.035953916 seconds time elapsed  

Quant aux raisons, il faut regarder le code assembleur généré ( g++ -std=c++11 -O3 -S regr.cpp). En mode C ++ 11, le code généré est nettement plus encombré que pour le mode C ++ 98 et l' inclusion de la fonction
void std::vector<Item,std::allocator<Item>>::_M_emplace_back_aux<Item>(Item&&)
échoue en mode C ++ 11 avec la valeur par défaut inline-limit.

Cet échec en ligne a un effet domino. Non pas parce que cette fonction est appelée (elle n'est même pas appelée!) Mais parce que nous devons être préparés: si elle est appelée, les arguments de la fonction ( Item.aet Item.b) doivent déjà être au bon endroit. Cela conduit à un code assez désordonné.

Voici la partie pertinente du code généré pour le cas où l' inline réussit :

.L42:
    testq   %rbx, %rbx  # container$D13376$_M_impl$_M_finish
    je  .L3 #,
    movl    $0, (%rbx)  #, container$D13376$_M_impl$_M_finish_136->a
    movl    $0, 4(%rbx) #, container$D13376$_M_impl$_M_finish_136->b
.L3:
    addq    $8, %rbx    #, container$D13376$_M_impl$_M_finish
    subq    $1, %rbp    #, ivtmp.106
    je  .L41    #,
.L14:
    cmpq    %rbx, %rdx  # container$D13376$_M_impl$_M_finish, container$D13376$_M_impl$_M_end_of_storage
    jne .L42    #,

C'est une boucle sympa et compacte. Maintenant, comparons ceci à celui du cas inline échoué :

.L49:
    testq   %rax, %rax  # D.15772
    je  .L26    #,
    movq    16(%rsp), %rdx  # D.13379, D.13379
    movq    %rdx, (%rax)    # D.13379, *D.15772_60
.L26:
    addq    $8, %rax    #, tmp75
    subq    $1, %rbx    #, ivtmp.117
    movq    %rax, 40(%rsp)  # tmp75, container.D.13376._M_impl._M_finish
    je  .L48    #,
.L28:
    movq    40(%rsp), %rax  # container.D.13376._M_impl._M_finish, D.15772
    cmpq    48(%rsp), %rax  # container.D.13376._M_impl._M_end_of_storage, D.15772
    movl    $0, 16(%rsp)    #, D.13379.a
    movl    $0, 20(%rsp)    #, D.13379.b
    jne .L49    #,
    leaq    16(%rsp), %rsi  #,
    leaq    32(%rsp), %rdi  #,
    call    _ZNSt6vectorI4ItemSaIS0_EE19_M_emplace_back_auxIIS0_EEEvDpOT_   #

Ce code est encombré et il se passe beaucoup plus de choses dans la boucle que dans le cas précédent. Avant la fonction call(dernière ligne affichée), les arguments doivent être placés de manière appropriée:

leaq    16(%rsp), %rsi  #,
leaq    32(%rsp), %rdi  #,
call    _ZNSt6vectorI4ItemSaIS0_EE19_M_emplace_back_auxIIS0_EEEvDpOT_   #

Même si cela n'est jamais réellement exécuté, la boucle organise les choses avant:

movl    $0, 16(%rsp)    #, D.13379.a
movl    $0, 20(%rsp)    #, D.13379.b

Cela conduit au code désordonné. S'il n'y a pas de fonction callparce que l'inline réussit, nous n'avons que 2 instructions de déplacement dans la boucle et il n'y a pas de problème avec le %rsp(pointeur de pile). Cependant, si l'inline échoue, nous obtenons 6 coups et nous gâchons beaucoup avec le %rsp.

Juste pour étayer ma théorie (notez la -finline-limit), à la fois en mode C ++ 11:

 $ g++ -std=c++11 -O3 -finline-limit=105 regr.cpp && perf stat -r 10 ./a.out

 Performance counter stats for './a.out' (10 runs):

         84.739057 task-clock                #    0.993 CPUs utilized            ( +-  1.34% )
                 8 context-switches          #    0.096 K/sec                    ( +-  2.22% )
                 1 CPU-migrations            #    0.009 K/sec                    ( +- 64.01% )
            19,801 page-faults               #    0.234 M/sec                  
       266,809,312 cycles                    #    3.149 GHz                      ( +-  0.58% ) [81.20%]
       206,804,948 stalled-cycles-frontend   #   77.51% frontend cycles idle     ( +-  0.91% ) [81.25%]
       129,078,683 stalled-cycles-backend    #   48.38% backend  cycles idle     ( +-  1.37% ) [69.49%]
       183,130,306 instructions              #    0.69  insns per cycle        
                                             #    1.13  stalled cycles per insn  ( +-  0.85% ) [85.35%]
        38,759,720 branches                  #  457.401 M/sec                    ( +-  0.29% ) [85.43%]
            24,527 branch-misses             #    0.06% of all branches          ( +-  2.66% ) [83.52%]

       0.085359326 seconds time elapsed                                          ( +-  1.31% )

 $ g++ -std=c++11 -O3 -finline-limit=106 regr.cpp && perf stat -r 10 ./a.out

 Performance counter stats for './a.out' (10 runs):

         37.790325 task-clock                #    0.990 CPUs utilized            ( +-  2.06% )
                 4 context-switches          #    0.098 K/sec                    ( +-  5.77% )
                 0 CPU-migrations            #    0.011 K/sec                    ( +- 55.28% )
            19,801 page-faults               #    0.524 M/sec                  
       104,699,973 cycles                    #    2.771 GHz                      ( +-  2.04% ) [78.91%]
        58,023,151 stalled-cycles-frontend   #   55.42% frontend cycles idle     ( +-  4.03% ) [78.88%]
        30,572,036 stalled-cycles-backend    #   29.20% backend  cycles idle     ( +-  5.31% ) [71.40%]
       140,669,773 instructions              #    1.34  insns per cycle        
                                             #    0.41  stalled cycles per insn  ( +-  1.40% ) [88.14%]
        38,117,067 branches                  # 1008.646 M/sec                    ( +-  0.65% ) [89.38%]
            27,519 branch-misses             #    0.07% of all branches          ( +-  4.01% ) [86.16%]

       0.038187580 seconds time elapsed                                          ( +-  2.05% )

En effet, si nous demandons au compilateur de faire un peu plus d'efforts pour intégrer cette fonction, la différence de performances disparaît.


Alors, quelle est la conclusion de cette histoire? Les échecs en ligne peuvent vous coûter cher et vous devriez utiliser pleinement les capacités du compilateur: je ne peux que recommander l'optimisation du temps de liaison. Cela a donné une amélioration significative des performances à mes programmes (jusqu'à 2,5x) et tout ce que je devais faire était de passer le -fltodrapeau. C'est une très bonne affaire! ;)

Cependant, je ne recommande pas de jeter votre code avec le mot-clé en ligne; laissez le compilateur décider quoi faire. (L'optimiseur est autorisé à traiter le mot clé en ligne comme un espace blanc de toute façon.)


Grande question, +1!

Ali
la source
3
NB: inlinen'a rien à voir avec la fonction inline; cela signifie «défini en ligne» et non «veuillez l'intégrer». Si vous voulez réellement demander l'inline, utilisez __attribute__((always_inline))ou similaire.
Jon Purdy
2
@JonPurdy Pas tout à fait, par exemple les fonctions membres de classe sont implicitement en ligne. inlineest également une demande vers le compilateur que vous souhaitez que la fonction soit en ligne et par exemple le compilateur Intel C ++ utilisé pour donner des avertissements de performances s'il ne répond pas à votre demande. (Je n'ai pas vérifié icc récemment si c'est toujours le cas.) Malheureusement, j'ai vu des gens mettre leur code à la poubelle inlineet attendre que le miracle se produise. Je n'utiliserais pas __attribute__((always_inline)); il y a de fortes chances que les développeurs de compilateurs sachent mieux quoi incorporer et quoi ne pas faire. (Malgré le contre-exemple ici.)
Ali
1
@JonPurdy D'autre part, si vous définissez une fonction en ligne qui n'est pas une fonction membre d'une classe , vous n'avez en effet pas d'autre choix que de la marquer en ligne sinon vous obtiendrez plusieurs erreurs de définition de l'éditeur de liens. Si c'est ce que vous vouliez dire alors OK.
Ali
1
Oui, c'est ce que je voulais dire. La norme dit: «Le inlinespécificateur indique à l'implémentation que la substitution en ligne du corps de fonction au point d'appel doit être préférée au mécanisme d'appel de fonction habituel.» (§7.1.2.2) Cependant, les implémentations ne sont pas nécessaires pour effectuer cette optimisation, car c'est en grande partie une coïncidence que les inlinefonctions sont souvent de bons candidats pour l'inlining. Il est donc préférable d'être explicite et d'utiliser un pragma de compilation.
Jon Purdy
3
@JonPurdy Quant à la première moitié: Oui, c'est ce que je voulais dire en disant "L' optimiseur est autorisé à traiter le mot clé en ligne comme un espace blanc de toute façon." En ce qui concerne le pragma du compilateur, je ne l'utiliserais pas, je laisserais à l'optimisation du temps de liaison le choix de l'inline ou non. Il fait un très bon travail; il a également résolu automatiquement ce problème abordé ici dans la réponse.
Ali