Mesurez une API d'inférence de bout en bout
Le festival a entraîné un petit modèle qui classe les avis des spectateurs (positif, négatif, neutre) et l’expose derrière une API HTTP. Le jour de la clôture des votes du prix du public, l’application mobile l’appellera en continu. L’équipe veut une garantie simple : « 99 % des réponses en moins de 150 ms, jusqu’à 50 requêtes par seconde ». Est-ce tenable, et comment le prouver ?
Ce dernier chapitre ne présente aucun outil nouveau. Il assemble tout le cours en une étude de bout en bout : fixer l’objectif, prévoir avec un modèle, construire un banc d’essai reproductible, analyser, puis appliquer la même démarche à un pipeline de données.
Étape 1 : fixer l’objectif et les métriques
Section intitulée « Étape 1 : fixer l’objectif et les métriques »On commence toujours par la démarche du chapitre 1. L’objectif est formulé comme un objectif de niveau de service (SLO, service level objective), mesurable et neutre :
- latence : p99 inférieur à 150 ms, mesuré côté client (c’est ce que vit l’utilisateur) ; on suit aussi le p50 et le p95 ;
- débit : au moins 50 requêtes/s soutenues ;
- taux d’erreur : moins de 0,1 % de réponses en erreur ou en délai dépassé ;
- utilisations : processeur et GPU, pour trouver le goulot et calibrer le modèle.
Latence et débit sont deux métriques différentes, et elles tirent souvent en sens inverse. Le regroupement en lots (batching) en est l’exemple type : si le GPU traite un lot de 8 avis en 30 ms au lieu d’un avis en 10 ms, sa capacité passe de 100 à environ 267 requêtes/s. Mais chaque requête doit attendre que le lot se remplisse : à 50 requêtes/s, réunir 8 requêtes prend en moyenne ms pour la première arrivée du lot. Le débit monte, la latence aussi. D’où le batching dynamique, qui ferme un lot dès qu’il est plein ou qu’un délai maximal (quelques millisecondes) est écoulé.
Étape 2 : prévoir avant de mesurer
Section intitulée « Étape 2 : prévoir avant de mesurer »Avant d’écrire la moindre ligne de banc d’essai, on estime l’ordre de grandeur avec les chapitres 2 à 4. Une première mesure isolée, à faible charge, donne les demandes de service par requête :
| Ressource | Rôle | Demande |
|---|---|---|
| Processeur | décodage JSON, tokenisation | 3 ms |
| GPU | inférence du modèle | 10 ms |
| Processeur | mise en forme de la réponse | 2 ms |
Le goulot est le GPU ( ms), et le débit maximal vaut requêtes/s (chapitre 3). L’objectif de 50 requêtes/s correspond à 50 % d’utilisation du GPU.
Le temps de réponse : en approchant le GPU par une file M/M/1 (chapitre 4), ms à 50 requêtes/s, et le p99 de cette partie vaut environ ms. En ajoutant les 5 ms de processeur, on reste sous 150 ms.
La marge : jusqu’à quel débit l’objectif tient-il ? Le p99 de la partie GPU reste sous 150 ms tant que ms, soit , c’est-à-dire environ 69 requêtes/s (un peu moins en comptant le processeur).
Little : à 50 requêtes/s et environ 25 ms de temps de réponse moyen, il y a en moyenne requêtes en cours dans le service. Le serveur doit pouvoir en traiter plusieurs en parallèle, mais pas des centaines.
L’hypothèse exponentielle est pessimiste pour un modèle dont le temps d’inférence est presque constant : la formule de Pollaczek-Khinchine (chapitre 6) donnerait une attente moitié moindre. À l’inverse, des requêtes de tailles très variables (avis de 3 mots ou de 300) augmentent la variabilité, donc l’attente.
Étape 3 : construire un banc d’essai reproductible
Section intitulée « Étape 3 : construire un banc d’essai reproductible »Un service de test
Section intitulée « Un service de test »Pour s’entraîner sans GPU, voici un faux service d’inférence : un seul « GPU » partagé (un verrou), qui met 10 ms par prédiction. C’est une file M/D/1, dont vous connaissez la théorie.
# serveur.py : faux service d'inférence, un seul "GPU" partagé, 10 ms par prédictionimport jsonimport threadingimport timefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
gpu = threading.Lock() # une seule inférence à la fois
class Handler(BaseHTTPRequestHandler): def do_POST(self): self.rfile.read(int(self.headers["Content-Length"])) with gpu: time.sleep(0.010) # le "modèle" corps = json.dumps({"classe": "positif"}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(corps))) self.end_headers() self.wfile.write(corps)
def log_message(self, *args): # pas de journal : il fausserait la mesure pass
ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()Boucle ouverte ou boucle fermée ?
Section intitulée « Boucle ouverte ou boucle fermée ? »Un générateur de charge peut fonctionner de deux façons :
- en boucle fermée, clients virtuels envoient une requête, attendent la réponse, puis recommencent (le réseau fermé du chapitre 5) ;
- en boucle ouverte, les requêtes partent à des dates fixées à l’avance, par exemple selon un processus de Poisson de taux , qu’il y ait des réponses ou non (le réseau ouvert du chapitre 4).
Pour une API publique, où des milliers d’utilisateurs indépendants arrivent sans se concerter, la boucle ouverte est la plus réaliste. La boucle fermée a un défaut sournois : quand le service ralentit, les clients virtuels envoient moins de requêtes, et le générateur cesse de mesurer précisément pendant les ralentissements. On appelle ce biais l’omission coordonnée (coordinated omission) : les pires latences disparaissent des statistiques, et le p99 paraît bien meilleur qu’il n’est.
Le générateur de charge
Section intitulée « Le générateur de charge »Ce générateur envoie des requêtes selon un processus de Poisson au débit demandé, mesure chaque latence depuis la date prévue, compte les erreurs et résume par percentiles. Il commence par une chauffe dont les résultats sont jetés, puis explore plusieurs niveaux de charge, chacun avec sa graine.
# charge.py : générateur de charge en boucle ouverteimport jsonimport randomimport statisticsimport timeimport urllib.requestfrom concurrent.futures import ThreadPoolExecutor
URL = "http://127.0.0.1:8000/predict"CORPS = json.dumps({"texte": "Le concert était génial"}).encode()
def appeler(date_prevue): """Envoie une requête ; renvoie (latence mesurée depuis la date PRÉVUE, succès).""" attente = date_prevue - time.perf_counter() if attente > 0: time.sleep(attente) req = urllib.request.Request(URL, data=CORPS, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=5) as rep: rep.read() ok = rep.status == 200 except Exception: ok = False return time.perf_counter() - date_prevue, ok
def essai(debit, duree, graine): """Arrivées de Poisson au débit demandé (req/s) pendant `duree` secondes.""" rng = random.Random(graine) t = time.perf_counter() + 0.5 fin, dates = t + duree, [] while t < fin: t += rng.expovariate(debit) dates.append(t) with ThreadPoolExecutor(max_workers=200) as pool: resultats = list(pool.map(appeler, dates)) latences = sorted(l for l, ok in resultats if ok) erreurs = sum(not ok for _, ok in resultats) return latences, erreurs
def resume(latences): q = statistics.quantiles(latences, n=100) return {"p50": q[49] * 1000, "p95": q[94] * 1000, "p99": q[98] * 1000}
if __name__ == "__main__": essai(20, 5, graine=0) # chauffe : résultats jetés for debit in (20, 50, 80): latences, erreurs = essai(debit, 20, graine=debit) r = resume(latences) print(f"{debit:>3} req/s : p50 {r['p50']:5.1f} ms p95 {r['p95']:5.1f} ms " f"p99 {r['p99']:6.1f} ms erreurs {erreurs}")Lancez serveur.py dans un terminal, puis charge.py dans un autre. Vous observerez la forme prédite par le chapitre 4 : des percentiles presque stables à faible charge, puis une remontée brutale du p99 quand on approche de 100 requêtes/s. Vos chiffres exacts dépendront de votre machine : sur un portable, la précision de time.sleep et la pile HTTP ajoutent plusieurs millisecondes à chaque requête. Comparez-les au modèle : à 50 requêtes/s, une file M/D/1 à 10 ms prévoit un temps de réponse moyen de ms ; tout écart systématique mesure les surcoûts de votre environnement.
Étape 4 : analyser les résultats
Section intitulée « Étape 4 : analyser les résultats »Assez de données pour le p99
Section intitulée « Assez de données pour le p99 »Le p99 est déterminé par les 1 % de requêtes les plus lentes. Avec 20 s à 50 requêtes/s, vous avez environ 1 000 requêtes, donc une dizaine seulement au-delà du p99 : c’est peu. Pour un p99 fiable, visez plusieurs milliers de requêtes par niveau de charge, et répétez chaque niveau (avec des graines différentes) pour donner un intervalle de confiance sur le p99 lui-même, comme au chapitre 7.
La courbe latence-débit
Section intitulée « La courbe latence-débit »En traçant le p99 en fonction du débit offert, on obtient une courbe en crosse de hockey, semblable à celle du chapitre 4. Le coude de la courbe indique la capacité utile du service : au-delà, quelques requêtes par seconde de plus coûtent très cher en latence. On dimensionne sous le coude, avec une marge.
Les contrôles de cohérence
Section intitulée « Les contrôles de cohérence »Deux vérifications simples trahissent un banc d’essai défectueux :
- Little : le nombre moyen de requêtes en cours, mesuré côté serveur, doit valoir le débit multiplié par le temps de réponse moyen ;
- la loi d’utilisation : l’utilisation mesurée du GPU doit valoir le débit multiplié par la demande de service (). À 50 requêtes/s et 10 ms, on doit lire 50 %. Si l’on lit 80 %, une partie du travail n’a pas été comptée (prétraitement sur le GPU, copies mémoire).
Étape 5 : la même démarche pour un pipeline de données
Section intitulée « Étape 5 : la même démarche pour un pipeline de données »Pour un pipeline par lots (le script d’agrégation des chapitres 7 à 9), la métrique principale n’est plus la latence d’une requête mais le débit (lignes par seconde) et la durée totale. La démarche est identique : trouver l’étape goulot, puis savoir ce que rapporterait son amélioration. On chronomètre chaque étape :
import csvimport ioimport randomimport timefrom contextlib import contextmanager
durees = {}
@contextmanagerdef etape(nom): """Chronomètre un bloc de code et cumule sa durée.""" debut = time.perf_counter() yield durees[nom] = durees.get(nom, 0.0) + time.perf_counter() - debut
random.seed(0)brut = "\n".join(f"{i},{random.randint(10, 80)},{random.choice('ABC')}" for i in range(300_000))
with etape("lecture"): lignes = list(csv.reader(io.StringIO(brut)))with etape("nettoyage"): ventes = [(int(p), c) for _, p, c in lignes if int(p) > 0]with etape("agrégation"): total = {} for prix, cat in ventes: total[cat] = total.get(cat, 0) + prix
t = sum(durees.values())print(f"débit : {len(lignes) / t:,.0f} lignes/s")for nom, d in sorted(durees.items(), key=lambda kv: -kv[1]): print(f"{nom:<11} {d:6.3f} s ({100 * d / t:4.1f} %)")Les parts de chaque étape alimentent directement la loi d’Amdahl (chapitre 9) : si l’agrégation ne pèse que 15 % du temps, même la rendre instantanée ne ferait gagner qu’un facteur . On attaque d’abord l’étape la plus lourde, et l’on remesure, car le goulot se déplace.
La liste de contrôle d’un benchmark reproductible
Section intitulée « La liste de contrôle d’un benchmark reproductible »| Point | Pourquoi |
|---|---|
| Objectif et SLO écrits avant de mesurer | éviter de choisir les métriques après coup |
| Environnement figé et documenté (matériel, versions, réglages) | pouvoir refaire la mesure et la comparer |
| Charge représentative (tailles d’entrées, mélange de requêtes) | un benchmark sur des entrées triviales ne prouve rien |
| Graines fixées pour la charge aléatoire | rejouer exactement le même scénario |
| Phase de chauffe jetée | caches froids, compilation, connexions |
| Générateur en boucle ouverte, latence depuis la date prévue | éviter l’omission coordonnée |
| Générateur hors du système mesuré et surveillé | le générateur ne doit pas être le goulot |
| Plusieurs niveaux de charge, répétés | tracer la courbe latence-débit avec des intervalles de confiance |
| Percentiles avec assez d’échantillons, taux d’erreur | la moyenne cache ce que vivent les utilisateurs |
| Contrôles de cohérence (Little, loi d’utilisation, modèle) | détecter un banc d’essai défectueux |
En résumé
Section intitulée « En résumé »- Une étude complète enchaîne objectif en percentiles, prévision par un modèle, banc d’essai reproductible, analyse et contrôles de cohérence.
- Latence et débit tirent souvent en sens inverse : le batching augmente le débit au prix de la latence.
- Le modèle (goulot, , M/M/1, Little) donne l’ordre de grandeur attendu avant la moindre mesure.
- Pour une API ouverte au public, on mesure en boucle ouverte, depuis la date prévue d’envoi, pour éviter l’omission coordonnée.
- Le p99 demande beaucoup d’échantillons et des répétitions ; on dimensionne sous le coude de la courbe latence-débit.
- Pour un pipeline de données, on chronomètre chaque étape, on trouve le goulot, et on estime les gains avec Amdahl avant d’optimiser.
Exercices
Section intitulée « Exercices »Exercice · Dimensionner le service
facileAu prochain festival, on attend 180 requêtes/s au pic. Chaque GPU a une demande de 10 ms par requête, et l’on veut que chaque GPU reste sous 60 % d’utilisation. Combien de GPU faut-il, si la charge se répartit équitablement ?
Voir la solution
Chaque GPU peut soutenir requêtes/s en restant à 60 %. Il en faut donc . Par prudence (panne d’un GPU, pics au-dessus de la prévision), on en prévoit un quatrième : avec 4 GPU, chacun est à % ; si l’un tombe, les trois autres montent à 60 %, ce qui respecte encore la cible.
En code
import math
lam, D, U_max = 180, 0.010, 0.60 # req/s au pic, demande par GPU (s), utilisation cible
par_gpu = U_max / D # débit soutenable par GPU à 60 %n_gpu = math.ceil(lam / par_gpu)print(par_gpu, n_gpu) # 60.0 req/s, 3 GPUfor n in (4, 3): # 4 GPU, puis 3 si l'un tombe en panne print(n, round(lam / n * D, 2)) # 4 -> 0.45, 3 -> 0.6lam <- 180; D <- 0.010; U_max <- 0.60 # req/s au pic, demande par GPU (s), utilisation cible
par_gpu <- U_max / D # débit soutenable par GPU à 60 %c(par_gpu, ceiling(lam / par_gpu)) # 60 req/s, 3 GPUsapply(c(4, 3), function(n) lam / n * D) # 0.45 avec 4 GPU, 0.60 si l'un tombe en panneExercice · Repérer l'omission coordonnée
moyenUn générateur en boucle fermée utilise 10 clients virtuels sans temps de réflexion. Le service répond en 10 ms, sauf pendant une pause de 2 s (un ramasse-miettes) toutes les 60 s.
- Pendant une minute, combien de requêtes rapides le générateur envoie-t-il, et combien de requêtes subissent la pause ?
- Quelle proportion des latences mesurées dépasse 1 s ? Que verrait un vrai trafic de 1 000 requêtes/s, arrivant indépendamment ?
Voir la solution
- Hors pause, chaque client enchaîne une requête toutes les 10 ms, soit 100 par seconde et 1 000 par seconde pour 10 clients. Sur 58 s, cela fait environ 58 000 requêtes rapides. Pendant la pause, les 10 clients sont bloqués sur une seule requête chacune : seules 10 requêtes subissent la pause.
- Seulement % des latences mesurées dépassent 1 s : le p99 mesuré vaut 10 ms, et la pause est invisible. Avec un vrai trafic indépendant de 1 000 requêtes/s, environ 2 000 requêtes arriveraient pendant la pause, avec des attentes de 0 à 2 s (1 s en moyenne). Cela représente environ 3,3 % des 60 000 requêtes de la minute, dont la moitié attend plus de 1 s : le vrai p99 dépasse la seconde. Le générateur en boucle fermée a sous-estimé le p99 d’un facteur de plus de 100.
En code
import numpy as np
SERVICE, PAUSE = 10, (58_000, 60_000) # en ms : 10 ms, pause de 2 s en fin de minute
def latence(t): """Latence (ms) d'une requête envoyée à la date t : bloquée jusqu'à la fin de la pause.""" attente = PAUSE[1] - t if PAUSE[0] <= t < PAUSE[1] else 0 return attente + SERVICE
# Boucle fermée : 10 clients, chacun renvoie une requête dès qu'il a sa réponsefermee = []for client in range(10): t = 0 while t < 60_000: fermee.append(latence(t)) t += fermee[-1]fermee = np.array(fermee)print(len(fermee), round(np.mean(fermee > 1000), 5), np.percentile(fermee, 99))# 58010 0.00017 10.0 : 10 requêtes lentes seulement, p99 = 10 ms
# Boucle ouverte : 1 000 req/s arrivant indépendamment (Poisson)rng = np.random.default_rng(0)dates = np.cumsum(rng.exponential(1, 70_000)) # un intervalle moyen de 1 msouverte = np.array([latence(t) for t in dates[dates < 60_000]])print(len(ouverte), round(np.mean(ouverte > 1000), 4), round(np.percentile(ouverte, 99)))# 59978 0.0176 1477 : 1,8 % des requêtes au-delà de 1 s, p99 de 1,5 sSERVICE <- 10; PAUSE <- c(58000, 60000) # en ms : 10 ms, pause de 2 s en fin de minute
# Latence (ms) d'une requête envoyée à la date t : bloquée jusqu'à la fin de la pauselatence <- function(t) ifelse(t >= PAUSE[1] & t < PAUSE[2], PAUSE[2] - t, 0) + SERVICE
# Boucle fermée : 10 clients, chacun renvoie une requête dès qu'il a sa réponsefermee <- c()for (client in 1:10) { t <- 0; lat <- numeric(0) while (t < 60000) { lat <- c(lat, latence(t)) t <- t + lat[length(lat)] } fermee <- c(fermee, lat)}length(fermee); mean(fermee > 1000); quantile(fermee, 0.99)# 58010 0.00017 10 : 10 requêtes lentes seulement, p99 = 10 ms
# Boucle ouverte : 1 000 req/s arrivant indépendamment (Poisson)set.seed(0)dates <- cumsum(rexp(70000, rate = 1)) # un intervalle moyen de 1 msouverte <- latence(dates[dates < 60000])length(ouverte); mean(ouverte > 1000); quantile(ouverte, 0.99)# 59881 0.016 1387 : 1,6 % des requêtes au-delà de 1 s, p99 de 1,4 sExercice · Batching et objectif de latence
difficileLe GPU traite un lot de requêtes en ms. Les requêtes arrivent à 150 requêtes/s. On utilise des lots de taille fixe .
- Pour quelles valeurs de le GPU n’est-il pas saturé ?
- Pour et , estimez le temps moyen qu’une requête passe à attendre que son lot se remplisse, plus le temps de traitement du lot (on néglige l’attente derrière d’autres lots). Laquelle respecte le mieux un objectif de latence moyenne de 40 ms ?
Voir la solution
-
Un lot de requêtes met ms, donc la capacité vaut requêtes/s. Il faut , soit , donc . Il faut des lots d’au moins 3 requêtes (capacité de 166,7 req/s pour , de 181,8 pour , de 210,5 pour ). Sans batching (), la capacité ne serait que de 100 requêtes/s.
-
Les requêtes arrivent toutes les ms. Dans un lot de , la -ième requête attend les suivantes, soit en moyenne intervalles.
- : remplissage ms, traitement ms, total environ 32 ms, mais le GPU est occupé à % : l’attente entre lots, négligée ici, sera sensible.
- : remplissage ms, traitement ms, total environ 61 ms, avec un GPU à 71 %.
respecte l’objectif de 40 ms sur ce calcul simplifié, non. Mais laisse peu de marge face à l’attente entre lots : c’est exactement le compromis latence-débit du batching, et une mesure (ou une simulation, chapitre 6) doit trancher.
En code
lam = 150 # requêtes/s
def traitement(b): return (6 + 4 * b) / 1000 # durée d'un lot de b requêtes (s)
# 1. Capacité b / traitement(b), à comparer au débit d'arrivéefor b in (1, 2, 3, 4, 8): print(b, round(b / traitement(b), 1), b / traitement(b) > lam)# 1 100.0 False / 2 142.9 False / 3 166.7 True / 4 181.8 True / 8 210.5 True
# 2. Remplissage moyen ((b - 1) / 2 intervalles) + traitement, et utilisation du GPUfor b in (4, 8): remplissage = (b - 1) / 2 / lam total = remplissage + traitement(b) U = lam / (b / traitement(b)) print(b, round(remplissage * 1000, 1), round(total * 1000, 1), round(U, 2))# 4 10.0 32.0 0.82# 8 23.3 61.3 0.71lam <- 150 # requêtes/straitement <- function(b) (6 + 4 * b) / 1000 # durée d'un lot de b requêtes (s)
# 1. Capacité b / traitement(b), à comparer au débit d'arrivéeb <- c(1, 2, 3, 4, 8)capacite <- b / traitement(b)data.frame(b, capacite = round(capacite, 1), stable = capacite > lam)# capacités 100, 142.9, 166.7, 181.8, 210.5 : stable à partir de b = 3
# 2. Remplissage moyen ((b - 1) / 2 intervalles) + traitement, et utilisation du GPUb <- c(4, 8)remplissage <- (b - 1) / 2 / lamdata.frame(b, remplissage_ms = 1000 * remplissage, total_ms = 1000 * (remplissage + traitement(b)), U = lam * traitement(b) / b)# b remplissage_ms total_ms U# 1 4 10.00000 32.00000 0.8250# 2 8 23.33333 61.33333 0.7125