Aller au contenu
English

Mesurer la performance du service

  • Leçon 12
  • 45 min
  • Module 4 : Choisir l'infrastructure et mesurer

Ce que vous saurez faire

Mesurez le service de la même façon chaque fois pour comparer deux configurations. Utilisez une charge fixe et un échauffement. Mesurez p50, p95 et le débit avec un nombre connu de requêtes simultanées. Expliquez ensuite où se trouve la limite. Dites seulement ce que les mesures montrent.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Python 3.14.6 et Docker Desktop (Docker Engine 29.6.1) sur macOS, avec une puce Apple. Les étapes pour Windows et Linux n’ont pas été testées. Vos temps seront différents : comparez vos propres essais entre eux, pas avec ceux du cours.

Une barista prépare un café en une minute. Pendant une période calme, vous commandez et recevez votre café en une minute. À l’heure de pointe, huit personnes vous précèdent et vous attendez neuf minutes. La barista ne travaille pas plus lentement : elle prépare toujours un café par minute, soit soixante par heure. Votre attente a augmenté à cause de la file.

Un café a donc deux mesures différentes. Le temps d’attente d’un client est sa latence, présentée dans Inférence en ligne. Le nombre de cafés préparés en une heure est son débit, présenté dans Inférence par lots. Ce n’est pas la même chose. Plus de clients à la fois changent la première mesure, pas la seconde.

Le problème : un seul nombre cache les requêtes lentes

Section intitulée « Le problème : un seul nombre cache les requêtes lentes »

Tomás a une réponse toute prête : « Une requête prend 3 ms. Donc, le service traite 333 requêtes par seconde et chaque utilisateur attend 3 ms. » Les deux conclusions sont des suppositions. Amira envoie 20 billets de soutien au service, un après l’autre, et examine chaque temps. Voici les temps triés, en millisecondes :

3.7 3.7 3.9 3.9 3.9 3.9 4.0 4.0 4.0 4.1
4.1 4.2 4.2 4.3 4.6 5.0 5.6 6.0 6.5 16.4

La moyenne est de 5,0 ms. Mais 15 des 20 requêtes ont pris moins de 5,0 ms, et une a pris 16,4 ms. La moyenne ne représente pas l’attente d’un utilisateur typique et ne montre pas la requête lente. Le centre de soutien ressent les effets des requêtes lentes.

Triez les temps. Un centile indique combien de requêtes prennent un temps égal ou inférieur à une valeur. p50 (le 50e centile, aussi appelé médiane) est le temps sous lequel restent la moitié des requêtes : la requête typique. p95 est le temps sous lequel restent 95 % des requêtes : seule une requête sur vingt est plus lente. Le budget de Grace utilise p95, car l’agent qui attend le plus longtemps compte aussi.

Voici l’exemple détaillé, avec tous les nombres visibles. Pour 20 temps triés, la moitié est 10. p50 se trouve donc entre le 10e et le 11e temps. Les deux valent 4,1, donc p50 = 4,1 ms. 95 % de 20 donne 19. p95 se trouve donc entre le 19e temps (6,5) et le 20e (16,4), très près du 19e : bench.py donne 7,0 ms.

Vingt temps de requête en colonnes, triés du plus rapide au plus lent : 3,7, 3,7, 3,9, 3,9, 3,9, 3,9, 4,0, 4,0, 4,0, 4,1, 4,1, 4,2, 4,2, 4,3, 4,6, 5,0, 5,6, 6,0, 6,5 et 16,4 ms. Une ligne pointillée entre les 10e et 11e colonnes indique p50 = 4,1 ms. Une ligne pointillée entre les 19e et 20e colonnes indique p95 = 7,0 ms. La 20e colonne, à 16,4 ms, est beaucoup plus haute que toutes les autres.
Vingt temps réels de requête, triés. p50 se trouve au milieu; p95 est près du 19e temps sur 20. La seule requête lente déplace la moyenne, mais pas p50.
Description textuelle du diagramme

Vingt colonnes, une par requête, triées de la plus rapide (3,7 ms) à la plus lente (16,4 ms). La plupart des colonnes sont entre 3,7 et 4,6 ms; viennent ensuite 5,0, 5,6, 6,0 et 6,5 ms. La dernière est à 16,4 ms. Une ligne pointillée entre les 10e et 11e colonnes indique p50 = 4,1 ms. Une ligne pointillée entre les 19e et 20e colonnes indique p95 = 7,0 ms.

Avec 1 000 requêtes, l’idée reste la même : p50 se situe vers le 500e temps, p95 vers le 950e. Vingt requêtes ne suffisent pas pour un p95 fiable, car une seule requête le détermine. C’est une des raisons pour lesquelles un banc d’essai en envoie beaucoup.

Un banc d’essai est une mesure que vous pouvez répéter. Il envoie les mêmes requêtes de la même façon. Ainsi, deux essais diffèrent seulement par ce que vous avez changé. bench/bench.py le fait pour le service :

  • Une charge fixe : les billets de juillet dans l’ordre du fichier, un billet par requête. Chaque essai envoie les mêmes billets.
  • Un échauffement : le script envoie les 50 premières requêtes, mais ne les mesure pas. Les coûts des premières requêtes (connexions, caches) ne comptent donc pas comme des temps typiques.
  • La simultanéité : --concurrency lance des fils d’exécution qui envoient des requêtes en même temps. Chaque fil envoie sa prochaine requête dès qu’il reçoit sa réponse.
  • Les résultats : p50, p95, p99, la requête la plus lente (max) et le débit. Ils sont affichés et ajoutés sur une ligne dans bench/results.jsonl, avec les paramètres.

Créez le dossier bench et le fichier :

bench/bench.py
"""Send tickets to a running service and measure how long each answer takes.
python bench/bench.py http://127.0.0.1:8000 --requests 1000 --concurrency 4
The workload is fixed, so two runs can be compared: the July tickets from
data/july_tickets.csv, in file order, one ticket per request. The first
--warmup requests are sent but not measured. Prints p50, p95, p99 and the
throughput, and adds one line to bench/results.jsonl with the settings.
"""
import argparse
import json
import os
import statistics
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
FEATURES = ["channel", "team", "segment", "region", "priority", "order_value", "word_count",
"customer_tenure_days", "prior_tickets_90d", "created_hour", "prior_escalations_90d"]
INTS = ["priority", "word_count", "customer_tenure_days", "prior_tickets_90d", "created_hour", "prior_escalations_90d"]
def load_tickets(path: Path) -> list[bytes]:
df = pd.read_csv(path, keep_default_na=False, na_values=[""], dtype={c: "Int64" for c in INTS})
bodies = []
for row in df[["ticket_id"] + FEATURES].to_dict("records"):
ticket = {k: (None if pd.isna(v) else (v.item() if hasattr(v, "item") else v)) for k, v in row.items()}
bodies.append(json.dumps(ticket).encode())
return bodies
def percentile(values: list[float], p: float) -> float:
return statistics.quantiles(values, n=100, method="inclusive")[p - 1]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("--requests", type=int, default=1000)
parser.add_argument("--concurrency", type=int, default=1)
parser.add_argument("--warmup", type=int, default=50)
parser.add_argument("--label", default="", help="a name for this configuration, for the results file")
parser.add_argument("--tickets", type=Path, default=Path("data/july_tickets.csv"))
args = parser.parse_args()
bodies = load_tickets(args.tickets)
headers = {"Content-Type": "application/json"}
if os.environ.get("API_KEY"):
headers["X-API-Key"] = os.environ["API_KEY"]
def send(i: int) -> tuple[float, int]:
request = urllib.request.Request(f"{args.url}/v1/score", data=bodies[i % len(bodies)], headers=headers)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=30) as response:
response.read()
status = response.status
except urllib.error.HTTPError as error:
status = error.code
except OSError:
status = 0
return (time.perf_counter() - started) * 1000, status
for i in range(args.warmup):
send(i)
started = time.perf_counter()
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
results = list(pool.map(send, range(args.warmup, args.warmup + args.requests)))
seconds = time.perf_counter() - started
ms = [r[0] for r in results if r[1] == 200]
errors = sum(1 for r in results if r[1] != 200)
summary = {
"label": args.label,
"at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"requests": args.requests,
"concurrency": args.concurrency,
"errors": errors,
"p50_ms": round(percentile(ms, 50), 1),
"p95_ms": round(percentile(ms, 95), 1),
"p99_ms": round(percentile(ms, 99), 1),
"max_ms": round(max(ms), 1),
"per_second": round(args.requests / seconds, 1),
}
print(f"{args.requests} requests, concurrency {args.concurrency}: p50 {summary['p50_ms']} ms, "
f"p95 {summary['p95_ms']} ms, p99 {summary['p99_ms']} ms, max {summary['max_ms']} ms, "
f"{summary['per_second']} requests/s, {errors} errors.")
with open(Path(__file__).parent / "results.jsonl", "a", encoding="utf-8") as out:
out.write(json.dumps(summary) + "\n")
if __name__ == "__main__":
main()

percentile utilise statistics.quantiles de Python avec la méthode inclusive : les mêmes p50 et p95 que dans l’exemple détaillé. Seules les requêtes avec le statut 200 comptent dans les temps. Les autres comptent comme des erreurs, car une erreur rapide n’est pas une réponse rapide. Git ignore results.jsonl : c’est votre cahier de laboratoire, pas du code.

Exemple détaillé : une requête à la fois, puis huit

Section intitulée « Exemple détaillé : une requête à la fois, puis huit »

Amira démarre le service avec un processus et un CPU (WORKERS=1, CPUS=1.0 dans .env). Elle lance ensuite le banc d’essai, une requête à la fois :

Terminal, in ~/projects/escalation-service
docker compose up -d --wait
python bench/bench.py http://127.0.0.1:8000 --requests 1000 --concurrency 1 --label "workers=1 cpus=1.0"

La sortie :

1000 requests, concurrency 1: p50 4.1 ms, p95 6.1 ms, p99 8.7 ms, max 52.3 ms, 221.6 requests/s, 0 errors.

Prédisez : maintenant, huit requêtes à la fois. Qu’arrive-t-il à p50 et au nombre de requêtes par seconde?

Terminal
python bench/bench.py http://127.0.0.1:8000 --requests 1000 --concurrency 8 --label "workers=1 cpus=1.0"

La sortie :

1000 requests, concurrency 8: p50 31.6 ms, p95 72.5 ms, p99 105.7 ms, max 136.1 ms, 216.5 requests/s, 0 errors.

Le débit n’a pas augmenté : 221,6 et 216,5 requêtes par seconde. p50 a été multiplié par environ huit, passant de 4,1 à 31,6 ms. C’est comme au café : un processus correspond à une barista. Avec huit requêtes à la fois, une nouvelle requête attend environ sept autres requêtes. Elle attend donc environ huit fois le temps d’une requête. Vérifiez : 8 requêtes ÷ 216,5 par seconde = 37 ms, près du p50 mesuré. Le p95 de 72,5 ms dépasse le budget de 50 ms de Grace, avant même d’ajouter le réseau.

Exploration : la simultanéité et la file d’attente

Section intitulée « Exploration : la simultanéité et la file d’attente »

Amira a mesuré chaque nombre de l’outil d’exploration : 1 000 requêtes par réglage, l’essai du milieu parmi cinq essais. L’axe du temps utilise une échelle logarithmique. Chaque pas de même longueur multiplie le temps par dix (1, 10, 100 ms). Ainsi, 4 ms et 60 ms tiennent sur un seul axe.

Explorer

Latence : que se passe-t-il quand plus de requêtes attendent en même temps?

p50 (médiane)
4,1 ms
p95
6,1 ms
Requêtes par seconde
222

1 worker, 1 CPU, concurrence 1 : une requête à la fois, donc aucune requête n’en attend une autre. La moitié des requêtes prennent 4,1 ms ou moins. La plupart des requêtes prennent à peu près le même temps, donc les barres sont proches les unes des autres.

0100200300400Requêtes1251020501002005001 000+Temps d’une requête (ms, échelle log)De 3,2 à moins de 3,5 ms : 77 requêtesDe 3,5 à moins de 4 ms : 321 requêtesDe 4 à moins de 4,5 ms : 312 requêtesDe 4,5 à moins de 5 ms : 132 requêtesDe 5 à moins de 5,6 ms : 81 requêtesDe 5,6 à moins de 6,3 ms : 31 requêtesDe 6,3 à moins de 7,1 ms : 17 requêtesDe 7,1 à moins de 7,9 ms : 12 requêtesDe 7,9 à moins de 8,9 ms : 7 requêtesDe 8,9 à moins de 10 ms : 2 requêtesDe 10 à moins de 11,2 ms : 2 requêtesp50 4,1 msp95 6,1 ms
0100200300400Requêtes1101001 000+Temps d’une requête (ms, échelle log)De 3,2 à moins de 3,5 ms : 77 requêtesDe 3,5 à moins de 4 ms : 321 requêtesDe 4 à moins de 4,5 ms : 312 requêtesDe 4,5 à moins de 5 ms : 132 requêtesDe 5 à moins de 5,6 ms : 81 requêtesDe 5,6 à moins de 6,3 ms : 31 requêtesDe 6,3 à moins de 7,1 ms : 17 requêtesDe 7,1 à moins de 7,9 ms : 12 requêtesDe 7,9 à moins de 8,9 ms : 7 requêtesDe 8,9 à moins de 10 ms : 2 requêtesDe 10 à moins de 11,2 ms : 2 requêtesp50 4,1 msp95 6,1 ms
1 worker, 1 CPU : 1 000 requêtes à chaque concurrence
Concurrencep50p95Requêtes par seconde
1 (affichée)4,1 ms6,1 ms222
414,9 ms27,1 ms232
831,6 ms72,5 ms216
1660,1 ms112 ms243
1 000 temps de requête mesurés, un processus avec un CPU. Choisissez la simultanéité : le nombre de requêtes envoyées en même temps.
Description textuelle de l’explorateur

Un histogramme de 1 000 temps de requête mesurés pour un processus avec un CPU. Des lignes pointillées indiquent p50 et p95, et un tableau présente les quatre essais. Simultanéité 1 : p50 de 4,1 ms, p95 de 6,1 ms, 222 requêtes par seconde. Presque toutes les requêtes se trouvent dans les barres de 2 à 8 ms. Simultanéité 4 : p50 de 14,9 ms, p95 de 27,1 ms, 232 requêtes par seconde. Simultanéité 8 : p50 de 31,6 ms, p95 de 72,5 ms, 216 requêtes par seconde. Simultanéité 16 : p50 de 60,1 ms, p95 de 111,7 ms, 243 requêtes par seconde. Quand la simultanéité augmente, tout l’histogramme se déplace vers la droite et s’étale. Le débit reste entre environ 215 et 245 requêtes par seconde.

Le débit reste près de 220 requêtes par seconde à chaque niveau de simultanéité. Le processus unique est la limite : il utilise un CPU, et chaque requête supplémentaire attend simplement plus longtemps. Une partie du système qui limite l’ensemble est le goulot d’étranglement.

Voici maintenant la deuxième commande : la configuration. Deux processus avec deux CPU, deux processus qui partagent un CPU, et un processus avec un demi-CPU.

Explorer

Latence : processus et CPU

p50 (médiane)
16,2 ms
p95
37,3 ms
Requêtes par seconde
423

2 workers, 2 CPU, concurrence 8 : 8 requêtes sont envoyées en même temps. p50 est de 16,2 ms, soit 3,9 fois les 4,2 ms à la concurrence 1. Le débit est 2 fois celui de la concurrence 1 (423 par seconde). Plus de travail se fait en même temps, car plus d’un CPU travaille, mais chaque requête attend encore son tour.

050100150Requêtes1251020501002005001 000+Temps d’une requête (ms, échelle log)De 3,5 à moins de 4 ms : 1 requêteDe 4 à moins de 4,5 ms : 3 requêtesDe 4,5 à moins de 5 ms : 5 requêtesDe 5 à moins de 5,6 ms : 15 requêtesDe 5,6 à moins de 6,3 ms : 6 requêtesDe 6,3 à moins de 7,1 ms : 14 requêtesDe 7,1 à moins de 7,9 ms : 24 requêtesDe 7,9 à moins de 8,9 ms : 40 requêtesDe 8,9 à moins de 10 ms : 43 requêtesDe 10 à moins de 11,2 ms : 70 requêtesDe 11,2 à moins de 12,6 ms : 72 requêtesDe 12,6 à moins de 14,1 ms : 87 requêtesDe 14,1 à moins de 15,8 ms : 103 requêtesDe 15,8 à moins de 17,8 ms : 94 requêtesDe 17,8 à moins de 20 ms : 88 requêtesDe 20 à moins de 22,4 ms : 87 requêtesDe 22,4 à moins de 25,1 ms : 69 requêtesDe 25,1 à moins de 28,2 ms : 58 requêtesDe 28,2 à moins de 31,6 ms : 40 requêtesDe 31,6 à moins de 35,5 ms : 25 requêtesDe 35,5 à moins de 39,8 ms : 18 requêtesDe 39,8 à moins de 44,7 ms : 13 requêtesDe 44,7 à moins de 50,1 ms : 5 requêtesDe 50,1 à moins de 56,2 ms : 1 requêteDe 56,2 à moins de 63,1 ms : 2 requêtesDe 63,1 à moins de 70,8 ms : 5 requêtesDe 70,8 à moins de 79,4 ms : 1 requêteDe 79,4 à moins de 89,1 ms : 5 requêtesDe 89,1 à moins de 100 ms : 5 requêtesDe 100 à moins de 112,2 ms : 1 requêtep50 16,2 msp95 37,3 ms
050100150Requêtes1101001 000+Temps d’une requête (ms, échelle log)De 3,5 à moins de 4 ms : 1 requêteDe 4 à moins de 4,5 ms : 3 requêtesDe 4,5 à moins de 5 ms : 5 requêtesDe 5 à moins de 5,6 ms : 15 requêtesDe 5,6 à moins de 6,3 ms : 6 requêtesDe 6,3 à moins de 7,1 ms : 14 requêtesDe 7,1 à moins de 7,9 ms : 24 requêtesDe 7,9 à moins de 8,9 ms : 40 requêtesDe 8,9 à moins de 10 ms : 43 requêtesDe 10 à moins de 11,2 ms : 70 requêtesDe 11,2 à moins de 12,6 ms : 72 requêtesDe 12,6 à moins de 14,1 ms : 87 requêtesDe 14,1 à moins de 15,8 ms : 103 requêtesDe 15,8 à moins de 17,8 ms : 94 requêtesDe 17,8 à moins de 20 ms : 88 requêtesDe 20 à moins de 22,4 ms : 87 requêtesDe 22,4 à moins de 25,1 ms : 69 requêtesDe 25,1 à moins de 28,2 ms : 58 requêtesDe 28,2 à moins de 31,6 ms : 40 requêtesDe 31,6 à moins de 35,5 ms : 25 requêtesDe 35,5 à moins de 39,8 ms : 18 requêtesDe 39,8 à moins de 44,7 ms : 13 requêtesDe 44,7 à moins de 50,1 ms : 5 requêtesDe 50,1 à moins de 56,2 ms : 1 requêteDe 56,2 à moins de 63,1 ms : 2 requêtesDe 63,1 à moins de 70,8 ms : 5 requêtesDe 70,8 à moins de 79,4 ms : 1 requêteDe 79,4 à moins de 89,1 ms : 5 requêtesDe 89,1 à moins de 100 ms : 5 requêtesDe 100 à moins de 112,2 ms : 1 requêtep50 16,2 msp95 37,3 ms
2 workers, 2 CPU : 1 000 requêtes à chaque concurrence
Concurrencep50p95Requêtes par seconde
14,2 ms6,9 ms213
49,8 ms20,2 ms357
8 (affichée)16,2 ms37,3 ms423
1637,4 ms88,4 ms366
La même charge avec quatre configurations de processus et de CPU. Choisissez la configuration et la simultanéité.
Description textuelle de l’explorateur

L’outil d’exploration commence avec deux processus et deux CPU, à une simultanéité de 8 : p50 de 16,2 ms, p95 de 37,3 ms, 423 requêtes par seconde.

Les quatre configurations à une simultanéité de 1 et de 8, avec p50, p95 et les requêtes par seconde. 1 processus, 1 CPU : 4,1 ms, 6,1 ms, 222; et 31,6 ms, 72,5 ms, 216. 2 processus, 2 CPU : 4,2 ms, 6,9 ms, 213; et 16,2 ms, 37,3 ms, 423. 2 processus, 1 CPU : 4,7 ms, 8,0 ms, 190; et 21,4 ms, 87,2 ms, 209. 1 processus, 0,5 CPU : 4,1 ms, 44,1 ms, 112; et 88,6 ms, 196,2 ms, 82. Mémoire : environ 155 Mio avec un processus et environ 350 Mio avec deux.

Les mêmes nombres dans un tableau, pour une simultanéité de 1 et de 8 :

Configuration Simultanéité 1 : p50 / p95 / req/s Simultanéité 8 : p50 / p95 / req/s Mémoire
1 processus, 1 CPU 4,1 / 6,1 ms / 222 31,6 / 72,5 ms / 216 155 Mio
2 processus, 2 CPU 4,2 / 6,9 ms / 213 16,2 / 37,3 ms / 423 347 Mio
2 processus, 1 CPU 4,7 / 8,0 ms / 190 21,4 / 87,2 ms / 209 349 Mio
1 processus, 0,5 CPU 4,1 / 44,1 ms / 112 88,6 / 196,2 ms / 82 155 Mio

Deux processus avec deux CPU doublent presque le débit sous charge : il y a deux baristas. Deux processus sur un CPU ne le font pas : ils travaillent à tour de rôle, et p95 augmente. Un demi-CPU réduit le débit de moitié. La limitation fait passer p95 à 44,1 ms, même avec une requête à la fois.

Un banc d’essai mesure un service à chaud : démarré, chargé, avec son échauffement terminé. La première requête après un démarrage est à froid pour la plateforme, mais pas pour le modèle. L’autovérification a évalué les 14 cas de parité au démarrage : c’est l’échauffement. Sur un CPU, la première prédiction après /ready a pris de 4,5 à 5,7 ms. Les suivantes ont pris un temps médian de 3,1 à 5,5 ms. Le vrai coût à froid est le démarrage lui-même.

Deux barres empilées avec des échelles différentes. Démarrage, une fois par processus, avec 1 CPU, environ 1,6 s : démarrage du conteneur 0,15 s, importation de FastAPI, uvicorn et pydantic 0,41 s, importation de NumPy, pandas et joblib 0,31 s, importation de scikit-learn 0,52 s, chargement du paquet 16 ms (empreintes, versions, joblib.load et autovérification), démarrage du serveur 0,21 s. Une requête à chaud, médiane, environ 3,1 ms : HTTP, réseau et client 0,68 ms, vérifications, JSON et journal 0,41 ms, frame 0,57 ms, prepare (prétraitement) 1,38 ms, model 0,06 ms.
La répartition de la latence, mesurée sur le portable avec 1 CPU. Le démarrage est presque entièrement consacré aux importations; une requête, surtout au prétraitement et à HTTP. Le modèle représente la plus petite partie des deux.
Description textuelle du diagramme

Deux barres empilées. La barre du démarrage, une fois par processus avec 1 CPU, représente environ 1,6 s : démarrage du conteneur 0,15 s; importation de FastAPI, uvicorn et pydantic 0,41 s; importation de NumPy, pandas et joblib 0,31 s; importation de scikit-learn 0,52 s; chargement du paquet 16 ms au total (empreintes et versions, joblib.load et autovérification); démarrage du serveur 0,21 s.

La barre des requêtes, la médiane de 900 requêtes à chaud, représente environ 3,1 ms : HTTP, réseau et client 0,68 ms; vérifications du contrat, JSON et ligne du journal 0,41 ms; frame (tableau de données) 0,57 ms; prepare (préparer), le prétraitement, 1,38 ms; et model (modèle) 0,06 ms.

Le démarrage prend environ 500 fois le temps d’une requête, presque entièrement pour importer des bibliothèques. Charger et vérifier le paquet de 5 ko prend 16 ms. Dans chaque requête, le modèle prend 0,06 ms; le prétraitement et HTTP occupent la majeure partie du temps.

Le goulot d’étranglement et ce que les nombres ne disent pas

Section intitulée « Le goulot d’étranglement et ce que les nombres ne disent pas »

Amira écrit ce que les mesures montrent, et seulement cela :

Sur ce portable, avec les billets de juillet, un processus avec un CPU plafonne à environ 220 requêtes par seconde. Le CPU le limite : plus de requêtes simultanées attendent simplement plus longtemps. Deux processus avec deux CPU ont atteint environ 420. Avec un demi-CPU, p95 était de 44 ms, même avec une requête à la fois, pendant que le banc d’essai occupait constamment le service.

Tomás : « 44 ms, c’est presque tout le budget. Payons pour 1 vCPU. » Amira vérifie d’abord les conditions. Le banc d’essai envoie le prochain billet dès que la dernière réponse arrive. Le service est donc occupé en permanence, et la limite de CPU ralentit un conteneur occupé. Larkfield envoie environ 45 billets par jour. Le service est inactif presque tout le temps. Une seule requête n’épuise jamais les 50 ms d’une période. Le banc d’essai n’a pas mesuré cette charge, il ne peut donc pas déterminer la taille. Amira garde 0,5 vCPU et inscrit la condition d’un changement dans le registre de décision.

Omar demande : « Donc, dans le nuage, on peut traiter 220 requêtes par seconde? » Amira ne dit pas oui non plus. Le banc d’essai tournait sur un portable, avec le client et le service sur le même ordinateur, sans réseau. Le nuage utilise d’autres CPU, et un réseau sépare le centre de soutien du service.

L’équipe du cours a mesuré le service sur Azure Container Apps (en conditions réelles). Le client se trouvait à Toronto et ouvrait une nouvelle connexion HTTPS pour chacune des 25 requêtes à chaud :

Taille Première requête à partir de zéro réplique Requête à chaud, p50 total Attente du serveur, p50 Server-Timing, à chaud (frame / prepare / model)
0,5 vCPU, 1 Gio 26,4 s 96 ms 38 ms 2,00 / 4,38 / 0,17 ms
1 vCPU, 2 Gio 23,9 s 99 ms 41 ms 2,05 / 5,31 / 0,18 ms

Doubler le CPU a réduit le démarrage à froid d’environ 2,5 s, sans accélérer une requête à chaud. Sur les 96 ms, environ 22 ms ont servi à la connexion. Environ 32 ms supplémentaires ont servi à la négociation TLS (l’établissement de HTTPS). Le serveur n’a vu la requête qu’après cela. Le démarrage à froid sert surtout à trouver une machine et à télécharger l’image de 166 Mo. Le CPU du nuage a aussi effectué le prétraitement environ trois fois plus lentement que le portable. Personne ne pouvait prédire ces nombres à partir du portable.

Le résumé honnête comporte donc deux parties. Mesuré : le goulot d’étranglement sur le portable et la limitation d’un conteneur occupé avec un demi-CPU. Non mesuré par le banc d’essai : le débit du nuage et le réseau. Pour le budget de 50 ms, le réseau est déterminant. Une nouvelle connexion HTTPS pour chaque prédiction coûte plus que le budget. La page du centre de soutien doit donc garder sa connexion au service ouverte. La charge de Larkfield, environ 45 billets par jour, reste de toute façon bien inférieure à 220 par seconde.

Erreur courante

Deux essais du même banc d'essai donnent des résultats différents

Symptôme : Amira teste la même configuration cinq fois : un processus, un CPU, une simultanéité de 8, les mêmes 1 000 billets. Rien n’a changé entre les essais. Le débit :

202, 209, 216, 237, 283 requests/s

L’essai le plus rapide est 40 % plus rapide que le plus lent. Avec un seul essai, elle aurait pu « prouver » que n’importe quel changement accélère ou ralentit le service.

Cause : le banc d’essai et le service partagent le portable avec tous les autres programmes. Pendant ces essais, d’autres conteneurs et programmes utilisaient plusieurs CPU (la charge moyenne était de 5 à 9 sur 10 CPU). Le client du banc d’essai utilise aussi du CPU. Quand un autre programme prend du temps CPU, le client et le service attendent tous les deux.

Solution : fermez les autres programmes. Testez chaque configuration au moins trois fois et gardez l’essai du milieu (la médiane), pas le meilleur. Testez les configurations à comparer l’une après l’autre, au même moment de la journée. Indiquez la dispersion avec les nombres. L’outil d’exploration du cours utilise l’essai du milieu parmi cinq essais pour chaque réglage.

Pour un vrai service d’IA : dans le nuage, d’autres clients partagent aussi les machines. Ne décidez jamais à partir d’un seul essai. Ne comparez jamais un nombre de votre portable avec un nombre du nuage. Notez les conditions à côté du résultat : ordinateur, charge, simultanéité, date.

Exercice guidé → tâche autonome. L’exemple détaillé comparait 1 et 8 requêtes simultanées pour une configuration. Comparez maintenant deux configurations et consignez la charge de travail pour que quelqu’un d’autre puisse reproduire le test.

  1. Fermez les autres programmes qui utilisent beaucoup de CPU.
  2. Dans .env, définissez WORKERS=1 et CPUS=1.0. Démarrez avec docker compose up -d --wait.
  3. Exécutez bench/bench.py avec --requests 1000, avec 1 puis 8 requêtes simultanées, et un --label qui nomme la configuration. Répétez chaque test trois fois.
  4. Dans .env, définissez CPUS=0.5. Redémarrez avec docker compose up -d --wait. Vérifiez avec docker inspect que NanoCpus vaut 500000000.
  5. Exécutez les mêmes six bancs d’essai avec la nouvelle étiquette.
  6. Exécutez docker stats --no-stream pour mesurer la mémoire.
  7. Ouvrez bench/results.jsonl. Pour chaque configuration et nombre de requêtes simultanées, retenez l’exécution médiane.
  8. Dans docs/infrastructure.md, ajoutez la section Mesures sur un portable : la charge de travail, l’ordinateur, le tableau et le goulot d’étranglement. Précisez ce qui n’est pas mesuré.
  9. Comparez p95 au budget de 50 ms, sous la charge réelle du service. Si la décision doit changer, changez-la et notez la date et la raison.
Afficher le corrigé : la nouvelle section de docs/infrastructure.md

Voici les chiffres d’Amira. Les vôtres diffèrent; la tendance est la même.

docs/infrastructure.md
## Measured on a laptop
Workload: `bench/bench.py`, 1,000 July tickets in file order, one ticket per request, 50 warm-up requests.
Computer: Apple silicon laptop, Docker Desktop, other programs running. Each number is the middle of five runs.
| Configuration | Concurrency 1: p50 / p95 / req/s | Concurrency 8: p50 / p95 / req/s | Memory |
|---|---|---|---|
| 1 worker, 1 CPU | 4.1 / 6.1 ms / 222 | 31.6 / 72.5 ms / 216 | 155 MiB |
| 1 worker, 0.5 CPU | 4.1 / 44.1 ms / 112 | 88.6 / 196.2 ms / 82 | 155 MiB |
- Cold start (docker run to /ready, image on the computer): 1 CPU 2.0 s, 0.5 CPU 3.5 s (median of 8 runs).
- Bottleneck: one worker uses one CPU. More requests at once only wait longer; throughput stays near 220 requests per second.
- At 0.5 CPU, p95 was 44 ms even with one request at a time, because the CPU limit pauses a busy container (throttling). The benchmark kept the service busy all the time. Larkfield's 45 tickets a day leave it idle almost all the time, so this is not the load we serve. We keep 0.5 vCPU, and we watch p95 in production (Module 5). If the load grows to keep the CPU busy, we move to 1 vCPU (US$28.94 to US$101.30 for 30 days).
- For the 50 ms budget, the open question is the network between the help desk and the service, which this benchmark does not include.
- Not measured here: the cloud network, the cloud's CPUs and the image download. Do not use these numbers as cloud numbers.

Dans La décision, elle ajoute une ligne sous la taille pour que le lecteur voie la vérification :

docs/infrastructure.md
Checked after the benchmark: no change (see below).

Les cases des trois premières vérifications deviennent [x]. La dernière, celle du nuage, reste ouverte jusqu’au premier déploiement.

La tâche autonome : choisissez vos propres configurations, par exemple WORKERS=2 CPUS=2.0 contre WORKERS=1 CPUS=1.0, avec 16 requêtes simultanées. Prédisez le résultat, mesurez chaque configuration trois fois, puis notez le résultat et les conditions dans la même section.

Vérifiez votre compréhension

Rappel : que signifient p50 et p95, et pourquoi le budget de Grace utilise-t-il p95?

Afficher la réponse

Triez les temps des requêtes. p50 est le temps sous lequel se trouve la moitié des requêtes : la requête typique. p95 est le temps sous lequel se trouvent 95 % des requêtes. Ainsi, seule une requête sur vingt est plus lente. Un budget basé sur p95 protège les agents qui attendent le plus longtemps. Un budget basé sur la moyenne ou p50 les masquerait. Dans l’exemple détaillé, la moyenne était de 5,0 ms à cause d’une requête de 16,4 ms. Pourtant, p50 était de 4,1 ms.

Vérifiez votre compréhension

Prédiction : avec un processus et un CPU, passez de 8 à 16 requêtes simultanées. Qu'arrive-t-il à p50 et au nombre de requêtes par seconde, et pourquoi?

Afficher la réponse

p50 double environ, et le débit reste à peu près le même. Un processus sur un CPU traite un nombre fixe de requêtes par seconde, environ 220 ici. C’est le goulot d’étranglement. Deux fois plus de requêtes simultanées signifie que chacune attend environ deux fois plus de requêtes. Les mesures : p50 de 31,6 ms à 8, de 60,1 ms à 16; 216 et 243 requêtes par seconde.

Vérifiez votre compréhension

Vérification finale : expliquez le goulot d'étranglement du service d'escalade à partir de vos mesures, sans affirmer ce que vous n'avez pas mesuré.

Afficher la réponse

Une bonne réponse nomme les conditions, la limite et les preuves, puis précise ce qui reste inconnu. Par exemple : « Sur mon portable (Apple silicon, Docker Desktop), j’ai envoyé 1 000 billets de soutien de juillet, un par requête. Un processus avec un CPU plafonnait à environ 220 requêtes par seconde, avec 1, 8 et 16 requêtes simultanées. Pendant ce temps, p50 augmentait avec le nombre de requêtes simultanées. Le seul CPU du processus est donc le goulot d’étranglement. Avec 0,5 CPU, p95 était de 44 ms, même avec une seule requête à la fois, à cause du bridage. Je n’ai pas mesuré le nuage : ses CPU, son réseau et le téléchargement de l’image sont différents. Ces chiffres ne représentent donc pas le nuage. » Une réponse comme « le service traite 220 requêtes par seconde en production » extrapole au-delà des conditions mesurées. Il en va de même pour « le nuage sera aussi rapide ».

Récapitulatif

Le module 4 a choisi où exécuter le modèle et a mesuré sa performance :

  • CPU, GPU et points de terminaison gérés : l’étape du modèle prend 0,05 ms sur environ 3 ms. Un CPU suffit donc. Un conteneur sur Azure Container Apps coûte beaucoup moins qu’un point de terminaison géré pour 45 billets par jour. docs/infrastructure.md consigne la décision.
  • Paramètres de ressources du conteneur : l’image contient le paquet; compose.yaml définit les CPU, la mémoire sans permutation et les processus. Bridage, processus = CPU, et OOMKilled (arrêt par manque de mémoire) quand les processus dépassent la mémoire disponible.
  • Mesurer la performance du service : une charge de travail fixe, un échauffement, p50 et p95, le débit et les requêtes simultanées. Un processus sur un CPU est le goulot d’étranglement, à environ 220 requêtes par seconde sur le portable. Un demi-CPU bride un service occupé, mais la charge de Larkfield laisse le service inactif. La taille reste donc inchangée. Dans le nuage (exécution réelle), le réseau et le téléchargement de l’image dominent.

Réinitialisation. Pour commencer le module 5 dans un état propre :

  1. Arrêtez le service : docker compose down.
  2. Si votre projet ne fonctionne plus, copiez l’instantané end-of-m04 dans ~/projects/escalation-service, comme dans Utiliser un instantané. Copiez ensuite .env.example vers .env et collez l’empreinte du paquet de l’instantané.
  3. Pour supprimer aussi l’image : docker image rm escalation-service:1.0.0. La prochaine commande docker compose up -d --build --wait la reconstruit.

Ensuite, une nouvelle version du modèle arrive. Vous la mettez en production à côté de l’ancienne, les comparez et gardez la possibilité de revenir en arrière.

Leçon suivante : Versions et déploiement progressif