Continuous Batching : Servir des Dizaines d'Utilisateurs sur un Seul GPU
Pourquoi un GPU qui génère pour un seul utilisateur tourne à vide, comment le continuous batching et PagedAttention corrigent ça, et comment régler vLLM en production.
Par Alex Perey
Un GPU qui fait tourner un LLM pour un seul utilisateur utilise moins de 5 % de sa capacité de calcul. Le reste du temps, il attend que la mémoire lui livre les poids. Le continuous batching est la technique qui transforme ce temps perdu en requêtes servies. C'est ce qui fait qu'un moteur comme vLLM sert dix à vingt fois plus d'utilisateurs qu'un script Transformers naïf, sur le même matériel.
Ce cours suppose que vous savez ce qu'est le KV cache et pourquoi le decode est limité par la mémoire. Si ce n'est pas le cas, lisez d'abord le cours sur le KV cache.
Le problème : un GPU qui attend
Le decode est limité par la bande passante
À chaque token généré, le GPU doit lire tous les poids du modèle (16 Go pour un 8B en FP16) et le KV cache de la séquence. Le calcul associé est minuscule en comparaison. Sur un H100, lire 16 Go prend environ 5 ms, et le calcul pour un seul token prend une fraction de ce temps.
| Ressource d'un H100 | Capacité | Utilisée pour 1 token, 1 utilisateur |
|---|---|---|
| Bande passante mémoire | ~3,3 To/s | Presque 100 % |
| Calcul FP16 | ~990 TFLOPs | Moins de 2 % |
Le GPU est un cuisinier qui va chercher tous ses ingrédients à la cave pour préparer une seule assiette, puis y retourne pour la suivante.
L'observation qui change tout
Lire les poids coûte le même prix qu'on serve un ou trente utilisateurs. Si trente séquences avancent d'un token dans le même passage, on lit les poids une fois et on produit trente tokens.
Le débit total est multiplié par 32 pour un coût par passage à peine supérieur. C'est ce qu'on appelle batcher les requêtes, et tout le sujet est de savoir comment le faire sans faire attendre les utilisateurs.
Le cuisinier revient de la cave avec de quoi faire trente assiettes. Il ne va pas plus vite pour une assiette, mais il en sort trente dans le même temps.
Static batching : la première idée, et pourquoi elle ne suffit pas
Le principe
On attend d'avoir N requêtes, on les traite ensemble, on renvoie les N réponses, on recommence. C'est ce que fait model.generate() de Transformers quand on lui passe un batch.
Le défaut : les séquences ne finissent pas en même temps
Une requête demande « oui ou non », une autre « rédige un contrat ». Dans un lot statique, le lot ne se libère que quand la plus longue a terminé. Les slots des séquences courtes restent occupés à ne rien produire.
Étape : 1 2 3 4 5 6 7 8 9 10
Séquence A ███ ███ ███ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ (3 tokens, puis attend)
Séquence B ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ (10 tokens)
Séquence C ███ ███ ███ ███ ███ ░░░ ░░░ ░░░ ░░░ ░░░ (5 tokens, puis attend)
Séquence D ███ ███ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ (2 tokens, puis attend)
███ = token utile ░░░ = slot gaspillé
Sur 40 emplacements, 20 produisent un token. 50 % de gaspillage, et les nouvelles requêtes attendent que tout le lot soit fini pour entrer.
| Défaut | Conséquence |
|---|---|
| Le lot se libère au rythme du plus long | GPU sous-utilisé pendant la fin du lot |
| Les requêtes entrantes attendent le lot suivant | Latence au premier token élevée et imprévisible |
| Padding des prompts à la même longueur | Mémoire et calcul gaspillés sur du vide |
Le static batching est correct pour du traitement par lots hors ligne, où toutes les requêtes sont connues d'avance. Il est inadapté à un service en ligne où les requêtes arrivent en continu.
Continuous batching : remplacer à la volée
Le principe
Introduit par le système Orca en 2022, le continuous batching change l'unité de décision. Au lieu de raisonner par lot, le scheduler raisonne par itération : à chaque pas de decode, il regarde quelles séquences ont terminé, les retire, et fait entrer des requêtes en attente à leur place.
Le même exemple qu'au-dessus devient :
Étape : 1 2 3 4 5 6 7 8 9 10
Slot 1 A A A E E E E H H H
Slot 2 B B B B B B B B B B
Slot 3 C C C C C F F F F F
Slot 4 D D G G G G G G I I
Aucun slot vide. Les requêtes E à I sont servies dans le temps qui était gaspillé.
Ce que ça change pour l'utilisateur
| Métrique | Static batching | Continuous batching |
|---|---|---|
| Débit (tokens/s sur le GPU) | Limité par le gaspillage de fin de lot | Proche du maximum |
| TTFT (temps avant le premier token) | Attente du prochain lot, très variable | Entrée dès qu'un slot se libère |
| TPOT (temps entre deux tokens) | Stable | Stable, légèrement plus élevé si le batch est plein |
Ordre de grandeur : sur un GPU et un modèle donnés, passer d'un generate() séquentiel à vLLM avec continuous batching multiplie le débit par 10 à 20 pour des charges avec beaucoup de requêtes courtes.
Micro-exercice 1 : simuler les deux stratégies
Pas besoin de GPU pour comprendre l'écart. On simule un GPU avec B slots, et des requêtes de longueurs variées.
import random
from collections import deque
random.seed(42)
# 64 requêtes, chacune doit générer entre 20 et 400 tokens
requetes = [random.randint(20, 400) for _ in range(64)]
B = 8 # nombre de séquences que le GPU traite par passage
def batching_statique(longueurs, B):
"""Le lot ne se libère que quand sa séquence la plus longue a fini."""
etapes, tokens_utiles = 0, 0
for i in range(0, len(longueurs), B):
lot = longueurs[i:i + B]
duree_lot = max(lot)
etapes += duree_lot
tokens_utiles += sum(lot)
return etapes, tokens_utiles / (etapes * B)
def batching_continu(longueurs, B):
"""À chaque pas, les séquences finies sortent et la file remplit les slots."""
file = deque(longueurs)
actives = [] # tokens restant à générer pour chaque séquence en cours
etapes, tokens_utiles = 0, 0
while file or actives:
while file and len(actives) < B:
actives.append(file.popleft())
actives = [restant - 1 for restant in actives] # un pas de decode
tokens_utiles += len(actives)
etapes += 1
actives = [restant for restant in actives if restant > 0]
return etapes, tokens_utiles / (etapes * B)
for nom, fonction in [("Static", batching_statique), ("Continuous", batching_continu)]:
etapes, utilisation = fonction(requetes, B)
print(f"{nom:11s} : {etapes:5d} pas de decode, GPU utilisé à {utilisation:.0%}")
Résultat attendu : avec la graine 42, le static batching tourne à 57 % d'utilisation et le continuous batching à 90 %. Le nombre de pas, donc le temps total, passe de 2 837 à 1 804, soit un tiers de moins. Augmentez la dispersion des longueurs (randint(5, 1000)) et l'écart se creuse encore.
Ce simulateur ignore le prefill et suppose que tous les slots ont la même capacité mémoire. Les deux sections suivantes traitent précisément ces deux simplifications.
La mémoire : le vrai plafond
Combien de séquences peut-on batcher ?
Le batch n'est pas limité par le calcul, mais par le KV cache. Chaque séquence active occupe de la mémoire GPU, et cette mémoire grandit à chaque token généré.
mémoire disponible pour le cache = mémoire GPU − poids du modèle − activations
nb séquences max ≈ mémoire disponible / (cache par token × longueur moyenne)
Pour Llama 3 8B (128 Ko par token) sur un GPU de 80 Go, il reste environ 60 Go après les poids. Avec des séquences de 4 000 tokens en moyenne, chacune consomme 512 Mo : on peut en tenir une centaine. Contre-intuitif mais vrai : Llama 2 7B, pourtant plus petit en paramètres, n'en tient qu'une vingtaine sur le même GPU. Son cache pèse 512 Ko par token, quatre fois plus, parce qu'il utilise 32 têtes KV (attention classique) là où Llama 3 n'en a que 8 (GQA). La taille du cache dépend du nombre de têtes KV, pas du nombre de paramètres.
Le gaspillage de l'allocation naïve
La première génération de serveurs réservait pour chaque requête un bloc contigu de la taille du contexte maximum, parce qu'on ne sait pas d'avance combien de tokens seront générés. Une requête qui répond « Oui. » réservait quand même 8 000 tokens de cache.
| Type de gaspillage | Cause | Part de mémoire perdue (mesure vLLM) |
|---|---|---|
| Réservation | Espace alloué pour des tokens jamais générés | Jusqu'à 60 % |
| Fragmentation interne | Bloc contigu trop grand pour la séquence | Variable |
| Fragmentation externe | Trous entre blocs libérés, inutilisables | Variable |
Résultat : seulement 20 à 40 % de la mémoire de cache contenait réellement des K et des V.
PagedAttention : la mémoire virtuelle appliquée au cache
vLLM a repris l'idée des pages mémoire des systèmes d'exploitation. Le cache est découpé en blocs de taille fixe (16 tokens par défaut). Une séquence reçoit des blocs au fur et à mesure qu'elle en a besoin, et ces blocs n'ont pas besoin d'être contigus. Une table de blocs par séquence fait le lien entre positions logiques et blocs physiques.
| Avantage | Effet |
|---|---|
| Allocation à la demande | Plus de réservation pour du contexte non utilisé |
| Pas de contiguïté requise | Plus de fragmentation externe |
| Gaspillage borné au dernier bloc | Moins de 16 tokens perdus par séquence |
| Blocs partageables | Deux séquences avec le même préfixe pointent vers les mêmes blocs (prefix caching) |
La conséquence directe : deux à quatre fois plus de séquences tiennent en mémoire, donc un batch deux à quatre fois plus grand, donc un débit proportionnellement supérieur.
Quand la mémoire est pleine : la préemption
Même avec PagedAttention, il arrive que les séquences actives aient besoin de plus de blocs qu'il n'en reste. Le scheduler doit alors préempter une séquence, en général la plus récente, et libérer ses blocs.
| Stratégie | Ce qu'on fait du cache | Coût |
|---|---|---|
| Recompute | On le jette et on referra le prefill plus tard | Du calcul, pas de mémoire CPU |
| Swap | On le copie en RAM CPU et on le rapatrie plus tard | De la bande passante PCIe |
vLLM utilise le recompute par défaut. Si vous voyez des préemptions fréquentes dans les logs, c'est le signal que max_num_seqs est trop élevé pour votre GPU.
Symptôme typique : le débit est excellent pendant quelques minutes, puis s'effondre. Le serveur passe son temps à préempter et recalculer. Baissez max_num_seqs ou max_model_len, ou passez le cache en FP8.
Prefill et decode dans le même batch
Le conflit
Le prefill d'un long prompt est un gros calcul parallèle. Le decode est une série de petits pas limités par la mémoire. Si on les mélange naïvement dans un même pas, le prefill de 8 000 tokens bloque le batch pendant tout ce temps, et les utilisateurs en cours de decode voient leurs tokens se figer.
Les deux réponses
Prioriser le prefill (comportement historique de vLLM) : quand une requête arrive, on fait son prefill avant de reprendre le decode. Bon TTFT, mais le TPOT des autres souffre à chaque nouvelle arrivée.
Chunked prefill : on découpe le prefill en morceaux (par exemple 512 tokens) et on glisse un morceau dans chaque pas de decode, à côté des tokens des autres séquences. Le pas devient un peu plus long, mais personne ne se fige.
| Réglage | TTFT | TPOT | Quand l'utiliser |
|---|---|---|---|
| Prefill prioritaire | Meilleur | Irrégulier | Requêtes courtes, peu de concurrence |
| Chunked prefill | Un peu plus long | Stable | Prompts longs, beaucoup d'utilisateurs, streaming |
Le chunked prefill est activé par défaut dans les versions récentes de vLLM. Le paramètre max_num_batched_tokens fixe le budget total de tokens (prefill + decode) traités par pas.
Pour un chatbot en streaming, un TPOT stable compte plus qu'un TTFT minimal. Un utilisateur tolère une demi-seconde avant le premier mot, beaucoup moins des mots qui s'affichent par à-coups.
Micro-exercice 2 : mesurer avec vLLM
Cet exercice demande un GPU NVIDIA. Un T4 gratuit sur Google Colab suffit pour un modèle 1,5B.
Étape 1 : la référence, Transformers en séquentiel
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
modele = "Qwen/Qwen2.5-1.5B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(modele)
model = AutoModelForCausalLM.from_pretrained(modele, torch_dtype=torch.float16).cuda().eval()
sujets = ["le KV cache", "la photosynthèse", "le protocole TCP", "la Révolution française",
"les nombres premiers", "le fonctionnement d'un GPU", "la fermentation", "l'attention"]
prompts = [f"Explique en trois phrases ce qu'est {s}." for s in sujets] * 4 # 32 requêtes
debut = time.perf_counter()
nb_tokens = 0
for p in prompts:
entrees = tokenizer.apply_chat_template([{"role": "user", "content": p}],
add_generation_prompt=True, return_tensors="pt").cuda()
with torch.no_grad():
sortie = model.generate(entrees, max_new_tokens=128, do_sample=False)
nb_tokens += sortie.shape[1] - entrees.shape[1]
duree = time.perf_counter() - debut
print(f"Transformers séquentiel : {nb_tokens / duree:.0f} tokens/s")
Étape 2 : la même chose avec vLLM
import time
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen2.5-1.5B-Instruct",
max_num_seqs=64, # séquences max dans le batch
gpu_memory_utilization=0.9, # part du GPU réservée (poids + cache)
enable_prefix_caching=True,
)
params = SamplingParams(max_tokens=128, temperature=0)
messages = [[{"role": "user", "content": p}] for p in prompts]
debut = time.perf_counter()
sorties = llm.chat(messages, params)
duree = time.perf_counter() - debut
nb_tokens = sum(len(s.outputs[0].token_ids) for s in sorties)
print(f"vLLM continuous batching : {nb_tokens / duree:.0f} tokens/s")
Résultat attendu : sur un T4, Transformers en séquentiel produit quelques dizaines de tokens par seconde, vLLM plusieurs centaines. Le facteur dépend du GPU et de la longueur des réponses, mais il est rarement inférieur à 8.
Étape 3 : en mode serveur, avec de vraies requêtes concurrentes
Le serveur expose une API compatible OpenAI. On lui envoie 32 requêtes en parallèle depuis Python.
import time
from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="inutile")
def requete(prompt: str) -> int:
reponse = client.chat.completions.create(
model="Qwen/Qwen2.5-1.5B-Instruct",
messages=[{"role": "user", "content": prompt}],
max_tokens=128,
)
return reponse.usage.completion_tokens
debut = time.perf_counter()
with ThreadPoolExecutor(max_workers=32) as pool:
tokens = sum(pool.map(requete, prompts))
duree = time.perf_counter() - debut
print(f"32 clients simultanés : {tokens / duree:.0f} tokens/s, {duree:.1f} s au total")
Relancez avec max_workers=1 pour voir le débit d'un client seul. C'est cette différence que paie le continuous batching.
Régler un serveur en production
Les paramètres qui comptent
| Paramètre vLLM | Rôle | Si trop bas | Si trop haut |
|---|---|---|---|
max_num_seqs | Séquences max dans un pas | Débit plafonné | Préemptions, TPOT dégradé |
max_num_batched_tokens | Tokens (prefill + decode) par pas | Prefill lent, TTFT long | Pas de decode trop longs |
max_model_len | Contexte max accepté | Requêtes refusées | Moins de séquences en mémoire |
gpu_memory_utilization | Part du GPU pour vLLM | Cache trop petit | OOM si autre chose tourne sur le GPU |
kv_cache_dtype | Précision du cache (auto, fp8) | fp8 double la capacité pour une perte de qualité minime | |
enable_prefix_caching | Partage des blocs de préfixe | Prefill répété inutilement | Aucun inconvénient notable |
La méthode
- Fixez
max_model_lenau vrai besoin. Accepter 128k tokens par défaut réserve de la mémoire pour rien. - Montez
max_num_seqsjusqu'à voir des préemptions dans les logs, puis redescendez d'un cran. - Activez
kv_cache_dtype="fp8"etenable_prefix_caching=True, mesurez, gardez si la qualité tient. - Surveillez trois métriques : débit total, TTFT au 95e percentile, TPOT au 95e percentile. Les moyennes cachent les utilisateurs qui attendent.
Optimiser le débit seul mène à un serveur qui traite beaucoup de tokens mais où chaque utilisateur attend. Un service interactif se règle sur le TPOT p95, pas sur les tokens par seconde.
Ailleurs que dans vLLM
Le continuous batching est devenu le standard. Vous le retrouvez sous d'autres noms et d'autres réglages.
| Outil | Continuous batching | Paramètre de concurrence |
|---|---|---|
| vLLM | Oui, avec PagedAttention | max_num_seqs |
| SGLang | Oui, avec RadixAttention (prefix caching en arbre) | max-running-requests |
| TensorRT-LLM | Oui, appelé in-flight batching | max_batch_size |
| Text Generation Inference | Oui | max-concurrent-requests |
| llama.cpp server | Oui | --parallel |
| Ollama | Oui | OLLAMA_NUM_PARALLEL (4 par défaut) |
Ollama en local sert donc plusieurs requêtes en parallèle sans rien configurer. Si vous lancez quatre scripts qui l'interrogent en même temps, ils partagent un batch, ils ne font pas la queue.
Les pièges à éviter
1. Comparer des débits sans comparer les latences
Un serveur à 2 000 tokens/s avec un TPOT de 200 ms est inutilisable en chat. Rapportez toujours débit et latence p95.
2. Tester avec des prompts identiques
Le prefix caching rend tout artificiellement rapide. Pour un benchmark honnête, variez les prompts ou désactivez enable_prefix_caching.
3. Oublier la sortie longue
Un batch de 64 séquences qui répondent 2 000 tokens chacune consomme 64 fois plus de cache à la fin qu'au début. Dimensionnez sur la fin de génération, pas sur le prompt.
4. Confondre concurrence et parallélisme de modèle
Le continuous batching sert plusieurs requêtes sur un GPU. Le tensor parallelism découpe un modèle sur plusieurs GPU. Les deux se combinent, mais ne répondent pas au même problème : le premier au débit, le second à la taille du modèle.
5. Laisser gpu_memory_utilization à 0.9 sur un GPU partagé
Si un autre processus utilise le GPU, vLLM ne le sait pas et plante au démarrage ou pendant la charge. Descendez la valeur ou isolez le GPU.
À retenir
| Point | En une phrase |
|---|---|
| Le problème | En decode, le GPU lit les poids pour un seul token et attend |
| L'idée | Faire avancer beaucoup de séquences par lecture des poids |
| Static batching | Batch fixe, gaspillé par les séquences courtes et le padding |
| Continuous batching | Décision à chaque pas : les finies sortent, les nouvelles entrent |
| Le plafond | La mémoire du KV cache, pas le calcul |
| PagedAttention | Cache par blocs, plus de réservation ni de fragmentation |
| Chunked prefill | Les longs prompts ne figent plus le decode des autres |
| Le réglage | max_num_seqs et max_model_len d'abord, surveiller TPOT p95 |
Pour aller plus loin
- Exercice bonus : ajoutez au simulateur du micro-exercice 1 une limite de mémoire (par exemple 20 000 tokens de cache au total) et une préemption de la séquence la plus récente quand la limite est atteinte. Observez à partir de quel
Ble débit se met à baisser. - Speculative decoding : générer plusieurs tokens par pas avec un petit modèle brouillon vérifié par le grand, pour attaquer le coût du decode par un autre angle.
- Prefill/decode désagrégés : faire tourner les deux phases sur des GPU différents, la direction prise par les grands déploiements depuis 2024.
- Cours lié : le KV cache, pour la partie mémoire, et faire tourner un LLM en local avec Ollama, pour tester la concurrence chez vous.
À propos de l'auteur
Alex Perey
Diplômé d'un master en intelligence artificielle, je travaille au quotidien sur des projets IA en entreprise. J'ai créé IwanttolearnAI pour rendre l'apprentissage de l'IA accessible à tous, gratuitement.
Continuer à apprendre
Le KV Cache : Pourquoi les LLMs Génèrent Vite (et Mangent la Mémoire)
Comprenez le KV cache, la structure qui évite de recalculer toute l'attention à chaque token généré, et apprenez à estimer combien de mémoire GPU il consomme.
12 min de lecture · 0 vues
Faire Tourner un LLM en Local avec Ollama
Installez et utilisez des LLMs directement sur votre machine avec Ollama : confidentialité totale, coût zéro, et intégration Python en quelques lignes.
15 min de lecture · 38 vues
Tokenisation : Comment l'IA Lit le Texte
Découvrez comment les LLMs décomposent le texte en tokens, pourquoi le français coûte plus cher que l'anglais, et comment maîtriser vos coûts d'API avec tiktoken.
16 min de lecture · 91 vues