""" Benchmark d'inférence via l'API FastAPI. Description ----------- Ce script permet de mesurer les performances réelles du endpoint optimisé : GET /predict/{client_id} L’objectif est de simuler plusieurs appels successifs à l’API afin d’analyser le comportement du système dans des conditions proches de la production. Pourquoi faire un benchmark ? ----------------------------- En MLOps, il ne suffit pas qu’un modèle soit précis. Il doit également être : - rapide - stable - scalable - capable de répondre sous forte charge Ce benchmark permet donc d’évaluer : - le temps de réponse moyen - les pics de latence (P95 / P99) - la stabilité globale du service - le taux d’erreur éventuel Pourquoi utiliser /predict/{client_id} ? ---------------------------------------- Dans cette architecture : - le client envoie uniquement un identifiant - les features sont récupérées automatiquement côté serveur Cela se rapproche davantage d’un vrai système de production : - payload HTTP plus léger - moins de transfert réseau - appels plus rapides - meilleure expérience utilisateur Concepts importants ------------------- Warmup ~~~~~~ Les premiers appels sont souvent plus lents : - chargement du modèle - initialisation mémoire - cache Python / ONNX - ouverture des connexions On réalise donc plusieurs appels de chauffe non comptabilisés. Session HTTP persistante ~~~~~~~~~~~~~~~~~~~~~~~~ Le script utilise requests.Session() afin de : - réutiliser les connexions TCP - éviter un reconnect à chaque appel - réduire la latence réseau Latence client vs latence API ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Deux mesures sont comparées : 1. latency_ms Temps total vu par le client : - réseau - sérialisation JSON - temps API - transfert HTTP 2. api_latency_ms Temps interne mesuré côté serveur uniquement. Cela permet d’identifier : - les coûts réseau - les surcoûts applicatifs - les vrais goulots d’étranglement P95 / P99 ~~~~~~~~~ Les percentiles élevés sont très importants en production. Exemple : - moyenne = système global - P95 = cas lents fréquents - P99 = pires cas rares Un système peut avoir une bonne moyenne mais de très mauvais P99. """ from __future__ import annotations # ============================================================================= # IMPORTS # ============================================================================= # Librairies standard import time from pathlib import Path from typing import Any # Librairies externes import pandas as pd import requests # Configuration du projet from app.core.config import API_KEY, API_URL, MODEL_BACKEND # ============================================================================= # CONFIGURATION DES DOSSIERS # ============================================================================= """ Organisation des fichiers de sortie. Les résultats sont stockés dans : artifacts/performance/ Cela permet : - d'historiser les benchmarks - de comparer plusieurs backends - d’alimenter le monitoring - de produire des graphiques """ # Racine du projet PROJECT_ROOT = Path(__file__).resolve().parents[1] # Dossier de sortie OUTPUT_DIR = PROJECT_ROOT / "artifacts" / "performance" # Création automatique du dossier OUTPUT_DIR.mkdir(parents=True, exist_ok=True) # ============================================================================= # APPEL API # ============================================================================= def call_predict( *, session: requests.Session, client_id: int, ) -> dict[str, Any]: """ Appelle l’endpoint de prédiction optimisé. Endpoint testé --------------- GET /predict/{client_id} Paramètres ---------- session : requests.Session Session HTTP persistante utilisée pour améliorer les performances réseau. client_id : int Identifiant du client à scorer. Retour ------ dict[str, Any] Réponse JSON retournée par l’API. Pourquoi utiliser une Session ? ------------------------------- Sans session : - nouvelle connexion TCP à chaque appel - plus de latence - benchmark faussé Avec session persistante : - connexion réutilisée - comportement plus réaliste - meilleures performances Gestion des erreurs ------------------- Toute réponse HTTP >= 400 déclenche une exception afin d’être comptabilisée comme échec du benchmark. """ response = session.get( f"{API_URL}/predict/{client_id}", headers={"X-API-Key": API_KEY}, timeout=30, ) # Détection des erreurs HTTP if response.status_code >= 400: raise RuntimeError(f"{response.status_code} - {response.text}") return response.json() # ============================================================================= # BENCHMARK PRINCIPAL # ============================================================================= def benchmark( *, client_id: int, n_runs: int = 300, warmup_runs: int = 10, output_file: str, ) -> pd.DataFrame: """ Lance le benchmark principal. Fonctionnement --------------- Le benchmark suit plusieurs étapes : 1. Warmup Stabilisation du système 2. Appels mesurés Exécution répétée des prédictions 3. Mesure des performances Calcul de la latence client 4. Sauvegarde Export CSV des résultats Paramètres ---------- client_id : int Client utilisé pour les tests. n_runs : int Nombre total d’appels mesurés. warmup_runs : int Nombre d’appels de chauffe. output_file : str Nom du CSV de sortie. Retour ------ pd.DataFrame Résultats détaillés du benchmark. Pourquoi faire beaucoup d’appels ? ---------------------------------- Un seul appel n’est pas représentatif. Plusieurs centaines d’appels permettent : - d’observer la stabilité - de mesurer les variations - d’obtenir des statistiques fiables """ rows: list[dict[str, Any]] = [] # Session HTTP persistante with requests.Session() as session: # --------------------------------------------------------------------- # WARMUP # --------------------------------------------------------------------- """ Les appels de chauffe servent à stabiliser le système. Ils permettent notamment : - chargement du modèle en mémoire - initialisation ONNX Runtime - remplissage des caches - ouverture des connexions Les résultats ne sont pas comptabilisés. """ for _ in range(warmup_runs): try: call_predict(session=session, client_id=client_id) except Exception: pass # --------------------------------------------------------------------- # BENCHMARK # --------------------------------------------------------------------- for i in range(n_runs): # Début mesure temps start = time.perf_counter() try: # Appel API result = call_predict( session=session, client_id=client_id, ) success = True error = None except Exception as exc: # Gestion des erreurs benchmark result = {} success = False error = str(exc) # ----------------------------------------------------------------- # Calcul latence client # ----------------------------------------------------------------- """ Latence mesurée côté client. Inclut : - temps réseau - temps API - sérialisation JSON - transfert HTTP """ latency_ms = (time.perf_counter() - start) * 1000 # ----------------------------------------------------------------- # Stockage résultats # ----------------------------------------------------------------- rows.append( { "run": i + 1, "backend": MODEL_BACKEND, "client_id": client_id, "endpoint": f"/predict/{client_id}", "success": success, "latency_ms": latency_ms, "prediction": result.get("prediction"), "probability": result.get("probability"), "score": result.get("score"), "model_version": result.get("model_version"), # Latence interne API "api_latency_ms": result.get("latency_ms"), "error": error, } ) # ------------------------------------------------------------------------- # Conversion DataFrame # ------------------------------------------------------------------------- df = pd.DataFrame(rows) # ------------------------------------------------------------------------- # Sauvegarde CSV # ------------------------------------------------------------------------- """ Les résultats sont exportés afin de : - produire des graphiques - comparer plusieurs backends - historiser les performances - alimenter un dashboard MLOps """ df.to_csv(OUTPUT_DIR / output_file, index=False) return df # ============================================================================= # RÉSUMÉ STATISTIQUE # ============================================================================= def print_summary(df: pd.DataFrame, output_file: str) -> None: """ Affiche un résumé statistique du benchmark. Métriques importantes --------------------- Moyenne Performance globale. Médiane Cas typique plus robuste que la moyenne. P95 95 % des requêtes sont plus rapides que cette valeur. P99 Mesure les pires cas rares. Pourquoi les percentiles sont importants ? ------------------------------------------ En production : - quelques appels très lents peuvent dégrader l’expérience utilisateur - les moyennes seules peuvent masquer ces problèmes Les P95/P99 sont donc très utilisés en MLOps et dans les systèmes distribués. """ valid = df[df["success"]].copy() failed = df[~df["success"]].copy() print("\nRésultats") print(f"Backend : {MODEL_BACKEND}") print(f"Fichier : {(OUTPUT_DIR / output_file).resolve()}") print(f"Total runs : {len(df)}") print(f"Succès : {len(valid)}") print(f"Échecs : {len(failed)}") # ------------------------------------------------------------------------- # Cas sans succès # ------------------------------------------------------------------------- if valid.empty: print("\nAucune prédiction réussie.") if not failed.empty: print("\nPremière erreur :") print(failed.iloc[0]["error"]) return # ------------------------------------------------------------------------- # Statistiques client # ------------------------------------------------------------------------- print(f"\nLatence client moyenne : {valid['latency_ms'].mean():.2f} ms") print(f"Médiane client : {valid['latency_ms'].median():.2f} ms") print(f"P95 client : {valid['latency_ms'].quantile(0.95):.2f} ms") print(f"P99 client : {valid['latency_ms'].quantile(0.99):.2f} ms") print(f"Min client : {valid['latency_ms'].min():.2f} ms") print(f"Max client : {valid['latency_ms'].max():.2f} ms") # ------------------------------------------------------------------------- # Statistiques API internes # ------------------------------------------------------------------------- if ( "api_latency_ms" in valid.columns and valid["api_latency_ms"].notna().any() ): print(f"\nLatence API moyenne : {valid['api_latency_ms'].mean():.2f} ms") print(f"P95 API : {valid['api_latency_ms'].quantile(0.95):.2f} ms") # ============================================================================= # POINT D'ENTRÉE # ============================================================================= if __name__ == "__main__": """ Point d’entrée du script. Ce bloc permet d’exécuter directement le benchmark : python benchmark.py Paramètres utilisés ------------------- CLIENT_ID Client testé. N_RUNS Nombre d’appels mesurés. WARMUP_RUNS Nombre d’appels de chauffe. """ CLIENT_ID = 100002 N_RUNS = 300 WARMUP_RUNS = 10 # Nom du backend backend = str(MODEL_BACKEND).lower().strip() # Nom du CSV de sortie output_file = f"benchmark_client_id_{backend}.csv" # ------------------------------------------------------------------------- # Affichage configuration # ------------------------------------------------------------------------- print(f"API_URL : {API_URL}") print(f"MODEL_BACKEND : {MODEL_BACKEND}") print(f"API_KEY : {API_KEY[:4]}****") print(f"Endpoint : /predict/{CLIENT_ID}") # ------------------------------------------------------------------------- # Lancement benchmark # ------------------------------------------------------------------------- df_results = benchmark( client_id=CLIENT_ID, n_runs=N_RUNS, warmup_runs=WARMUP_RUNS, output_file=output_file, ) # ------------------------------------------------------------------------- # Résumé final # ------------------------------------------------------------------------- print_summary(df_results, output_file)