Aller au contenu principal

Module 10 — Tests de charge et dimensionnement

Les neuf premiers modules ont livré un service qui répond correctement à une requête. Ce module change la question : combien de requêtes par seconde tient-il, avec quelle latence, et combien coûte-t-il par million de prédictions ? Répondre exige de le sonder avec un vrai générateur de charge, de lire proprement les résultats et d'en déduire un dimensionnement. Sans ce travail, chaque décision d'infrastructure — un travailleur de plus, un réplica de moins — se prend au doigt mouillé et finit soit trop chère, soit trop lente.

Pourquoi mesurer avant de dimensionner

L'intuition trompe. Un développeur qui appelle sa route dix fois avec curl conclut qu'elle tient 200 requêtes par seconde parce que chacune répond en 5 millisecondes. Cette extrapolation est fausse pour trois raisons.

D'abord, la concurrence change tout. Un modèle qui prend 5 ms sur un cœur au repos peut prendre 40 ms quand quatre requêtes s'exécutent en parallèle sur le même cœur, à cause de la contention sur le cache et sur les allocations mémoire. Ensuite, le pool de fils du module 6 a une taille finie : quand toutes les places sont prises, les requêtes patientent en file, ce qui invisibilise la latence dans un curl mais crève dans un tableau de bord. Enfin, la sérialisation JSON, la validation Pydantic, le journal structuré et la métrique Prometheus prennent chacun un peu de temps ; sur des lots de 5 000 dossiers, ces frais fixes deviennent la moitié du temps total.

Un vrai test de charge exerce toutes ces couches ensemble et livre les chiffres réels.

Locust en une page

Locust est un générateur de charge écrit en Python, où le scénario utilisateur se déclare comme du code plutôt que dans une interface graphique. C'est un choix qui compte pour un service ML : on peut facilement générer des dossiers réalistes avec numpy et faker, plutôt que rejouer un fichier CSV figé qui finit par polluer les caches et donner des chiffres trop optimistes.

pip install locust

Le scénario tient dans un fichier locustfile.py posé à côté du service.

import random
from datetime import date, timedelta
from locust import HttpUser, task, between

def dossier_aleatoire() -> dict:
return {
"abonne_id": random.randint(1, 10_000_000),
"age": random.randint(18, 90),
"anciennete_mois": random.randint(0, 240),
"forfait": random.choice(["prépayé", "postpayé", "entreprise"]),
"revenu_moyen_mensuel_eur": round(random.uniform(15, 300), 2),
"nb_reclamations_12m": random.randint(0, 20),
"date_dernier_paiement": (
date.today() - timedelta(days=random.randint(0, 60))
).isoformat(),
}

class UtilisateurAPI(HttpUser):
wait_time = between(0.1, 0.4) # pause entre deux requêtes du même utilisateur

@task(9)
def predict_unitaire(self):
self.client.post(
"/predict",
json=dossier_aleatoire(),
headers={"X-API-Key": "clé-secrète-1-très-longue-et-aléatoire"},
)

@task(1)
def predict_lot(self):
self.client.post(
"/predict/batch",
json={"lot_id": "test", "dossiers": [dossier_aleatoire() for _ in range(200)]},
headers={"X-API-Key": "clé-secrète-1-très-longue-et-aléatoire"},
)

On lance ensuite Locust en mode sans interface graphique, ce qui est le plus utile pour un rapport reproductible.

locust -f locustfile.py \
--host http://localhost:8000 \
--users 100 --spawn-rate 10 \
--run-time 5m --headless \
--csv rapport

Le rapport CSV donne, par route, le nombre d'appels, les latences minimale, médiane, p95, p99 et maximale, le débit et le taux d'erreur. C'est la matière première du dimensionnement.

Lire les percentiles plutôt que la moyenne

La moyenne d'une latence est presque toujours un chiffre inutile. Sur 10 000 requêtes qui répondent en 5 ms et 100 requêtes qui répondent en 2 secondes, la moyenne est 24 ms — pas trop mal ; la p99 est 2 000 ms — un désastre. C'est la seconde vue qui compte, parce que c'est celle qu'un utilisateur du tableau de bord Streamlit ressent quand il clique sur un abonné et attend deux secondes.

Trois percentiles servent en pratique.

La p50 (médiane) décrit le comportement typique. Utile pour dire « le service répond en général en 8 ms ». On ne s'engage jamais sur la p50 dans un contrat de service : la moitié des requêtes seraient plus lentes.

La p95 décrit le comportement raisonnable. C'est le chiffre le plus souvent cité dans un accord de niveau de service : « p95 sous 100 ms » signifie qu'une requête sur vingt peut être plus lente, ce qui reste acceptable pour un usage interactif.

La p99 décrit la queue de la distribution, celle qui abrite les requêtes qui coïncident avec un ramasse-miettes, une allocation lourde, une contention sur le pool de fils. Sur 1 000 clics, dix atteignent la p99 ; si celle-ci est à 3 secondes, ces dix personnes voient un service cassé. On la surveille, on l'améliore, on l'annonce rarement.

Une règle empirique utile : sur un service ML, viser p99 < 3 × p50. Un rapport plus grand trahit un problème de concurrence ou de mémoire.

Diagnostiquer les goulots

Une fois les chiffres obtenus, un p95 trop élevé se diagnostique par élimination. Trois goulots dominent les services de scoring.

Le modèle lui-même. C'est le premier candidat. On isole le temps de predict_proba en instrumentant la route (module 8) et on regarde combien de la latence totale il consomme. Sur un RandomForest de 300 arbres, cinq à vingt millisecondes ; sur un XGBoost équivalent, une à cinq ; sur un modèle PyTorch sans GPU, dix à cent. Un modèle qui prend 80 % du temps total est la cible évidente : élagage (max_depth plus bas), quantification (float16), ou décharge sur GPU si les volumes le justifient.

La sérialisation. Convertir 5 000 objets Pydantic en JSON prend 30 à 80 ms selon la profondeur du schéma. Deux leviers : renvoyer une réponse plus étroite (juste [abonne_id, probabilite] au lieu de l'objet complet) et utiliser orjson, qui divise le temps par trois par rapport au module json de la bibliothèque standard.

from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)

Le pool de fils. Le module 6 rappelle que FastAPI exécute les routes def dans un pool géré par Starlette, dont la taille par défaut est de 40 fils. Sur une machine à huit cœurs qui reçoit 300 requêtes par seconde, ce pool sature vite : les requêtes patientent, la p99 explose. On l'agrandit à la valeur qui correspond à la concurrence réelle.

from anyio import to_thread

@app.on_event("startup")
async def elargir_pool():
limiter = to_thread.current_default_thread_limiter()
limiter.total_tokens = 100

Attention : au-delà de 4 × nombre de cœurs, le pool ne sert plus, car les fils sont bloqués sur le CPU par le GIL — c'est un mirage.

Passer du chiffre au dimensionnement

Un test de charge livre une fonction débit soutenable(travailleurs, réplicas). Le dimensionnement consiste à choisir le couple qui atteint le débit visé avec la p95 respectée, au meilleur coût.

Sur le fil rouge, la campagne de mesure a donné les chiffres suivants pour la route /predict avec le modèle de résiliation :

Travailleurs UvicornRPS soutenus (p95 ≤ 100 ms)Mémoire par processus
190620 Mo
2175620 Mo
3260620 Mo
4310620 Mo
5320620 Mo
6315620 Mo

Trois enseignements. Le rendement décroît vite : passer de 3 à 4 travailleurs n'ajoute que 50 RPS. À partir de 5 travailleurs, le débit plafonne car la machine (4 cœurs, 8 Go) ne peut pas offrir plus de temps CPU. La mémoire est la seconde contrainte : 4 travailleurs consomment 2,5 Go pour le modèle plus 1 Go pour Python, il ne reste que 4,5 Go de marge — suffisant pour cette machine, insuffisant sur une plus petite.

La cible du fil rouge est 300 RPS avec p95 ≤ 100 ms. Deux stratégies tiennent : 4 travailleurs sur un réplica (310 RPS, machine chargée) ou 2 travailleurs sur deux réplicas (350 RPS cumulés, marge de sécurité). La seconde gagne sur le déploiement (canari possible, redémarrage sans coupure) et sur la robustesse (une panne matérielle n'emporte pas tout). On choisit donc deux réplicas de deux travailleurs.

Calculer le coût par million de prédictions

Un dimensionnement n'est pas complet sans son étiquette de prix. Sur un service géré facturé à la seconde de conteneur en marche, la formule est directe.

# 2 conteneurs × 2 vCPU × 4 Go, allumés 24 h × 30 j
heures_mois = 2 * 24 * 30
tarif_par_heure_conteneur = 0.096 # exemple : Cloud Run, 2 vCPU + 4 Go
cout_infra_mensuel = heures_mois * tarif_par_heure_conteneur
# = 138,24 USD par mois

# À 300 RPS de moyenne, 30 % du temps (heures de bureau) :
rps_moyen = 300 * 0.30
predictions_par_mois = rps_moyen * 3600 * 24 * 30
# = 233 millions

cout_par_million = cout_infra_mensuel / (predictions_par_mois / 1_000_000)
# = 0,59 USD par million de prédictions

Ce chiffre — moins d'un dollar par million — est celui qu'on présente au comité de finances. Il rend concret ce qu'apporte une architecture soignée : dix fois moins cher qu'un service naïf qui recharge le modèle à chaque requête et double sa flotte pour compenser.

Tester en production, avec précaution

Le test de charge en préproduction sur un jeu synthétique est utile mais imparfait : la distribution des dossiers réels, l'état des caches, les pics de trafic ne s'y reproduisent pas fidèlement. La complémentarité est un test en production, à faible intensité, sur une fraction du trafic — par exemple 5 % — pendant une heure. Locust peut viser un hôte de production autant qu'un local, à condition que l'appelant soit identifié comme un synthétique (une clé d'API dédiée qui bypass la limitation de débit et se distingue dans les journaux).

Ce test attrape ce qu'aucune préproduction ne peut : la sensibilité aux vraies distributions, la présence d'appelants concurrents, la concurrence pour la mémoire avec les autres services de l'hôte.

L'échelle automatique n'est pas un dimensionnement

Un service géré peut monter de deux à vingt réplicas selon la charge. C'est utile en pointe, mais dangereux si le dimensionnement de base est faux : chaque réplica démarre en 10 à 30 secondes (téléchargement de l'image, chargement du modèle), pendant lesquelles la charge continue d'affluer sur ceux qui répondent déjà. On garde donc une marge de deux réplicas au minimum et une politique d'échelle qui déclenche en dessous de 70 % d'usage CPU, pas au moment où le service craque.

En résumé

  • Locust en mode sans interface graphique livre débit, p50, p95 et p99 par route ; c'est la matière première d'un dimensionnement honnête.
  • Lire la p95 et la p99, jamais la moyenne ; viser p99 < 3 × p50, au-delà chercher un goulot de concurrence ou de mémoire.
  • Trois goulots dominent le scoring : le modèle lui-même, la sérialisation (JSON, Pydantic) et le pool de fils Starlette.
  • Choisir le couple (travailleurs, réplicas) qui atteint le débit visé avec deux réplicas au minimum, et étiqueter le coût par million de prédictions pour arbitrer.

Le module 11 récapitule le parcours et annonce l'examen final qui valide l'attestation.