Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Duel de performance : Python vs C++

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… ».


Démarrage rapide

run_duel.bat

Cela 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:fast

Lancement manuel d'un seul côté :

python python\bench.py --solo --duration 5
cpp\bench.exe --solo --duration 5
python compare.py --csv resultats.csv

Les tests

Section 1 — terrain défavorable à Python

Boucles 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

Section 2 — contre-tests

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

Résultats mesurés

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)

Compilation ≠ vectorisation

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:fast change 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.py le 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.


D'où vient le facteur ~66 ?

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 + b sur 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.x est un double à 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.

Précautions méthodologiques

Ces points sont ce qui sépare un benchmark crédible d'un benchmark contestable.

  1. Checksums. Chaque test renvoie une valeur de contrôle, affichée et comparée à la fin. Sans cela, rien ne garantit que /O2 n'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.
  2. Paramètres lus au runtime. Les deux programmes lisent config.json. Le compilateur ne peut donc pas pré-calculer fib(25) à la compilation, et les deux côtés utilisent forcément les mêmes tailles.
  3. 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 (list de Bodystd::vector<Body>, list de bool ↔ std::vector<char> et non std::vector<bool> qui serait un champ de bits, donc un autre algorithme).
  4. 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.
  5. 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.
  6. Rodage. 0,25 s de chauffe avant chaque mesure, hors chronomètre.
  7. Deux binaires C++. bench.exe (/O2 /fp:precise, scalaire) et bench_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.
  8. 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.

Limites à annoncer honnêtement

  • 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::regex est 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 dossier analyse/ décompose cet écart par la mesure — on y trouve notamment que \d{1} est 170× plus lent que \d en std::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.

Structure

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

Régler la démo

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 test

Augmenter 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.

About

Benchmark Python vs C++ : deux programmes synchronises executent la meme suite de tests et comptent leurs iterations, avec verification par checksum

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages