LLM Engineer's Handbook
Chapitre 8 / 11 · 16 min de lecture

L'optimisation de l'inférence

Servir un LLM vite et à moindre coût : quantification, cache KV, décodage spéculatif et parallélisme — les leviers latence/débit/mémoire.

Déployer un LLM est coûteux. Ces modèles réclament des accélérateurs spécialisés — GPU ou TPU — capables de paralléliser massivement les calculs, et même ainsi, une mise en service naïve gaspille le matériel : utilisation médiocre, débit décevant, latence trop haute. Or les besoins varient. Générer des documents en lot, la nuit, tolère la lenteur. Compléter du code en direct exige une réponse quasi instantanée. Optimiser l'inférence — la façon dont le modèle produit ses prédictions — devient donc une compétence centrale de l'ingénieur LLM.

Trois objectifs guident cette optimisation, et ils sont souvent en tension. Réduire le temps avant le premier token, c'est la latence (latency). Augmenter le nombre de tokens produits par seconde, c'est le débit (throughput). Diminuer la place occupée en mémoire vidéo, c'est réduire l'empreinte mémoire/coût (VRAM footprint). Iusztin et Labonne montrent qu'avec des techniques bien choisies — décodage spéculatif, parallélisme, quantification, mécanismes d'attention optimisés — on atteint des accélérations de 2 à 4x, voire davantage, sans sacrifier la qualité. Ce chapitre parcourt ces leviers et compare trois moteurs d'inférence de référence : Text Generation Inference (TGI), vLLM et TensorRT-LLM.

Le goulot d'étranglement : la génération séquentielle

Les LLM modernes (GPT, Llama) reposent sur une architecture Transformer à décodeur seul (decoder-only Transformer), conçue pour la génération de texte : prédire le mot suivant à partir des précédents. Le livre se concentre exclusivement sur cette architecture, qui domine le domaine — par contraste avec les modèles à encodeur seul (encoder-only, comme BERT, pour la classification) et encodeur-décodeur (encoder-decoder, comme T5, pour la traduction).

L'inférence d'un décodeur se déroule en trois temps. D'abord la tokenisation du prompt, son passage dans la couche d'embedding et l'encodage positionnel. Ensuite le calcul des paires clé-valeur (key-value pairs) de chaque token d'entrée via l'attention multi-tête. Enfin la génération des tokens de sortie, un par un.

Les deux premières étapes sont des multiplications matricielles hautement parallélisables : le GPU les avale efficacement. Le vrai problème est la troisième. Générer le token suivant exige d'avoir produit tous les précédents : le processus est intrinsèquement séquentiel. La séquence grandit token par token et n'exploite pas la puissance parallèle du matériel. Lever ce goulot est l'enjeu central de toute la suite.

Etape 1-2 (prefill)      : "I have a dream"  -> parallelisable, GPU saturee
Etape 3   (decode)       : "of" -> " a" -> " ..." -> token par token, sequentiel
                            ^ goulot : chaque token attend le precedent

Le cache KV : ne pas recalculer le passé

Pour prédire le 100e token, le modèle a besoin du contexte des tokens 1 à 99. Pour le 101e, il lui faut de nouveau les tokens 1 à 99, plus le 100e. Recalculer à chaque pas tout le contexte déjà traité est terriblement redondant.

Le cache clé-valeur (KV cache) supprime cette redondance. Les paires clé-valeur produites par les couches d'auto-attention sont stockées ; au lieu de les recalculer, le modèle les relit depuis le cache. À chaque nouveau token, seules la clé et la valeur de ce token unique sont calculées, puis ajoutées au cache. C'est une optimisation immédiate, présente dans tous les outils populaires — certaines implémentations gardent même un cache distinct par couche.

Le gain en vitesse a une contrepartie en mémoire. La taille du cache croît avec le nombre de tokens, et dépend du nombre de couches, du nombre de têtes d'attention, de leur dimension et de la précision des paramètres. Pour un modèle typique de 7 milliards de paramètres en précision 16 bits, le cache dépasse 2 Go au-delà de 2 048 tokens. Les modèles plus gros explosent davantage.

Note

Le cache KV grandit à chaque pas et reste dynamique, ce qui empêche d'utiliser torch.compile — l'outil qui fusionne le code PyTorch en noyaux optimisés. Le cache KV statique (static KV cache) résout cela en pré-allouant le cache à une taille maximale ; on peut alors le combiner avec torch.compile pour une passe avant jusqu'à 4x plus rapide.

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "google/gemma-2b-it"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")

# 1) Passer le cache KV en statique (taille pre-allouee)
model.generation_config.cache_implementation = "static"

# 2) Compiler le modele en noyaux fusionnes et optimises
compiled_model = torch.compile(
    model, mode="reduce-overhead", fullgraph=True
)

device = "cuda" if torch.cuda.is_available() else "cpu"
inputs = tokenizer("What is 2+2?", return_tensors="pt").to(device)

outputs = model.generate(
    **inputs, do_sample=True, temperature=0.7, max_new_tokens=64
)
print(tokenizer.batch_decode(outputs, skip_special_tokens=True))
# ['What is 2+2?\n\nThe answer is 4. 2+2 = 4.']

Attention : le cache statique ne fonctionne pas avec toutes les architectures — la documentation de transformers liste les modèles compatibles. Et comme le cache peut vite épuiser la VRAM et brider la taille des lots, sa bonne gestion a motivé les mécanismes d'attention économes en mémoire abordés plus loin.

Le batching continu : ne jamais laisser le GPU oisif

Traiter plusieurs requêtes d'inférence en même temps — le batching (groupement par lots) — est l'approche standard pour viser un haut débit. De plus grands lots amortissent le coût mémoire des poids du modèle et transfèrent plus de données d'un coup au GPU, saturant mieux sa capacité parallèle.

Mais les décodeurs posent un problème particulier : la longueur des prompts et des réponses varie énormément. Une requête peut tenir en un mot, une autre attendre plusieurs paragraphes. Avec un batching traditionnel, il faut attendre que la requête la plus longue du lot se termine avant d'en lancer un nouveau : l'accélérateur reste partiellement oisif, en attente d'un traînard.

Le batching continu (continuous batching), aussi appelé in-flight batching, supprime ce temps mort. Dès qu'une requête achève sa génération, elle est expulsée du lot et une nouvelle prend immédiatement sa place. Le lot reste toujours plein, l'utilisation du matériel est maximale. Une subtilité demeure : il faut périodiquement interrompre la génération pour faire le prefill (embedding et encodage des requêtes en attente). Trouver le bon équilibre génération/prefill exige d'ajuster un hyperparamètre de ratio attente/service. Cette technique est native dans TGI, vLLM et TensorRT-LLM.

Batching traditionnel          Batching continu
+--------------------+         +--------------------+
| R1 ######          |         | R1 ###### R5 ##### |  <- R5 prend la place
| R2 ############### |         | R2 ############### |     de R1 des sa fin
| R3 ####            |         | R3 #### R6 #### R7  |
| R4 #########       |         | R4 ######### R8 ## |
+--------------------+         +--------------------+
 GPU oisif en fin de lot        GPU toujours sature

Le décodage spéculatif : un brouillon validé d'un coup

Même avec le batching continu, la génération token par token ne sature pas la capacité parallèle de l'accélérateur. Le décodage spéculatif (speculative decoding), aussi appelé génération assistée, exploite cette capacité dormante pour prédire plusieurs tokens à la fois.

L'idée : un petit modèle « brouillon » (draft model) — version distillée ou élaguée du grand — propose en parallèle plusieurs complétions, disons 5 à 10 tokens en une étape. Ces propositions sont ensuite soumises au grand modèle en une seule passe, qui valide lesquelles correspondent à ce qu'il aurait lui-même généré. On conserve le plus long préfixe correct et on jette le reste. Si le petit modèle approxime bien le grand, plusieurs tokens sont validés d'un coup, sans perte de qualité. Le degré d'accélération dépend de la qualité des prédictions : un taux de correspondance de 90 % peut donner un gain de 3 à 4x.

À retenir

Les deux modèles doivent partager le même tokenizer. Sinon, les tokens du modèle brouillon ne s'alignent pas sur ceux du grand modèle, et la validation devient impossible. Pour un gain maximal, le modèle assistant doit être bien plus petit que le modèle principal.

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "Qwen/Qwen1.5-1.8B-Chat"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")

# Modele brouillon, beaucoup plus petit, meme tokenizer
draft_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen1.5-0.5B-Chat", device_map="auto"
)

device = "cuda" if torch.cuda.is_available() else "cpu"
inputs = tokenizer("What is 2+2?", return_tensors="pt").to(device)

# assistant_model active le decodage speculatif
outputs = model.generate(
    **inputs, do_sample=True, assistant_model=draft_model,
    temperature=0.7, max_new_tokens=64,
)
print(tokenizer.batch_decode(outputs, skip_special_tokens=True))
# ['What is 2+2? 2 + 2 equals 4!']

Deux variantes méritent d'être citées. Le décodage par recherche dans le prompt (prompt lookup decoding) cible les tâches ancrées dans l'entrée (résumé), où prompt et sortie se recoupent souvent : on réutilise les n-grammes partagés comme candidats, via prompt_lookup_num_tokens. Plus ambitieuse, Medusa insère des têtes de spéculation dédiées dans le modèle principal et les entraîne (Medusa-1 fige le grand modèle, Medusa-2 affine les deux conjointement) ; un modèle de 70M peut alors approcher les performances d'un 7B sur une gamme de tâches.

Astuce

En combinant le cache KV statique avec torch.compile, le batching continu et le décodage spéculatif, on obtient des accélérations de 2 à 4x ou plus sans aucune perte de qualité. Ce sont les premiers leviers à activer avant même de toucher à la précision des poids.

Les mécanismes d'attention optimisés

L'attention du Transformer croît de façon quadratique avec la longueur de séquence — d'où l'explosion du cache KV sur les longs contextes. Deux innovations majeures s'attaquent à ce mur.

PagedAttention (Kwon, Li et al., 2023) s'inspire de la mémoire virtuelle et de la pagination des systèmes d'exploitation. Le cache KV est partitionné en blocs de taille fixe, supprimant le besoin d'allocation mémoire contiguë ; le noyau récupère ces blocs où qu'ils soient physiquement. Résultat : une utilisation mémoire quasi optimale qui permet de regrouper plus de séquences, donc plus de débit. Le découpage en blocs autorise aussi le partage mémoire entre sorties issues d'un même prompt (échantillonnage parallèle, beam search), réduisant la surcharge mémoire jusqu'à 55 % et améliorant le débit jusqu'à 2,2x selon les auteurs. C'est le moteur vLLM qui l'a implémenté en premier, suivi de TGI et TensorRT-LLM.

FlashAttention-2 (Tri Dao, 2023) attaque le même problème côté calcul. En découpant les matrices d'entrée et de sortie en petits blocs tenant dans la SRAM rapide de la puce (plutôt que la mémoire à haute bande passante, plus lente), elle réduit drastiquement les transferts de données. Couplée à un softmax en ligne (online softmax) qui calcule le softmax bloc par bloc — en maintenant un maximum et une somme glissants — elle évite de stocker d'énormes matrices intermédiaires. À l'entraînement, recalculer ces valeurs lors de la passe arrière fait passer la mémoire de quadratique à linéaire en fonction de la longueur de séquence. Contrairement à PagedAttention, FlashAttention-2 s'utilise directement dans transformers :

from transformers import AutoModelForCausalLM

# Necessite : pip install flash-attn --no-build-isolation
model = AutoModelForCausalLM.from_pretrained(
    "mistralai/Mistral-7B-Instruct-v0.3",
    attn_implementation="flash_attention_2",
)

Le parallélisme de modèle : répartir sur plusieurs GPU

Quand un modèle ne tient plus sur un seul accélérateur, ou pour gagner en débit, on distribue mémoire et calcul sur plusieurs GPU : c'est le parallélisme de modèle (model parallelism). Conçues à l'origine pour l'entraînement, ces approches se réutilisent à l'inférence en se concentrant sur la passe avant. Trois familles, orthogonales et combinables.

Le parallélisme de données (data parallelism, DP) est le plus simple : on réplique le modèle entier sur chaque GPU, et chacun traite un sous-ensemble des données. À l'inférence, il aide à servir des requêtes concurrentes — plusieurs requêtes en parallèle réduisent la latence et augmentent le débit. Son défaut : répliquer tous les poids n'a de sens que si le modèle tient déjà sur un seul GPU, ce qui laisse peu de place aux données et bride la taille des lots. Il sert donc surtout à l'entraînement.

Le parallélisme de pipeline (pipeline parallelism, PP), introduit par GPipe (Huang et al., 2019), partitionne les couches : les 25 premiers pour cent au GPU 1, les suivants au GPU 2, etc. Les activations passent d'un GPU au suivant. Son atout majeur est la réduction mémoire par GPU, mais il souffre des bulles de pipeline (pipeline bubbles) — des GPU oisifs attendant les activations amont. Le micro-batching atténue le problème en découpant le lot en sous-lots : un GPU peut commencer le sous-lot suivant avant la fin du précédent.

Le parallélisme de tenseur (tensor parallelism, TP), introduit par Megatron-LM (Shoeybi et al., 2019), découpe les matrices de poids au sein d'une couche. Chaque GPU détient une tranche et calcule sa part ; les résultats partiels sont agrégés par une opération all-reduce. Particulièrement efficace sur l'auto-attention, où les têtes sont naturellement parallèles, il est plus rapide que le pipeline car il n'attend pas la fin des couches précédentes. Limites : certaines couches (LayerNorm, Dropout) ne se découpent pas proprement et sont répliquées — on peut alors les fractionner sur la dimension de séquence (parallélisme de séquence, sequence parallelism). Surtout, TP exige des interconnexions très rapides entre GPU, ce qui le rend impraticable entre nœuds mal reliés.

TechniqueCe qui est répartiForce principaleFaiblesseUsage
Parallélisme de données (DP)Réplicas complets du modèleServir des requêtes concurrentesModèle entier dupliqué par GPUSurtout entraînement
Parallélisme de pipeline (PP)Couches du modèleRéduction mémoire maximaleBulles de pipeline (GPU oisifs)Si la contrainte est la mémoire
Parallélisme de tenseur (TP)Matrices de poids dans une coucheFaible latence, calcul simultanéExige des interconnexions rapidesSi la latence prime

Astuce

Ces techniques se combinent pour compenser leurs défauts respectifs. Si la priorité est de faire tenir le modèle en mémoire, privilégiez le pipeline. Si c'est la latence, privilégiez le tenseur et acceptez une empreinte mémoire plus grande. En pratique, on découpe souvent le modèle en quelques étages de pipeline, avec du parallélisme de tenseur à l'intérieur de chaque étage.

La quantification : abaisser la précision des poids

La quantification (quantization) représente poids et activations dans un type de données de précision plus faible. Par défaut, les poids sont stockés en flottant 16 ou 32 bits (FP16, FP32) : grande précision, mais empreinte mémoire et coût de calcul élevés. Réduire à 8 bits, 4 bits ou moins diminue la mémoire et accélère l'inférence, au prix d'un compromis sur la qualité. Détail surprenant : un grand modèle (plus de 30 milliards de paramètres) quantifié en 2 ou 3 bits peut surpasser un petit modèle (7B–13B) à empreinte mémoire comparable.

Deux grandes approches. La quantification post-entraînement (post-training quantization, PTQ) convertit directement les poids d'un modèle pré-entraîné, sans réentraînement : simple, mais avec une dégradation possible. La quantification consciente de l'entraînement (quantization-aware training, QAT) quantifie pendant l'entraînement, laissant le modèle s'adapter aux poids basse précision : meilleure qualité, mais coûteuse en calcul et en données.

Le choix du type de données compte. FP32 (32 bits) est précis mais lourd. FP16 (demi-précision) et BF16 (16 bits) allègent l'empreinte. BF16 (brain floating-point) est souvent préféré quand le matériel le supporte, car les réseaux profitent davantage d'une large plage que d'une grande précision — mais il exige une architecture récente (NVIDIA Ampere et au-delà ; Turing ne le gère pas). Plus bas, INT8 (entiers 8 bits) réduit encore l'empreinte via des techniques comme la quantification absmax (par maximum absolu) ou zero-point (avec décalage).

import torch

def absmax_quantize(X):
    # Echelle : on ramene le max absolu sur 127
    scale = 127 / torch.max(torch.abs(X))
    X_quant = (scale * X).round()
    return X_quant.to(torch.int8)

Ces méthodes naïves butent sur les valeurs aberrantes (outlier features) : environ 0,1 % des poids ont des valeurs extrêmes qu'on ne peut écarter sans dégrader le modèle, mais qui ruinent la précision des autres. LLM.int8() (Dettmers et al., 2022) répond par une précision mixte : les aberrantes en FP16, le reste en INT8. L'empreinte est divisée par près de 2 avec une dégradation négligeable (moins de 1 %), au prix d'environ 20 % d'inférence en plus pour les gros modèles. NF4 (Dettmers et al., 2023) pousse à 4 bits, le format de QLoRA. Les deux s'utilisent directement via bitsandbytes :

from transformers import AutoModelForCausalLM

model_name = "meta-llama/Meta-Llama-3-8B-Instruct"

# LLM.int8() : 8 bits, valeurs aberrantes preservees en FP16
model_8bit = AutoModelForCausalLM.from_pretrained(
    model_name, device_map="auto", load_in_8bit=True
)

# NF4 : 4 bits (necessite bitsandbytes)
model_4bit = AutoModelForCausalLM.from_pretrained(
    model_name, device_map="auto", load_in_4bit=True
)

Les formats GGUF, GPTQ, EXL2 et AWQ

Au-delà du chargement à la volée, plusieurs formats de quantification dédiés existent, avec des compromis bien distincts.

GGUF (avec llama.cpp, de Georgi Gerganov) est le plus populaire, avec d'innombrables modèles sur le Hugging Face Hub. Son atout : il tourne sur un large éventail de matériel, y compris CPU et Android, et sait décharger des couches vers le GPU pour accélérer. Il propose des précisions de 1 à 8 bits, selon une convention de nommage : Q2_K (2 bits, qualité faible ; en 2 bits, c'est plutôt IQ2 qui reste utilisable pour les grands modèles), Q4_K_M (4 bits, bon compromis pour la plupart des modèles), Q5_K_M (5 bits, haute qualité), jusqu'à Q8_0 (8 bits, qualité maximale). Quantifier consiste à grouper les valeurs en blocs et super-blocs, puis à les arrondir à la précision visée.

GPTQ et EXL2 sont dédiés au GPU, donc plus rapides que llama.cpp à l'inférence — EXL2 offrant le plus haut débit via sa bibliothèque ExLlamaV2. Tous deux reposent sur l'algorithme GPTQ (Frantar et al., 2023), qui raffine l'approche Optimal Brain Quantization. GPTQ se limite au 4 bits ; EXL2 est bien plus flexible, autorisant des débits binaires précis entre 2 et 8 bits (2,3, 3,5, 6,0…) et des niveaux différents par couche — ce qui permet de faire tourner un modèle de 70B sur un seul GPU de 24 Go en 2,55 bits.

AWQ (activation-aware weight quantization, Lin et al., 2023) identifie et protège les poids les plus importants, déterminés par la magnitude des activations (et non des poids), via une mise à l'échelle par canal sans rétropropagation. Proche de GPTQ/EXL2 en résultats (un peu plus lent), il est bien supporté par les moteurs d'inférence. Enfin, des techniques extrêmes comme QuIP# et HQQ ciblent le 1–2 bits en préservant mieux la qualité, surtout pour les très grands modèles.

FormatCible matériellePrécisionParticularitéSupport
GGUF (llama.cpp)CPU + déchargement GPU1 à 8 bitsTourne partout, le plus populairellama.cpp / LM Studio / Text Gen Web UI
GPTQGPU4 bitsAlgorithme GPTQ, rapideTGI, TensorRT-LLM
EXL2 (ExLlamaV2)GPU2 à 8 bits, mixablePlus haut débit, bitrate finTGI
AWQGPU4 bitsProtège les poids saillants (activations)TGI, vLLM, TensorRT-LLM

Attention

La quantification est le seul levier de ce chapitre à dégrader potentiellement la qualité des sorties. Le décodage spéculatif et l'attention optimisée préservent la qualité ; abaisser la précision des poids l'échange contre de la mémoire et de la vitesse. Mesurez toujours l'impact sur vos tâches réelles avant de choisir un format et un nombre de bits.

Choisir son moteur d'inférence

Le choix final consiste à mapper une architecture de modèle sur le matériel disponible, en arbitrant latence, débit et mémoire. Le livre compare trois moteurs majeurs. TGI (Text Generation Inference, Hugging Face) est le plus complet en fonctionnalités, dont le décodage spéculatif et l'EXL2, qu'il est seul à proposer ici. vLLM est né de PagedAttention et excelle au débit. TensorRT-LLM (NVIDIA) est le seul à offrir le parallélisme de pipeline.

TechniqueTGIvLLMTensorRT-LLM
Batching continu
Décodage spéculatif
FlashAttention-2
PagedAttention
Parallélisme de pipeline
Parallélisme de tenseur
GPTQ
EXL2
AWQ

À retenir

  • L'inférence d'un LLM se juge sur trois axes souvent en tension — latence, débit (throughput) et empreinte mémoire/coût — et le goulot vient de la génération séquentielle token par token.
  • Le cache KV évite de recalculer le passé (gros gain de vitesse, coût en mémoire) ; le cache statique débloque torch.compile et jusqu'à 4x sur la passe avant.
  • Le batching continu sature le GPU en remplaçant chaque requête finie ; le décodage spéculatif fait valider d'un coup un brouillon proposé par un petit modèle au même tokenizer — 2 à 4x sans perte de qualité.
  • PagedAttention (mémoire en blocs, cœur de vLLM) et FlashAttention-2 (calcul en SRAM, softmax en ligne) domptent le coût quadratique de l'attention sur les longs contextes.
  • Le parallélisme répartit les très gros modèles : pipeline pour économiser la mémoire, tenseur pour la latence (mais interconnexions rapides exigées), les deux étant combinables.
  • La quantification (8/4 bits ; GPTQ, AWQ, EXL2 sur GPU, GGUF jusqu'au CPU) réduit mémoire et coût en échangeant un peu de qualité — le seul levier ici à mesurer impérativement sur vos tâches.