Deux programmes autonomes qui exécutent exactement la même suite de tests, pendant une durée fixe, et comptent le nombre d'itérations accomplies. Ils s'affichent dans deux fenêtres côte à côte et se synchronisent avant chaque test pour démarrer ensemble.
L'objectif est de montrer l'écart réel entre un langage interprété et un langage compilé — et de montrer aussi où cet écart disparaît, ce qui rend la démonstration beaucoup plus solide face à l'objection « oui, mais avec numpy… ».
run_duel.batCela compile le C++ si besoin, ouvre les deux fenêtres, puis affiche le tableau comparatif à la fin.
run_duel.bat avx2 :: utilise la variante C++ /arch:AVX2 /fp:fastLancement manuel d'un seul côté :
python python\bench.py --solo --duration 5
cpp\bench.exe --solo --duration 5
python compare.py --csv resultats.csvBoucles serrées, peu de travail par itération, aucune délégation à du code C. Chaque opération élémentaire passe par l'interpréteur.
| Test | Une itération = | Ce qu'il stresse |
|---|---|---|
| N-body | 1 pas de simulation gravitationnelle sur 256 corps | float scalaire + accès attributs + double boucle O(n²) |
| Mandelbrot | 1 image 120×90, max_iter=60 | arithmétique flottante en boucle serrée |
| Fibonacci récursif | 1 calcul de fib(25) (242 785 appels) |
coût de création d'une frame d'appel |
| Crible d'Ératosthène | 1 crible complet jusqu'à 300 000 | indexation de tableau, boucle entière |
| Quicksort manuel | 1 tri de 20 000 entiers (Hoare, écrit à la main) | comparaisons + échanges + récursion |
Ici Python ne fait plus le calcul lui-même : il appelle du C. L'écart s'effondre, et sur le second il s'inverse.
| Test | Une itération = | Ce qu'il montre |
|---|---|---|
| Produit scalaire numpy | 1 passe sur 4 M de doubles | numpy = boucle C vectorisée ; le C++ scalaire n'a plus qu'un avantage marginal |
| Regex sur logs | 1 passe de comptage sur ~300 Ko de logs | le module re (écrit en C, très optimisé) bat largement std::regex |
Machine de référence : 8 cœurs, Windows 11, CPython 3.9.13, MSVC 19.29 (/O2 /fp:precise),
5 s par test, les deux process épinglés sur des cœurs distincts.
1. TERRAIN DEFAVORABLE A PYTHON
test Python C++ ratio
N-body 256 corps, 1 pas 18.2 /s 2.1 k/s x117
Crible d Eratosthene N=300 000 8.1 /s 693.1 /s x86
Mandelbrot 120x90, max_iter=60 8.9 /s 608.1 /s x68
Fibonacci recursif n=25 20.0 /s 1.2 k/s x61
Quicksort manuel n=20 000 10.2 /s 307.2 /s x30
2. CONTRE-TESTS
Produit scalaire numpy n=4.0 M 29.1 /s 76.0 /s x2.6
Regex sur logs 0.3 Mo 50.6 /s 3.2 /s /15.6 <- Python devant
Ecart moyen (terrain defavorable) : x66 (moyenne geometrique, min x30 / max x117)
Ecart moyen (contre-tests) : /2.4 (Python devant)
Beaucoup de comparaisons confondent les deux. Le second binaire permet de les séparer :
bench_avx2.exe est le même code source, recompilé en /O2 /arch:AVX2 /fp:fast.
test scalaire avx2+fast gain checksum
nbody 2104.6 4798.6 x2.28 ecart 8e-16 (ok)
dotproduct 81.3 153.9 x1.89 ecart 3e-15 (ok)
mandelbrot 738.9 1219.1 x1.65 184170 -> 184125 DIVERGENT
fib 1170.3 1374.5 x1.17 identique
sieve 695.6 855.6 x1.23 identique
quicksort 322.9 390.2 x1.21 identique
Deux enseignements :
- Le gain SIMD ne touche que le flottant. Les tests entiers (fib, crible,
quicksort) ne gagnent que ~1,2× — ce qui reste vient du réglage global de
/O2, pas de la vectorisation. Sur le N-body en revanche, la vectorisation ajoute un facteur 2,3 par-dessus le facteur 125 : le ratio total monterait à ~285. Annoncer ce chiffre sans préciser d'où il vient serait trompeur, d'où le binaire scalaire par défaut. /fp:fastchange le résultat. Le checksum Mandelbrot passe de 184 170 à 184 125 : en réassociant les opérations flottantes, le compilateur fait basculer quelques pixels de l'autre côté du test d'échappement.compare.pyle signale comme DIVERGENT — c'est le mécanisme de vérification qui fait son travail, pas un bug. C'est aussi une bonne illustration du fait qu'« aller plus vite » et « calculer la même chose » sont deux exigences distinctes.
Pour la démonstration de référence, utilisez donc run_duel.bat (binaire scalaire,
checksums exacts) ; run_duel.bat avx2 sert à montrer ce point précis.
Ce n'est pas « la compilation » à elle seule, c'est la somme de plusieurs effets :
- Dispatch bytecode : chaque opération élémentaire passe par la boucle d'évaluation de l'interpréteur (~20–40 ns), là où le C++ exécute une instruction machine (~0,3 ns).
- Objets boxés :
a + bsur deux flottants Python, c'est deux déréférencements, un test de type, une allocation pour le résultat, et deux mises à jour de compteurs de références. - Absence de types statiques : le compilateur C++ sait à la compilation que
bj.xest undoubleà un offset connu ; l'interpréteur doit le redécouvrir à chaque passage. - Mémoire non contiguë : une liste d'objets Python est une liste de pointeurs
éparpillés dans le tas → défaut de cache à chaque accès. Un
std::vector<Body>est un bloc contigu. - Coût d'un appel de fonction : création d'une frame Python ≈ 100× le coût d'un appel C++ inliné. C'est ce qu'isole le test Fibonacci.
Ces points sont ce qui sépare un benchmark crédible d'un benchmark contestable.
- Checksums. Chaque test renvoie une valeur de contrôle, affichée et comparée à
la fin. Sans cela, rien ne garantit que
/O2n'a pas purement et simplement supprimé la boucle C++ (élimination de code mort) — le piège classique qui produit des ratios de ×50 000 et décrédibilise toute la démonstration. Ici les 7 checksums concordent, dont 6 au bit près. - Paramètres lus au runtime. Les deux programmes lisent
config.json. Le compilateur ne peut donc pas pré-calculerfib(25)à la compilation, et les deux côtés utilisent forcément les mêmes tailles. - Même unité d'itération. « 1 itération » désigne exactement le même travail des
deux côtés : même algorithme, mêmes structures de données idiomatiques
(
listdeBody↔std::vector<Body>,listde bool ↔std::vector<char>et nonstd::vector<bool>qui serait un champ de bits, donc un autre algorithme). - Mêmes données. Un générateur xorshift64* identique bit à bit des deux côtés produit les mêmes jeux de données.
- Affinité CPU. Les deux process sont épinglés sur des cœurs distincts (2 et 4) pour éviter qu'ils se disputent le même cœur pendant le duel.
- Rodage. 0,25 s de chauffe avant chaque mesure, hors chronomètre.
- Deux binaires C++.
bench.exe(/O2 /fp:precise, scalaire) etbench_avx2.exe(/O2 /arch:AVX2 /fp:fast). Comparer les deux permet de séparer ce qui vient de la compilation de ce qui vient de la vectorisation SIMD — deux choses différentes souvent confondues. - Appel virtuel. La boucle de mesure C++ appelle
run()via une fonction virtuelle : impossible pour l'optimiseur de la dérouler ou de la supprimer. Le surcoût (~2 ns) est négligeable devant des itérations de 0,3 ms et plus.
- CPython 3.9. Les versions 3.11+ sont environ 1,25× plus rapides sur ce type de code. L'écart resterait du même ordre de grandeur, mais il est honnête de le dire.
- PyPy (JIT) réduirait l'écart d'un facteur 5 à 20 sur ces mêmes tests. La vraie frontière n'est pas « interprété vs compilé » mais « dispatch dynamique à l'exécution vs code machine typé statiquement ».
std::regexest un point faible connu de la bibliothèque standard C++. Le contre-test compare deux bibliothèques standard, pas deux plafonds de performance : un C++ utilisant RE2 ou un parsing manuel repasserait devant. Le dossieranalyse/décompose cet écart par la mesure — on y trouve notamment que\d{1}est 170× plus lent que\denstd::regex, alors que les deux reconnaissent exactement le même langage.- Le produit scalaire diffère de 7e-14 en valeur relative entre les deux côtés : numpy utilise une sommation par paires, le C++ une sommation séquentielle. C'est du bruit d'arrondi flottant, pas une divergence d'algorithme.
- Un écart de ×30 à ×120 sur du calcul brut ne dit rien du temps de développement, de la lisibilité ni du coût de maintenance — ni du fait que la plupart des programmes réels passent leur temps en I/O, en réseau ou dans des bibliothèques natives.
config.json paramètres partagés par les deux programmes
run_duel.bat lance le duel (2 fenêtres) puis la synthèse
compare.py tableau comparatif + vérification des checksums
python/bench.py programme Python
cpp/bench.cpp programme C++ (miroir exact)
cpp/build.bat compilation des deux variantes MSVC
tools/ lanceurs des fenêtres + positionnement à l'écran
analyse/ sondes qui décomposent le résultat du contre-test regex
results/ JSON de sortie, CSV, verrous de synchronisation
Tout se règle dans config.json sans retoucher le code :
"duration_seconds": 5.0, // durée de chaque test
"order": ["nbody", "mandelbrot"] // sous-ensemble et ordre des tests
"tests": { "fib": { "n": 25 } } // taille de chaque testAugmenter une taille rend le compteur d'itérations plus petit mais chaque itération plus représentative ; la diminuer donne un compteur qui défile plus vite, ce qui est plus spectaculaire à l'écran. Les valeurs actuelles visent ~40 à 130 itérations côté Python sur 5 s : assez pour que le chiffre soit stable, assez peu pour que la différence saute aux yeux.