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 :

  1. changer un seul paramètre ;
  2. utiliser le même programme et les mêmes données ;
  3. vérifier que les résultats sont identiques ;
  4. faire le warmup avant les mesures ;
  5. alterner ou randomiser l'ordre des configurations si la campagne est longue ;
  6. rapporter toutes les mesures, au moins la médiane, la moyenne et l'écart-type ;
  7. 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-spy observe 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 . 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.

Pages liées