LLMAvancé18 min de lecture · 0 vues

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 H100CapacitéUtilisée pour 1 token, 1 utilisateur
Bande passante mémoire~3,3 To/sPresque 100 %
Calcul FP16~990 TFLOPsMoins 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éfautConséquence
Le lot se libère au rythme du plus longGPU sous-utilisé pendant la fin du lot
Les requêtes entrantes attendent le lot suivantLatence au premier token élevée et imprévisible
Padding des prompts à la même longueurMé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étriqueStatic batchingContinuous batching
Débit (tokens/s sur le GPU)Limité par le gaspillage de fin de lotProche du maximum
TTFT (temps avant le premier token)Attente du prochain lot, très variableEntrée dès qu'un slot se libère
TPOT (temps entre deux tokens)StableStable, 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 gaspillageCausePart de mémoire perdue (mesure vLLM)
RéservationEspace alloué pour des tokens jamais générésJusqu'à 60 %
Fragmentation interneBloc contigu trop grand pour la séquenceVariable
Fragmentation externeTrous entre blocs libérés, inutilisablesVariable

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.

AvantageEffet
Allocation à la demandePlus de réservation pour du contexte non utilisé
Pas de contiguïté requisePlus de fragmentation externe
Gaspillage borné au dernier blocMoins de 16 tokens perdus par séquence
Blocs partageablesDeux 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égieCe qu'on fait du cacheCoût
RecomputeOn le jette et on referra le prefill plus tardDu calcul, pas de mémoire CPU
SwapOn le copie en RAM CPU et on le rapatrie plus tardDe 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églageTTFTTPOTQuand l'utiliser
Prefill prioritaireMeilleurIrrégulierRequêtes courtes, peu de concurrence
Chunked prefillUn peu plus longStablePrompts 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.

pip install vllm

É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

vllm serve Qwen/Qwen2.5-1.5B-Instruct --max-num-seqs 64 --port 8000

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 vLLMRôleSi trop basSi trop haut
max_num_seqsSéquences max dans un pasDébit plafonnéPréemptions, TPOT dégradé
max_num_batched_tokensTokens (prefill + decode) par pasPrefill lent, TTFT longPas de decode trop longs
max_model_lenContexte max acceptéRequêtes refuséesMoins de séquences en mémoire
gpu_memory_utilizationPart du GPU pour vLLMCache trop petitOOM si autre chose tourne sur le GPU
kv_cache_dtypePrécision du cache (auto, fp8)fp8 double la capacité pour une perte de qualité minime
enable_prefix_cachingPartage des blocs de préfixePrefill répété inutilementAucun inconvénient notable

La méthode

  1. Fixez max_model_len au vrai besoin. Accepter 128k tokens par défaut réserve de la mémoire pour rien.
  2. Montez max_num_seqs jusqu'à voir des préemptions dans les logs, puis redescendez d'un cran.
  3. Activez kv_cache_dtype="fp8" et enable_prefix_caching=True, mesurez, gardez si la qualité tient.
  4. 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.

OutilContinuous batchingParamètre de concurrence
vLLMOui, avec PagedAttentionmax_num_seqs
SGLangOui, avec RadixAttention (prefix caching en arbre)max-running-requests
TensorRT-LLMOui, appelé in-flight batchingmax_batch_size
Text Generation InferenceOuimax-concurrent-requests
llama.cpp serverOui--parallel
OllamaOuiOLLAMA_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

PointEn une phrase
Le problèmeEn decode, le GPU lit les poids pour un seul token et attend
L'idéeFaire avancer beaucoup de séquences par lecture des poids
Static batchingBatch fixe, gaspillé par les séquences courtes et le padding
Continuous batchingDécision à chaque pas : les finies sortent, les nouvelles entrent
Le plafondLa mémoire du KV cache, pas le calcul
PagedAttentionCache par blocs, plus de réservation ni de fragmentation
Chunked prefillLes longs prompts ne figent plus le decode des autres
Le réglagemax_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 B le 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.

GitHub·En savoir plus

Continuer à apprendre

LLMDébutant

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

LLMIntermédiaire

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