Aller au contenu

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.

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 7/50=1407/50 = 140 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é.

050100150200temps (ms) ; ▼ arrivée d’une requête, à 50 req/ssans lotlots de 8lot de 8la 1re requête attend le remplissage : 7 / 50 = 140 mschaque requête : 10 mscapacité 100 req/s8 requêtes en 30 mscapacité ≈ 267 req/s
Le lot de 8 multiplie la capacité du GPU (de 100 à environ 267 req/s), mais la première requête du lot attend 140 ms avant même d'être traitée.

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 :

RessourceRôleDemande DiD_i
Processeurdécodage JSON, tokenisation3 ms
GPUinférence du modèle10 ms
Processeurmise en forme de la réponse2 ms

Le goulot est le GPU (Dmax⁡=10D_{\max} = 10 ms), et le débit maximal vaut 1/0,010=1001/0{,}010 = 100 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), RGPU=10/(1−0,5)=20R_{\text{GPU}} = 10/(1 - 0{,}5) = 20 ms à 50 requêtes/s, et le p99 de cette partie vaut environ 4,6×20≈924{,}6 \times 20 \approx 92 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 RGPU≤150/4,6≈32,6R_{\text{GPU}} \leq 150/4{,}6 \approx 32{,}6 ms, soit U≤1−10/32,6≈0,69U \leq 1 - 10/32{,}6 \approx 0{,}69, c’est-à-dire environ 69 requêtes/s (un peu moins en comptant le processeur).

020406080100débit offert (req/s)050100150200250300temps passé sur le GPU (ms)objectif : p99 < 150 ms50 req/s : p99 ≈ 92 ms≈ 69 req/s :marge maximaleplafond100 req/smoyenne R = 10 / (1 − U)p95 ≈ 3 Rp99 ≈ 4,6 R
Le modèle, avant toute mesure : à 50 req/s, le p99 de la partie GPU (≈ 92 ms) respecte l'objectif ; il le franchit vers 69 req/s, bien avant le plafond théorique de 100 req/s.

Little : à 50 requêtes/s et environ 25 ms de temps de réponse moyen, il y a en moyenne 50×0,025≈1,2550 \times 0{,}025 \approx 1{,}25 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 »

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édiction
import json
import threading
import time
from 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()

Un générateur de charge peut fonctionner de deux façons :

  • en boucle fermée, NN 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 λ\lambda, 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.

0100200latence (ms)1 seule requête retardée mesurée ;le client n’envoie rien pendant la pausepause du service (200 ms)boucle fermée : 1 client attend sa réponse avant de renvoyer0100200300400500600700date d’envoi prévue (ms)0100200latence (ms)20 requêtes retardées : c’est ceque vivent de vrais utilisateursboucle ouverte : une requête prévue toutes les 10 ms
Pendant la même pause, le client en boucle fermée reste bloqué sur une seule requête et n'en envoie plus : une seule mesure lente. En boucle ouverte, les requêtes prévues continuent d'arriver et toutes subissent la pause.

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 ouverte
import json
import random
import statistics
import time
import urllib.request
from 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 10+0,5×10/(2×0,5)=1510 + 0{,}5 \times 10/(2 \times 0{,}5) = 15 ms ; tout écart systématique mesure les surcoûts de votre environnement.

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.

60708090100110120p99 estimé sur un essai (ms) ; partie GPU à 50 req/s, 2 000 essais simulésvrai p99 : 92 ms1 000 requêtes : 95 % des estimations entre 80 et 104 ms10 000 requêtes : 95 % des estimations entre 88 et 96 ms
Avec 1 000 requêtes (20 s à 50 req/s), le p99 mesuré peut tomber n'importe où entre 80 et 104 ms ; avec 10 000, il se resserre autour de la vraie valeur. D'où plusieurs milliers de requêtes et des répétitions par niveau.

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.

020406080100débit offert (req/s)0255075100125150175200latence (ms)zone de dimensionnement(sous le coude, avec marge)coudefile M/D/1 simulée, 10 msp50p95p99
La courbe latence-débit du faux service : les percentiles bougent peu jusque vers 60 req/s, puis le p99 décolle au coude, vers 80 req/s. On dimensionne dans la zone verte, avec de la marge.

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 (U=XDU = X D). À 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 csv
import io
import random
import time
from contextlib import contextmanager
durees = {}
@contextmanager
def 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 1/0,85≈1,181/0{,}85 \approx 1{,}18. 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 »
PointPourquoi
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éatoirerejouer exactement le même scénario
Phase de chauffe jetéecaches 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éstracer la courbe latence-débit avec des intervalles de confiance
Percentiles avec assez d’échantillons, taux d’erreurla 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
  • 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, 1/Dmax⁡1/D_{\max}, 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.

Exercice · Dimensionner le service

facile

Au 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 0,6/0,010=600{,}6/0{,}010 = 60 requêtes/s en restant à 60 %. Il en faut donc 180/60=3180/60 = 3. 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 à 45×0,010=4545 \times 0{,}010 = 45 % ; 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 GPU
for 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.6

Exercice · Repérer l'omission coordonnée

moyen

Un 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.

  1. Pendant une minute, combien de requêtes rapides le générateur envoie-t-il, et combien de requêtes subissent la pause ?
  2. 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
  1. 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.
  2. Seulement 10/58 010≈0,01710/58\,010 \approx 0{,}017 % 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éponse
fermee = []
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 ms
ouverte = 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 s

Exercice · Batching et objectif de latence

difficile

Le GPU traite un lot de bb requêtes en 6+4b6 + 4b ms. Les requêtes arrivent à 150 requêtes/s. On utilise des lots de taille fixe bb.

  1. Pour quelles valeurs de bb le GPU n’est-il pas saturé ?
  2. Pour b=4b = 4 et b=8b = 8, 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
  1. Un lot de bb requêtes met 6+4b6 + 4b ms, donc la capacité vaut 1 000 b/(6+4b)1\,000\,b/(6 + 4b) requêtes/s. Il faut 1 000 b/(6+4b)>1501\,000\,b/(6 + 4b) > 150, soit 1 000 b>900+600 b1\,000\,b > 900 + 600\,b, donc b>2,25b > 2{,}25. Il faut des lots d’au moins 3 requêtes (capacité de 166,7 req/s pour b=3b = 3, de 181,8 pour b=4b = 4, de 210,5 pour b=8b = 8). Sans batching (b=1b = 1), la capacité ne serait que de 100 requêtes/s.

  2. Les requêtes arrivent toutes les 1/150≈6,671/150 \approx 6{,}67 ms. Dans un lot de bb, la jj-ième requête attend les b−jb - j suivantes, soit en moyenne (b−1)/2(b - 1)/2 intervalles.

    • b=4b = 4 : remplissage 1,5×6,67=101{,}5 \times 6{,}67 = 10 ms, traitement 2222 ms, total environ 32 ms, mais le GPU est occupé à 150/181,8≈82150/181{,}8 \approx 82 % : l’attente entre lots, négligée ici, sera sensible.
    • b=8b = 8 : remplissage 3,5×6,67≈23,33{,}5 \times 6{,}67 \approx 23{,}3 ms, traitement 3838 ms, total environ 61 ms, avec un GPU à 71 %.

    b=4b = 4 respecte l’objectif de 40 ms sur ce calcul simplifié, b=8b = 8 non. Mais b=4b = 4 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ée
for 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 GPU
for 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.71