vLLM, TGI, SGLang à batch=1 : comment je mesure la latence
Les benchmarks de débit répondent à une autre question que celle d'un assistant, où un seul utilisateur attend sa réponse. Le protocole que j'utilise en mono-flux, les métriques à suivre, le plancher physique fixé par la mémoire, et les pièges qui rendent la plupart des chiffres publiés incomparables.
La plupart des benchmarks publiés sur le service de LLM mesurent le débit : batch de 32, 64 ou 128, tokens par seconde cumulés sur des requêtes en parallèle. C'est la bonne métrique pour indexer un corpus ou classer des documents en masse.
Pour un assistant, c'est une autre histoire. Un utilisateur envoie un prompt et attend. La taille de batch vaut un, le GPU est presque au repos, et chaque optimisation qui échange de la latence contre du taux d'occupation joue contre vous.
C'est le cas de mon projet Quant : le LLM tourne avec vLLM sur une instance g6 (une NVIDIA L4), et je suis seul à l'utiliser. Je décris ici comment je mesure ce cas, et pourquoi la plupart des chiffres mono-flux qu'on trouve en ligne ne se comparent pas entre eux.
Anatomie d'une requête
Avant de choisir un moteur, il faut savoir ce qu'on mesure. Une requête en streaming a trois phases, et les moteurs n'optimisent pas tous la même.
Trois nombres, trois choses différentes :
- TTFT : le délai avant que le premier token apparaisse. C'est ce que l'utilisateur ressent comme « est-ce qu'il m'a entendu ? ». Il dépend surtout du prefill, donc de la longueur du prompt.
- ITL / TPOT : l'intervalle entre deux tokens. C'est la vitesse de lecture ressentie. Il dépend surtout de la bande passante mémoire, de la taille du cache KV et de la taille du modèle, et presque pas de la longueur du prompt.
- Le temps de bout en bout : la somme. Utile pour dimensionner, trompeur pour l'expérience : à durée totale égale, un premier token rapide suivi d'un décodage lent paraît meilleur que l'inverse.
À batch=1, le prefill laisse la plus grande partie du GPU inoccupée, puisqu'aucune autre requête ne vient remplir les trous. C'est pour ça que les optimisations pensées pour le débit ne se transposent pas.
Le protocole
- Matériel : un seul GPU, toujours le même, fréquences verrouillées (voir plus bas).
- Modèles : des modèles d'environ 7 à 8 milliards de paramètres, servis en 16 bits.
- Moteurs : une version précise de chaque moteur, notée avec les résultats.
- Charge : un échantillon de prompts représentatif du trafic réel, contexte de 4k, 256 tokens max en sortie.
- Concurrence : 1, puis 4, 16 et 64 pour voir où la latence décroche.
- Métriques : TTFT, ITL et bout en bout, en p50 et p99.
Chaque série est répétée au moins 3 fois, et je publie la médiane des médianes.
Pourquoi les moteurs divergent au décodage
Une fois le décodage lancé, le goulot passe du calcul à la bande passante mémoire : chaque token demande de relire tout le cache KV. La façon dont un moteur organise ce cache décide donc de sa vitesse de décodage.
PagedAttention (vLLM) range le cache KV en blocs de taille fixe, comme un système d'exploitation pagine la mémoire. La fragmentation interne d'une allocation contiguë disparaît presque : une plus grande part du cache tient dans des lectures moins nombreuses et plus denses.
RadixAttention (SGLang) organise les préfixes en cache dans un arbre radix : les requêtes qui partagent un préfixe partagent ses entrées KV. Pour un prompt système répété dans chaque requête, le prefill de ce segment commun se fait une fois au lieu d'une fois par requête. C'est exactement la charge d'un assistant.
Les deux techniques visent des phases différentes. Un moteur peut donc mener sur le TTFT à cache chaud et un autre sur la vitesse de décodage. Elles se combinent, et l'écart entre moteurs se réduit à chaque version : un classement précis est vite périmé.
Côté TGI, la documentation officielle indique que le projet est passé en mode maintenance : seuls les petits correctifs sont encore acceptés, et Hugging Face oriente vers vLLM, SGLang, ou des moteurs locaux comme llama.cpp et MLX. Ça ne change rien à la méthode de mesure, mais c'est un critère à prendre en compte avant de bâtir dessus.
L'intuition mathématique : la mémoire fixe la vitesse
Pour produire un token, le GPU doit relire tous les poids du modèle depuis sa mémoire (la HBM), plus le cache KV. À batch=1, il a très peu de calcul à faire avec ce qu'il lit : il passe l'essentiel du temps à attendre les données. Le temps entre deux tokens a donc un plancher :
temps par token ≥ taille des poids / bande passante mémoire
Un modèle de 7 milliards de paramètres en 16 bits pèse environ 14,5 Go. Une H100 SXM lit sa mémoire à environ 3,35 To/s, d'où un plancher d'environ 4,3 ms par token, soit au plus ~230 tokens par seconde pour un utilisateur seul, quel que soit le moteur. Sur ma L4, dont la bande passante est d'environ 300 Go/s, le même calcul donne un plancher d'environ 48 ms par token, soit une vingtaine de tokens par seconde au mieux. Les moteurs ne passent pas sous ce plancher, ils s'en approchent plus ou moins : moins de lectures inutiles du cache KV avec PagedAttention, moins de prefill répété avec RadixAttention.
À batch=32, la même lecture des poids sert 32 requêtes à la fois. Le débit total est multiplié, mais chaque utilisateur attend toujours au moins ces quelques millisecondes entre deux tokens. C'est pour ça que les benchmarks de débit ne disent rien de l'expérience à batch=1, et que la quantification en 8 ou 4 bits accélère le décodage : elle réduit le nombre d'octets à lire, sans toucher au calcul.
Les pièges qui rendent les chiffres incomparables
C'est la partie de l'article qui me sert le plus.
L'état du cache de préfixes n'est pas indiqué. Un cache de préfixes chaud peut fortement réduire le TTFT quand le prompt système se répète. Un benchmark qui ne dit pas si le cache était chaud, froid ou vidé entre les séries ne se compare à rien. Je publie les deux cas, séparément.
Le temps du client compté comme latence serveur. Un client Python qui parse du JSON à chaque morceau reçu peut ajouter quelques millisecondes, du même ordre que l'écart qu'on cherche à mesurer. Je mesure côté serveur quand c'est possible, et j'indique toujours de quel côté du réseau se trouve l'horloge.
Des tokeniseurs différents. Deux moteurs à qui on donne « le même prompt » peuvent compter un nombre de tokens différent pour la même chaîne. Les métriques par token comparent alors des quantités différentes. La parade : tokeniser une fois en amont et envoyer des identifiants de tokens, ou au minimum publier les nombres de tokens avec les latences.
La taille des morceaux en streaming. Certains serveurs émettent un token par événement SSE, d'autres en regroupent plusieurs. Une ITL calculée à partir des heures d'arrivée des événements mesure la politique d'envoi du serveur, et sa vitesse de décodage reste inconnue.
Les fréquences du GPU bougent. Sous charge prolongée, la carte atteint ses limites thermiques ou de puissance, et une fréquence boost non verrouillée dérive au fil de la série. Je verrouille les fréquences pendant la mesure et j'écarte le préchauffage :
"tok-com"># Verrouiller les fréquences pour que la première et la dernière série soient comparables.
sudo nvidia-smi -pm 1
sudo nvidia-smi -lgc 1980,1980 # une valeur que la carte tient dans la durée(à adapter au GPU)
nvidia-smi --query-gpu=clocks.sm,temperature.gpu,power.draw \
--format=csv -l 5 > clocks.csv # à garder à côté des résultatsUne seule série, c'est du bruit. La latence mono-flux a une longue traîne à droite, à cause de l'allocateur et de la gigue de l'ordonnanceur. Trois répétitions sont un minimum, et la médiane des médianes représente mieux la réalité qu'une moyenne.
Les versions bougent vite. Chaque chiffre est lié à une version précise du moteur. Les moteurs d'inférence changent plus entre deux versions mineures que la plupart des bibliothèques entre deux versions majeures.
Reproduire
"tok-com"># vLLM, mono-flux, cache de préfixes activé. Version épinglée : la noter avec les résultats.
VLLM_VERSION=<version testée>
docker run --gpus all --shm-size=8g -p 8000:8000 \
vllm/vllm-openai:${VLLM_VERSION} \
--model mistralai/Mistral-7B-Instruct-v0.3 \
--max-model-len 8192 \
--enable-prefix-cachingEnsuite, deux séries à concurrence 1 avec le même jeu de prompts : une à cache froid (serveur redémarré), une à cache chaud (après une trentaine de requêtes de préchauffage). Je publie les deux séparément : les fusionner donne un chiffre qui ne décrit aucun des deux cas.
Au moment de choisir
Si la charge est un assistant conversationnel avec un long prompt système partagé, la réutilisation de préfixes l'emportera probablement sur le reste. Je mesure alors le TTFT à chaud et à froid avant de lire toute autre comparaison.
Si la charge est de la génération longue à partir de prompts courts, c'est la vitesse de décodage qui compte, et la réutilisation de préfixes n'apporte presque rien.
Si on ne sait pas dans quel cas on est, c'est la première mesure à faire, sur sa propre distribution de prompts. Un benchmark fait sur ShareGPT renseigne sur ShareGPT.
Limites
Le protocole se transpose d'une machine à l'autre, les classements beaucoup moins. Un résultat vaut pour un GPU, un jeu de versions de moteurs et une distribution de prompts. C'est tout l'intérêt de publier le protocole : chacun peut le faire tourner sur son propre trafic avant de choisir un moteur.