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
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.
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.
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))
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. :)).
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.