web-dev-qa-db-fra.com

Pourquoi ce programme C ++ est-il si incroyablement rapide?

J'ai écrit un petit benchmark pour comparer les performances de différents interprètes/compilateurs pour Python, Ruby, JavaScript et C++. Comme prévu, il s'avère que C++ (optimisé) bat les langages de script, mais le facteur par lequel il le fait est incroyablement élevé.

Les résultats sont:

sven@jet:~/tmp/js$ time node bla.js              # * JavaScript with node *
0

real    0m1.222s
user    0m1.190s
sys 0m0.015s
sven@jet:~/tmp/js$ time Ruby foo.rb              # * Ruby *
0

real    0m52.428s
user    0m52.395s
sys 0m0.028s
sven@jet:~/tmp/js$ time python blub.py           # * Python with CPython *
0

real    1m16.480s
user    1m16.371s
sys 0m0.080s

sven@jet:~/tmp/js$ time pypy blub.py             # * Python with PyPy *
0

real    0m4.707s
user    0m4.579s
sys 0m0.028s

sven@jet:~/tmp/js$ time ./cpp_non_optimized 1000 1000000 # * C++ with -O0 (gcc) *
0

real    0m1.702s
user    0m1.699s
sys 0m0.002s
sven@jet:~/tmp/js$ time ./cpp_optimized 1000 1000000     # * C++ with -O3 (gcc) *
0

real    0m0.003s # (!!!) <---------------------------------- WHY?
user    0m0.002s
sys 0m0.002s

Je me demande si quelqu'un peut expliquer pourquoi le code C++ optimisé est plus de trois ordres de grandeur plus rapide que tout le reste.

Le benchmark C++ utilise des paramètres de ligne de commande afin d'empêcher le pré-calcul du résultat au moment de la compilation.

Ci-dessous, j'ai placé les codes sources pour les différents benchmarks de langue, qui devraient être sémantiquement équivalents. J'ai également fourni le code Assembly pour la sortie du compilateur C++ optimisé (à l'aide de gcc). En regardant l'assemblage optimisé, il semble que le compilateur a fusionné les deux boucles du benchmark en une seule, mais néanmoins, il y a IS toujours une boucle!

JavaScript:

var s = 0;
var outer = 1000;
var inner = 1000000;

for (var i = 0; i < outer; ++i) {
    for (var j = 0; j < inner; ++j) {
        ++s;
    }
    s -= inner;
}
console.log(s);

Python:

s = 0
outer = 1000
inner = 1000000

for _ in xrange(outer):
    for _ in xrange(inner):
        s += 1
    s -= inner
print s

Rubis:

s = 0
outer = 1000
inner = 1000000

outer_end = outer - 1
inner_end = inner - 1

for i in 0..outer_end
  for j in 0..inner_end
    s = s + 1
  end
  s = s - inner
end
puts s

C++:

#include <iostream>
#include <cstdlib>
#include <cstdint>

int main(int argc, char* argv[]) {
  uint32_t s = 0;
  uint32_t outer = atoi(argv[1]);
  uint32_t inner = atoi(argv[2]);
  for (uint32_t i = 0; i < outer; ++i) {
    for (uint32_t j = 0; j < inner; ++j)
      ++s;
    s -= inner;
  }
  std::cout << s << std::endl;
  return 0;
}

Assembly (lors de la compilation du code C++ ci-dessus avec gcc -S -O3 -std = c ++ 0x):

    .file   "bar.cpp"
    .section    .text.startup,"ax",@progbits
    .p2align 4,,15
    .globl  main
    .type   main, @function
main:
.LFB1266:
    .cfi_startproc
    pushq   %r12
    .cfi_def_cfa_offset 16
    .cfi_offset 12, -16
    movl    $10, %edx
    movq    %rsi, %r12
    pushq   %rbp
    .cfi_def_cfa_offset 24
    .cfi_offset 6, -24
    pushq   %rbx
    .cfi_def_cfa_offset 32
    .cfi_offset 3, -32
    movq    8(%rsi), %rdi
    xorl    %esi, %esi
    call    strtol
    movq    16(%r12), %rdi
    movq    %rax, %rbp
    xorl    %esi, %esi
    movl    $10, %edx
    call    strtol
    testl   %ebp, %ebp
    je  .L6
    movl    %ebp, %ebx
    xorl    %eax, %eax
    xorl    %edx, %edx
    .p2align 4,,10
    .p2align 3
.L3:                             # <--- Here is the loop
    addl    $1, %eax             # <---
    cmpl    %eax, %ebx           # <---
    ja  .L3                      # <---
.L2:
    movl    %edx, %esi
    movl    $_ZSt4cout, %edi
    call    _ZNSo9_M_insertImEERSoT_
    movq    %rax, %rdi
    call    _ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
    popq    %rbx
    .cfi_remember_state
    .cfi_def_cfa_offset 24
    popq    %rbp
    .cfi_def_cfa_offset 16
    xorl    %eax, %eax
    popq    %r12
    .cfi_def_cfa_offset 8
    ret
.L6:
    .cfi_restore_state
    xorl    %edx, %edx
    jmp .L2
    .cfi_endproc
.LFE1266:
    .size   main, .-main
    .p2align 4,,15
    .type   _GLOBAL__sub_I_main, @function
_GLOBAL__sub_I_main:
.LFB1420:
    .cfi_startproc
    subq    $8, %rsp
    .cfi_def_cfa_offset 16
    movl    $_ZStL8__ioinit, %edi
    call    _ZNSt8ios_base4InitC1Ev
    movl    $__dso_handle, %edx
    movl    $_ZStL8__ioinit, %esi
    movl    $_ZNSt8ios_base4InitD1Ev, %edi
    addq    $8, %rsp
    .cfi_def_cfa_offset 8
    jmp __cxa_atexit
    .cfi_endproc
.LFE1420:
    .size   _GLOBAL__sub_I_main, .-_GLOBAL__sub_I_main
    .section    .init_array,"aw"
    .align 8
    .quad   _GLOBAL__sub_I_main
    .local  _ZStL8__ioinit
    .comm   _ZStL8__ioinit,1,1
    .hidden __dso_handle
    .ident  "GCC: (Ubuntu 4.8.2-19ubuntu1) 4.8.2"
    .section    .note.GNU-stack,"",@progbits
76
Sven Hager

L'optimiseur a déterminé que la boucle intérieure avec la ligne suivante est un no-op et l'a éliminée. Malheureusement, il n'a pas non plus réussi à éliminer la boucle extérieure.

Notez que l'exemple node.js est plus rapide que l'exemple C++ non optimisé, indiquant que V8 (le compilateur JIT du nœud) a réussi à éliminer au moins une des boucles. Cependant, son optimisation a quelques frais généraux, car (comme tout compilateur JIT), il doit équilibrer les opportunités d'optimisation et de réoptimisation guidée par le profil par rapport au coût de le faire.

102
ecatmur

Je n'ai pas fait une analyse complète de l'Assemblée, mais il semble qu'il ait déroulé la boucle intérieure et compris que, avec la soustraction de l'intérieur, c'est un non.

L'assemblage semble seulement faire la boucle extérieure qui incrémente seulement un compteur jusqu'à ce que l'extérieur soit atteint. Il aurait même pu optimiser cela, mais il semble que ce ne soit pas le cas.

21
wich

Existe-t-il un moyen de mettre en cache le code compilé JIT après l'avoir optimisé, ou doit-il ré-optimiser le code à chaque exécution du programme?

Si j'écrivais en Python j'essaierais de réduire la taille du code pour obtenir une vue "overhead" de ce que faisait le code. Comme essayer d'écrire ceci (beaucoup plus facile à lire IMO ):

for i in range(outer):
    innerS = sum(1 for _ in xrange(inner))
    s += innerS
    s -= innerS

ou même s = sum(inner - inner for _ in xrange(outer))

6
aoeu256

Même si les boucles ont beaucoup d'itérations, les programmes ne sont probablement pas encore assez longs pour échapper à la surcharge des temps de démarrage de l'interpréteur/JVM/Shell/etc. Dans certains environnements, ceux-ci peuvent varier considérablement - dans certains cas * toux * Java * toux * prenant plusieurs secondes avant qu'il ne se rapproche de votre code réel.

Idéalement, vous chronométrez l'exécution dans chaque morceau de code. Il peut être difficile de le faire avec précision dans toutes les langues, mais même imprimer l'heure de l'horloge en ticks avant et après serait mieux que d'utiliser time, et ferait le travail car vous n'êtes probablement pas concerné par la super- timing précis ici.

(Je suppose que cela ne se rapporte pas vraiment à la raison pour laquelle l'exemple C++ est tellement plus rapide - mais cela pourrait expliquer une partie de la variabilité des autres résultats. :)).

2
Tom
for (uint32_t i = 0; i < outer; ++i) {
    for (uint32_t j = 0; j < inner; ++j)
        ++s;
    s -= inner;
}

La boucle intérieure équivaut à "s + = intérieure; j = intérieure;" ce qu'un bon compilateur d'optimisation peut faire. Puisque la variable j a disparu après la boucle, tout le code est équivalent à

for (uint32_t i = 0; i < outer; ++i) {
    s += inner;
    s -= inner;
}

Encore une fois, un bon compilateur d'optimisation peut supprimer les deux modifications de s, puis supprimer la variable i, et il ne reste plus rien. Il semble que c'est ce qui s'est produit.

Maintenant, c'est à vous de décider à quelle fréquence une optimisation comme celle-ci se produit et s'il s'agit d'un avantage réel.

2
gnasher729