Benchmarking
Cette page décrit le protocole minimal pour comparer deux configurations Catnip. bench/ l'implémente : les workloads
maintenus, la matrice de configurations et le format des mesures y vivent.
make bench # matrice par défaut
python -m bench run --config opt0,opt3 --runs 50 # deux configurations, 50 mesures
python -m bench run --workload loop_sum # cibler une mesure pendant une itération
python -m bench run -o avant.json # archiver
python -m bench compare avant.json apres.json # détecter une régression
La matrice par défaut est baseline, opt0, opt3, ast, jit : chacune ne diffère de baseline que par un paramètre,
c'est ce qui permet d'attribuer un écart. jit en fait partie parce que c'est la configuration au plus grand effet, et
dans les deux sens — première mesure du 2026-08-03 : loop_sum 207 fois plus rapide, struct_field 24 % plus lent
faute d'assez de travail natif pour amortir la compilation. Un jeu par défaut qui l'omet cache le gain autant que son
prix.
Les workloads maintenus, et ce que chacun exerce :
| Workload | Ce qu'il mesure |
|---|---|
broadcast |
opérateur sur source homogène (chemin Rust), lambda par élément, filtre de comparaison |
call_heavy |
coût d'appel de fonction et TCO |
loop_sum |
dispatch VM et arithmétique entière |
redundant_expr |
sous-expressions redondantes et invariants de boucle, cibles du tier inter-blocs |
struct_field |
création d'instances et accès aux champs |
Ce qu'aucun ne couvre, donc ce qu'une régression traverserait sans être vue : le pattern matching, les closures, les opérations sur chaînes, et le mode ND parallèle.
La règle « alterner l'ordre des configurations » est appliquée par le harnais lui-même : les configurations sont
chronométrées à tour de rôle à chaque répétition, pas en blocs successifs, et le warmup emprunte le même ordre qu'elles.
Mesurées en blocs, la montée en température et la charge de la machine s'alignent sur l'ordre des blocs, et l'écart
s'impute à la configuration plutôt qu'à la dérive. Il n'y a donc plus d'ordre à permuter d'un passage à l'autre — le
champ version des mesures vaut 2 depuis ce changement, et compare signale une comparaison avec un fichier produit
sous l'ancien protocole.
Le harnais refuse de mesurer une configuration qu'il n'applique pas : il relit optimize, vm_mode et jit sur
l'instance, après parse(), et compare au réglage demandé. Le contrôle est là parce que son absence coûte cher —
l'outil précédent affichait « Opt level : 2 » en mesurant du niveau 0, parce qu'il lisait la configuration du CLI et
instanciait Catnip() nu. Le contrôle est après parse() pour attraper aussi le workload qui renverse le réglage par
un pragma.
Définir la frontière mesurée
Avant de chronométrer, choisir une seule frontière :
| Mesure | Inclus | Usage |
|---|---|---|
| exécution | execute() sur un programme déjà préparé |
comparer VM, AST, JIT ou une optimisation runtime |
| préparation | création du pipeline et parse() |
mesurer parsing, transformation et analyse |
| bout en bout | création, préparation et exécution | estimer le coût observé par un appel one-shot |
| CLI | démarrage du processus jusqu'à sa sortie | comparer l'expérience d'un script complet |
Mélanger ces frontières rend un ratio ambigu. Un cache de bytecode peut accélérer l'exécution sans changer le parsing ; un JIT peut ajouter du warmup puis gagner sur une boucle longue.
Protocole
Un comparatif valide respecte les mêmes règles dans les deux branches :
- changer un seul paramètre ;
- utiliser le même programme et les mêmes données ;
- vérifier que les résultats sont identiques ;
- faire le warmup avant les mesures ;
- alterner ou randomiser l'ordre des configurations si la campagne est longue ;
- rapporter toutes les mesures, au moins la médiane, la moyenne et l'écart-type ;
- noter versions, matériel, système et configuration d'énergie.
Une seed fixe rend les entrées reproductibles. Elle ne doit pas figer un seul cas si le comportement dépend de la distribution des données : dans ce cas, mesurer plusieurs seeds et les publier.
Un ratio de temps n'a de sens qu'après l'égalité des résultats. Retirer le calcul reste une optimisation extrêmement performante.
Un harnais d'exécution
Le harnais suivant mesure une instance déjà préparée. Il sert pour VM/AST, JIT on/off ou deux niveaux d'optimisation :
seules les deux fonctions make_* changent.
from __future__ import annotations
import statistics
import time
from collections.abc import Callable
from catnip import Catnip
SOURCE = """
total = 0
for i in range(1, 100000) {
total = total + i
}
total
"""
def measure(
factory: Callable[[], Catnip],
*,
warmup: int = 5,
runs: int = 30,
) -> tuple[list[float], object]:
cat = factory()
cat.parse(SOURCE)
for _ in range(warmup):
cat.execute()
samples = []
result = None
for _ in range(runs):
start = time.perf_counter_ns()
result = cat.execute()
samples.append((time.perf_counter_ns() - start) / 1_000_000)
return samples, result
def report(label: str, samples: list[float]) -> None:
mean = statistics.mean(samples)
median = statistics.median(samples)
stdev = statistics.stdev(samples)
print(f"⇒ {label}: median={median:.3f} ms, mean={mean:.3f} ms, σ={stdev:.3f} ms")
samples_a, result_a = measure(lambda: Catnip(vm_mode='on', optimize=3))
samples_b, result_b = measure(lambda: Catnip(vm_mode='off', optimize=3))
assert result_a == result_b
report("VM", samples_a)
report("AST", samples_b)
print(f"⇒ ratio des médianes: {statistics.median(samples_b) / statistics.median(samples_a):.2f}x")
Pour mesurer la préparation, créer une nouvelle instance dans chaque itération et chronométrer uniquement parse().
Pour le bout en bout, chronométrer la création, parse() et execute() ensemble. Ne réutilise pas les nombres d'une
frontière pour commenter une autre.
Choisir les configurations
Exemples de comparaisons isolées :
lambda: Catnip(vm_mode='on', optimize=3)
lambda: Catnip(vm_mode='off', optimize=3)
lambda: Catnip(vm_mode='on', jit=True)
lambda: Catnip(vm_mode='on', jit=False)
lambda: Catnip(vm_mode='on', optimize=0)
lambda: Catnip(vm_mode='on', optimize=3)
Pour le JIT, le programme doit dépasser le seuil de chauffe et effectuer assez de travail natif pour amortir la compilation. Publier séparément le premier passage et l'état chaud quand les deux correspondent à des usages réels.
Pour la TCO, comparer le même programme avec et sans TCO mesure le coût du mécanisme, mais pas sa capacité principale : l'espace de pile borné. Une récursion qui échoue sans TCO n'a pas de ratio de temps comparable.
Comparer avec un autre langage
La comparaison doit conserver :
- le même algorithme ;
- les mêmes types d'entrée ;
- le même résultat et les mêmes erreurs pertinentes ;
- la même frontière, démarrage du processus compris ou exclu des deux côtés ;
- un traitement équivalent de l'I/O.
Une boucle Catnip ne se compare pas à un builtin vectorisé Python si le sujet est le dispatch de boucle. Inversement, remplacer volontairement l'algorithme par un builtin est pertinent si la question porte sur le temps d'une tâche utilisateur ; il faut alors nommer cette différence.
bench/ ne compare pas Catnip à d'autres langages, délibérément. Sur des workloads courts, une telle comparaison mesure
surtout le démarrage des interpréteurs : dans l'outil précédent, « Fibonacci(35) » relevait 72,8 ms pour Catnip dont ~70
de lancement, et les mêmes 20 ms pour Python quel que soit le programme — leur startup. Un comparatif inter-langages
reste possible, mais il demande des workloads dimensionnés pour que le calcul domine, et il se publie avec le temps de
démarrage séparé.
Interpréter les mesures
La médiane résiste à quelques pauses du système ; la moyenne et l'écart-type rendent visibles la dispersion et les longues pauses. Publier les échantillons permet de recalculer d'autres statistiques.
Ne supprime pas automatiquement les valeurs éloignées de deux écarts-types : sur une distribution asymétrique, cette règle peut retirer des observations valides et améliorer artificiellement le résultat. Une valeur ne doit être exclue qu'avec une cause externe identifiée, en conservant le résultat brut.
Un intervalle de confiance n'efface pas un biais de protocole. Trente mesures d'une branche chaude comparées à trente premiers lancements de l'autre donnent une estimation précise de deux choses différentes.
Profiler après avoir mesuré
Un benchmark détecte une différence ; le profiler localise son origine.
py-spyobserve la partie Python d'un processus complet ;- les compteurs VM/JIT séparent compilation, guards et exécution native ;
- les outils de profilage Rust servent aux chemins purs ;
- les ledgers de valeurs décrits dans Machine virtuelle détectent les allocations retenues.
cProfile instrumente Python et peut perturber une mesure courte. Utilise-le pour attribuer le temps, pas comme source
du ratio final.
Pour une comparaison CLI reproductible, hyperfine gère warmup, alternance et export :
hyperfine \
--warmup 5 \
--runs 30 \
--export-json benchmark.json \
"catnip -o level:0 script.cat" \
"catnip -o level:3 script.cat"
Compte rendu minimal
Un résultat publiable contient :
- la commande ou le script exact ;
- le commit Catnip et la version Python ;
- CPU, OS, mode d'énergie et nombre de workers ;
- la frontière mesurée ;
- warmup, nombre de runs et ordre d'exécution ;
- médiane, moyenne, écart-type et échantillons bruts ;
- l'assertion d'équivalence ;
- les caches activés ou vidés.
python -m bench run -o mesures.json enregistre tout cela : version et commit de Catnip, plateforme, warmup, nombre de
runs, médiane, moyenne, écart-type, la valeur rendue par chaque configuration, et la décomposition parse / compile /
execute en microsecondes. Un fichier de mesures se relit et se compare ; un tableau recopié à la main dans une page,
non.
Cette décomposition est mesurée sur l'instance qui vient d'être chronométrée, et vaut null en mode AST :
execute_timed passe par le pipeline VM quel que soit l'exécuteur, donc la publier sous cette étiquette décrirait autre
chose que ce qui a été mesuré. La mesurer sur une instance neuve rendait quatre fois la même valeur sous quatre
étiquettes — une instance nue n'a encore reçu ni son niveau d'optimisation, posé par parse(), ni son JIT, posé par
execute().
La règle d'équivalence vaut aussi entre deux fichiers : compare exclut une paire dont le result_repr a changé, et le
signale, au lieu d'en tirer un « gain » sur deux exécutions qui ne calculent plus la même chose. run compte de même un
désaccord entre configurations comme un échec.
compare refuse aussi de conclure sur ce qu'il n'a pas : une mesure présente dans le premier fichier et absente du
second est signalée et fait sortir en 1, au lieu de rendre « 0 régression » sur moins de mesures qu'annoncé — ce qui
arrive dès qu'un workload cesse de parser, ou qu'une campagne a été lancée avec --workload. Et comme l'écart-type
borne le bruit sous lequel un delta est déclaré stable, les conditions qui l'ont produit sont comparées : un nombre de
répétitions, un warmup, une version de Python ou une plateforme qui diffèrent sont annoncés avant le tableau. La
comparaison reste possible — elle est seulement dite.
Profiler plutôt que chronométrer
Chronométrer dit combien, profiler dit où. Pour la seconde question, le binaire standalone se prête aux outils système, sans rien ajouter au dépôt :
make install-bins
perf record -g .venv/bin/catnip bench/workloads/loop_sum.cat
perf report
perf voit la pile Rust : dispatch VM, allocations, appels PyO3. Il ne voit pas les fonctions Catnip — un programme
utilisateur y apparaît comme du temps passé dans vm::core::execute. Répondre « quelle fonction Catnip coûte » demande
une instrumentation de la boucle de dispatch, qui n'existe pas aujourd'hui et n'a pas de raison d'être écrite avant
qu'une question réelle la réclame.