La dernière fois, dans la première partie qu'on peut retrouver ici : "Comment héberger un LLM (partie 1)", on a laissé vLLM nous balancer une ligne de logs un peu mystérieuse: "Available KV cache memory: 20.77 GiB". Avant de comprendre ce chiffre, il faut d'abord comprendre ce qu'est le “KV Cache", et pour ça, on doit retourner à la source de tout : le mécanisme d'attention.
3. Attention parce que ça coûte.
Allez on entame la dernière partie avec un peu de mathématiques … courage ! Comme vous le savez sûrement, la révolution de l’IA vient avant tout de l’architecture “transformer” et de l’article “Attention Is All You Need”.
Dans un LLM, chaque phrase envoyée va être séparée en tokens, et ces tokens vont être transformés en vecteurs, c’est ce qu'on appelle l'embedding. L’ensemble de ces vecteurs va passer dans une opération nommée “attention” et ça permet aux vecteurs de parler à chacun et de modifier leurs valeurs en fonction de la relation avec les autres.
Le vecteur de chaque mot ne représente plus uniquement le mot mais aussi sa position, son sens dans le contexte donné. Le “context size” est la limite maximale de tokens que le modèle peut traiter en une fois.
C’est ce qui permet de faire une cohérence entre les différents tokens, le vecteur du prénom Jordan aura une valeur différente s’il y a des mots comme NBA, basket ou Chicago dans son contexte ou, au contraire, des mots comme musique ou danse.
Pour gérer cette attention il y a 3 grandes matrices : QKV (Query, Key, Value).
La query est une matrice qui permet de poser les questions sur le rôle du mot dans le contexte.
Pour avoir le résultat, il faut multiplier le vecteur par la matrice Q, le produit matriciel obtenu sera le "vecteur query":
e→ · Wq = q→
La key est le vecteur qui répond à la query, il peut être calculé grâce à la matrice K:
e→ · Wk = k→
Pour mesurer à quel point un token A est pertinent pour un token B, on calcule le produit scalaire (dot product) entre leur query et leur key : q→ · k→
Plus le score est grand, plus les deux tokens "matchent". On passe ensuite tous ces scores par Softmax pour obtenir une distribution de probabilités, C'est ce qu'on appelle les poids d'attention. On dit alors que le token A "attends to" token B proportionnellement à ce score.
C'est l'attention pattern :
Plus le score est élevé, plus le vecteur value du token va modifier le vecteur de l’autre token.
C’est ça le "Head Of Attention" : Attention(Q, K, V) = Softmax(Q · Kᵀ / √dk) · V
De nos jours les modèles ont plusieurs head of attention qui ont tous leurs matrices QKV mais Mistral utilise encore une autre façon de faire où les multi head of attention se partagent leur KV matrice (Grouped Query Attention).
Heureusement une fois que le token a posé ses questions aux tokens en mémoire via sa query, on n’a plus besoin de sa query et on peut la libérer du GPU. En revanche, tant qu'un token fait partie du contexte, tous les tokens futurs auront besoin de son vecteur Key (k→) et Value (v→). Le KV que vLLM indique par “Available KV cache memory: 20.77 GiB” et “GPU KV cache size: 209,456 tokens” est donc le besoin de 20.77 GiB de VRAM pour garder cette matrice remplie des vecteurs key et value des 209,456 tokens maximum.
Maintenant qu'on sait tout, ou du moins beaucoup, sur ce qui compose un LLM, on peut se poser la question suivante : comment aller plus loin ?
Comment les grandes entreprises du marché comme Anthropic, OpenAI, Mistral & co parviennent-elles à gérer des modèles qui font des centaines de Go, tout en répondant aussi rapidement à un nombre d'utilisateurs qui ne cesse d’augmenter ?
4. Des GPU jusqu'à l’infini.
4.1 Comment augmenter sa puissance de calcul ?
Pour pouvoir scaler il y a plusieurs méthodes, la plus évidente serait d’avoir un GPU plus gros.
Comme on a pu le voir, la VRAM doit accueillir non seulement les poids du modèle, mais aussi le KV Cache en fonction du contexte maximum souhaité et du nombre de requêtes simultanées à gérer.
Mais même les GPU ont leur limite, en fonction de leur nombre de cœurs, de Tensor Cores et de la taille de leur VRAM. Heureusement comme pour les CPU, un serveur peut contenir plusieurs cartes graphiques, et on peut diviser ledit modèle sur ces différents GPU disponible, c’est ce qu’on appelle le ‘Tensor parallelism’. Il y a plusieurs moyens de faire ça. On peut d'abord connecter les GPU directement entre eux grâce au NVLink. Chaque GPU possède un nombre limité de ports NVLink, ce qui limite aussi le nombre d'interconnexions directes possibles.
Le ‘next level’ est un NVSwitch qui permet de multiplier le nombre de GPU utilisables ensemble, au lieu de connecter les GPU entre eux comme avec le NVLink on connecte tous les GPU sur le NVSwitch. Je ne rentre pas volontairement dans le détail de comment ça fonctionne, les avantages et contraintes, ce n’est pas le sujet mais sachez que cela existe.
Une fois que le serveur détecte bien les 4 GPU branchés ensemble grâce au NVLink ou NVSwitch, nous pouvons passer le paramètre "--tensor-parallel-size 4" et vLLM se charge de charger le modèle sur les 4 GPU disponibles dans le serveur. Il va répartir les matrices des poids du modèle sur les différents GPU et comme la communication est intra serveur avec une technologie dédiée (NVLink) qui peut être de 600 GB/s jusqu'à 1800 GB/s, la latence est quasiment invisible pour l’utilisateur.
Mais cela aussi a ses limites, et si on souhaite scaler encore davantage, on va devoir connecter plusieurs serveurs entre eux (de plusieurs GPU soyons fous).
C’est là où Kubernetes apporte une solution adéquate, notamment grâce à sa fonction native de regrouper au sein d’une même entité logique plusieurs nodes.
Il y a plusieurs options, soit on considère que le modèle nous suffit et on souhaite juste augmenter le nombre de requêtes simultanées, dans ce cas, il nous suffit de monter la même instance du modèle sur un ou plusieurs autres GPU et de dispatcher les requêtes sur les différents endpoints disponibles. C'est ce qu'on appelle le “Data Parallelism”. Si en revanche on possède plusieurs serveurs avec chacun un ou plusieurs GPU et qu’on souhaite héberger un plus gros modèle, on va diviser le modèle par couche. C'est ce qu'on appelle le “Pipeline Parallelism”. Par exemple, si on dispose de plein de petits GPU de 32G, on peut héberger un modèle bien plus gros en divisant les couches sur nos différents GPU. Les couches 1 à 6 sont sur le GPU A, les couches 7 à 12 sont sur le GPU B …
Contrairement au Tensor Parallelism qui opère en intra-serveur via NVLink, le Pipeline Parallelism implique une communication inter-serveurs, et c'est là que la latence devient un vrai sujet...
Toute l'infrastructure a son rôle à jouer. Si les ports des switchs sont mal configurés, si les câbles ne sont pas optimisés, le transport des trames va ralentir tout le processus.
Les GPU peuvent envoyer de larges volumes de données entre eux, et si les interfaces réseau n’ont pas assez de bande passante (bandwidth), c’est toute la pipeline d'inférence qui va en souffrir.
Beaucoup d’équipements, surtout le network, ont vu des nouvelles techniques apparaître comme InfiniBand pour répondre à ces problématiques. Côté système également, il faut répondre à beaucoup de question :
Comment orchestrer plusieurs instances de modèle ? Comment router intelligemment les requêtes ? Comment gérer le scaling ? …
En plus des fonctionnalités natives de Kubernetes, pour gérer cette complexité il y a un outil qui se démarque : KServe.
4.2 KServe, manager les besoins LLM sur Kubernetes.
KServe est une solution open-source qui s'intègre nativement avec Kubernetes et qui a été conçue précisément pour orchestrer le serving de modèles à grande échelle. Là où vLLM s'occupe de faire tourner le modèle sur un ou plusieurs GPU d'un même serveur, KServe opère à un niveau au-dessus et combine Ray et vLLM pour gérer le tensor et pipeline parallélisme entre les nodes du cluster Kubernetes.
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: huggingface-llama3
annotations:
serving.kserve.io/deploymentMode: Standard
serving.kserve.io/autoscalerClass: none
spec:
predictor:
model:
modelFormat:
name: huggingface
storageUri: pvc://llama-3-8b-pvc/hf/8b_instruction_tuned
workerSpec:
pipelineParallelSize: 2
tensorParallelSize: 1
Un petit schéma pour l’illustrer :
Requête entrante
↓
Head Node (Ray) → reçoit la requête et coordonne
↓
Worker Node 1 → calcule sa partie du modèle
Worker Node 2 → calcule sa partie du modèle
↓
Head Node → assemble le résultat et répond
Incroyable non ? Et ce n’est pas tout.
KServe peut être aussi utilisé pour faire du pipeline d'inférence où plusieurs services s'enchaînent automatiquement… Prenons l'exemple d'un RAG (Retrieval Augmented Generation), il faut, avant même que le LLM reçoive la question de l'utilisateur, que le message soit modifié.
Par exemple:
apiVersion: serving.kserve.io/v1alpha1
kind: InferenceGraph
metadata:
name: rag-graph
spec:
nodes:
root:
routerType: Sequence
steps:
- nodeName: embedding
embedding:
routerType: Sequence
steps:
- serviceUrl: "http://ingestion.llm.svc.cluster.local/wrapper/embeddings"
dependency: Hard
- nodeName: retriever
data: $response
retriever:
routerType: Sequence
steps:
- serviceUrl: "http://ingestion.llm.svc.cluster.local/wrapper/query"
dependency: Hard
- nodeName: llm
data: $response
llm:
routerType: Sequence
steps:
- serviceUrl: "http://ministral.llm.svc.cluster.local/v1/chat/completions"
dependency: Soft
Les deux premières étapes s'appuient sur une RAG-API exécutant un script Python basé sur LangChain, qui va s’occuper de deux choses :
Premièrement, elle transforme le message de l'utilisateur en vecteur (embedding) pour pouvoir le comparer à la base de données vectorielle. Deuxièmement, elle interroge Qdrant, notre base de données vectorielles, pour chercher si des informations intéressantes existent dans la base. On va comparer le vecteur de la question avec tous les vecteurs stockés et retourner les documents les plus proches sémantiquement. Par exemple, si on demande quel cloud provider est utilisé par le client A, il va retrouver les cohérences avec le client A et rajouter les cloud providers qu’il peut trouver (Scaleway, AWS, Azure ...).
Message utilisateur
↓
1. Embedding model → transforme le message en vecteur
↓
2. Vector database → cherche les documents avec un lien
↓
3. Assemblage → combine le message + les documents trouvés
↓
4. LLM → génère la réponse finale avec les infos du RAG
Le RAG est un sujet qui mérite son propre article. De l'importance de l'indexation aux complexités du reranking, en passant par la pertinence de l'industrialisation. Aujourd’hui, nous avons développé un RAG 100 % auto-hébergé. Il s'appuie sur un pipeline qui indexe toute notre documentation en ajoutant à chaque document un préfixe et de métadonnées basés sur le “breadcrumb” (fil d'Ariane).
Après un aperçu de toutes les configurations et outils qu'on pourrait implémenter, comment pourrait-on optimiser notre déploiement ? Qu’est-ce que l'écosystème a créé pour améliorer notre expérience ?
5. Optimisation
5.1 Quantification
Vous vous rappelez des poids du modèle qui sont des nombres flottants (floats) ? Plus le modèle se veut précis, plus les floats ont beaucoup de chiffres après la virgule. Pourtant certaines personnes se sont rendu compte qu’on pouvait réduire drastiquement le poids d’un modèle en enlevant justement des chiffres après la virgule sans perdre beaucoup en précision, il s’agit de la quantification.
Cela ne se fait pas au hasard, l’idée est de minimiser la perte de précision en identifiant intelligemment quels poids peuvent être simplifiés. Vu que le réseau est composé d’un grand nombre de couches, si on s'aperçoit que baisser la précision des poids d’une couche a un impact très faible sur les résultats du modèle on va cibler ces poids et biais en priorité et laisser les autres intacts.
C’est grâce à cette pratique que d'énormes modèles peuvent être hébergés dans des plus petites infrastructures. Si on reprend notre modèle d'exemple : Ministral-3-3B-Instruct-2512-BF16, c'est le suffixe BF16 qui indique sa précision, chaque poids est stocké sur 16 bits.
Et même si ce modèle ne pèse pas très lourd (environ 8 GB), sa version quantifiée au format GGUF (format standard de quantification aujourd'hui pour faire tourner des LLM allégés) est encore bien plus réduite : 4.1 GB, presque deux fois moins.
Il existe de nombreuses techniques de quantification qui varient en fonction de plusieurs éléments, le type de GPU (Nvidia, AMD ou encore Apple Silicon) par exemple, mais aussi de la méthode utilisée pour minimiser la perte de précision. Et tous les moteurs d'inférence ne sont pas capables de gérer l’ensemble.
Pour illustrer quel type de quantification est compatible avec quels moteurs d'inférence et quel GPU, voici un petit tableau récapitulatif :
Comme vous pouvez le voir, vLLM n’est pas capable de gérer le format GGUF aujourd’hui. Ce qui est handicapant car beaucoup des gros modèles sont accessibles dans des formats GGUF très performants. Et pourtant la plupart des gens utilisent quand même vLLM, pourquoi ?
5.2 PagedAttention
Vous vous souvenez quand on parlait des moteurs d'inférence, j’ai parlé d’Ollama et vLLM. Ollama prend bien en charge les modèles GGUF mais malgré cela vLLM lui est tout le temps préférable dans un environnement de production. Pourquoi ? Parce que le KV cache est à l'origine de beaucoup de problèmes et vLLM propose des solutions.
Imaginons, nous avons un modèle qui tourne en local sur votre pc grâce à Ollama ou llama.cpp. Vous pouvez tranquillement requêter votre LLM en local. Mais si vous commencez à paralléliser, vous allez sûrement remarquer une latence de plus en plus importante.
Pourquoi ? Parce que dans le cas où on met le contexte maximum à 16384, à chaque requête Ollama va réserver immédiatement la place nécessaire pour le KV Cache de 16384 tokens dans le GPU et libérer l’espace uniquement quand la demande aura été traitée et cela peu importe la taille de la demande et de la réponse nécessaire. On pourrait très bien n’avoir besoin que de 2% de cela, Ollama va quand même réserver toute la taille et donc s'il y a d'autres requêtes elles ne pourront pas être traitées en même temps, même si en théorie le GPU aurait les capacités de tout gérer en même temps. vLLM propose une solution : PagedAttention. Au lieu de réserver un grand bloc de mémoire continue, PagedAttention découpe le KV Cache en petits blocs de taille fixe qui peuvent être donnés de manière dynamique petit à petit.
Ça permet deux choses formidables, “continuous batching” plusieurs requêtes peuvent se partager la mémoire disponible sans se tirer dans les pattes. Et de ne pas gaspiller la mémoire, une requête de 200 tokens ne va pas bloquer la totalité de l’espace disponible du KV Cache. Cette façon de faire est complètement une copie de la “pagination mémoire” des OS.
Et cela permet aussi une autre innovation (qui est en expérimentation) : KV offloading.
5.3 KV Offloading
Le postulat de base est simple, la VRAM est rare et précieuse. Comment lui permettre de respirer sans perdre trop en performance ? L'idée qui a été trouvée : quand le GPU manque de VRAM, au lieu de rejeter les nouvelles demandes, on peut déplacer les blocs du KV les moins utilisés vers la mémoire qui est moins rapide mais plus grande généralement : la RAM.
Le problème, c’est que pour déplacer les données de la VRAM du GPU vers la RAM, elles doivent passer par deux chemins. D’abord du GPU vers le CPU via un PCIe qui est clairement le maillon faible (32/64GB/s), qui est clairement en dessous des 900 GB/s entre le GPU et sa VRAM. Et puis du CPU vers la RAM, ce qui est moins problématique car il est connecté via un bus mémoire.
Retenez juste que le KV Offloading nous permet d’avoir plus de réponses en même temps malgré une taille de VRAM limitée mais que cela peut engendrer des coûts en termes de latence parfois importants. Pour activer ce mécanisme sur vLLM, il faut rajouter des paramètres :
--kv-transfer-config '{
"kv_connector": "OffloadingConnector",
"kv_role": "kv_both",
"kv_connector_extra_config": {
"block_size": 64,
"cpu_bytes_to_use": 10000000000
}
Le “kv_connector” indique à vLLM d’activer la fonctionnalité, alors que le “kv_role” va lui indiquer comment il doit se comporter. “Both” veut dire “envoie des blocs sur la mémoire et récupère aussi des blocs depuis la mémoire”.
“Extra config”, lui, configure la taille des blocs et la mémoire qui doit être utilisée.
Puis dans les logs du pod vLLM quand il y a de l’activité :
Engine 000: Avg prompt throughput: 752.1 tokens/s, Avg generation throughput: 62.0 tokens/s, Running: 6 reqs, Waiting: 0 reqs, GPU KV cache usage: 89.3%, Prefix cache hit rate: 11.8%, External prefix cache hit rate: 0.0%
KV Transfer metrics: vllm:kv_offload_cpu_cache_usage_perc=0.02728226652675761, vllm:kv_offload_lookup_sync_delay_seconds_count=2, vllm:kv_offload_lookup_sync_delay_seconds_sum=1.935870386660099e-05, vllm:kv_offload_cpu_allocation_size_count=4, vllm:kv_offload_cpu_allocation_size_sum=118, vllm:kv_offload_store_bytes=964689920, vllm:kv_offload_store_time=0.04701926326751709, vllm:kv_offload_store_size_count=3, vllm:kv_offload_store_size_sum=964689920
Top, le batching fonctionne bien, le KV Offloading aussi… Maintenant qu’on a un modèle fonctionnel, utilisable et “légèrement” optimisé,
comment testons-nous ce qu’on est capable de gérer ou non ? Est-ce qu’on pourrait avec ce setup remplacer ChatGPT pour 5, 10, 100 employés ? Quel type d'employé, un développeur ou un ingénieur data qui utilisent en permanence les LLM ou juste un employé qui utilise les chatbots pour corriger l’orthographe de ses emails (comme moi pour ces articles) ?
6. Pour aller plus loin
6.1 Benchmark
Il y a plusieurs façons de tester un modèle, le plus connu sont les benchmarks qui vont tester les capacités du LLM. Est ce qu’il peut résoudre cette équation, générer correctement du code …
Mais cela ne nous intéresse pas vraiment ici, chaque modèle disponible sur Hugging Face propose des benchmarks. Et même si certains enjolivent les résultats, ils sont vite repérés par la communauté. Nous ce qu’on veut savoir, c’est combien de requêtes puis-je faire en même temps avant que le modèle soit saturé, combien de temps prend-il pour répondre quand il a X requêtes à gérer.
Il existe plein de datasets avec des questions déjà configurées pour tester notre LLM du type :
"from": "human",
"value": "explain this code: import cv2\nimport os\nfrom flask import Flask,request,render\\_template\nfrom datetime import date\nfrom datetime import datetime\nimport numpy as np\nfrom sklearn.neighbors import KNeighborsClassifier\nimport pandas as pd\nimport joblib\n\n#### Defining Flask App\napp = Flask(\\_\\_name\\_\\_)\n#### …”
Et vLLM nous fournit même une commande avec pas mal d'options. Qui, en plus, va interroger le endpoint metric du serveur pour nous faire un résumé, formidable non ?Par exemple, si on souhaite envoyer 20 requêtes avec un débit de deux requêtes par seconde, on lance :
vllm bench serve --backend openai \
--base-url http://ministral.ia.ippon-hosting.net \
--model mistralai \
--tokenizer mistralai/Ministral-3-3B-Instruct-2512-BF16 \
--dataset-name sharegpt \
--dataset-path ShareGPT_V3_unfiltered_cleaned_split.json \
--num-prompts 20 \
--request-rate 2
Et voilà le résultat :
============ Serving Benchmark Result ============
Successful requests: 20
Failed requests: 0
Request rate configured (RPS): 2.00
Benchmark duration (s): 37.58
Total input tokens: 3815
Total generated tokens: 4008
Request throughput (req/s): 0.53
Output token throughput (tok/s): 106.65
Peak output token throughput (tok/s): 211.00
Peak concurrent requests: 11.00
Total token throughput (tok/s): 208.17
---------------Time to First Token----------------
Mean TTFT (ms): 112.87
Median TTFT (ms): 114.43
P99 TTFT (ms): 138.48
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms): 45.08
Median TPOT (ms): 45.06
P99 TPOT (ms): 45.45
---------------Inter-token Latency----------------
Mean ITL (ms): 45.06
Median ITL (ms): 45.07
P99 ITL (ms): 47.17
Prenons le temps d’observer les résultats, première bonne nouvelle 0 échec.
11 requêtes en même temps au maximum, et 38 secondes pour répondre à tout le monde, pas mal !
Ces 3 catégories (TTFT, TPOT, ITL) permettent de se rendre compte encore plus précisément du comportement du serveur sous charge… Chacune est divisée en 3 métriques distinctes : Moyenne, Médiane et P99. Je pense que vous connaissez déjà la différence entre une moyenne et une médiane donc attardons-nous sur le P99. P99 signifie que 99% des requêtes ont obtenu un résultat en dessous de ce temps. En informatique on accepte que le 1% restant ait pu subir une erreur réseau, un pic de CPU ou autres. Ces cas isolés ne représentent pas le comportement réel du système. C’est pour ça que P99 est un standard dans le monde des SRE et de la production plus généralement.
Parlons maintenant des trois catégories.“Time to First Token” (TTFT) c’est la phrase de “prefill” : la requête arrive, le GPU ingère l'intégralité du contexte et traite tous ces tokens en même temps jusqu’au moment où le premier token de la réponse est généré et envoyé.
"Time per Output Token” (TPOT) correspond à la moyenne de temps qu’il faut au modèle pour générer et envoyer chaque token suivant en s’appuyant sur le KV cache des tokens précédents. C’est la phase de decode. “Inter-token Latency” (ITL) nous donne plus ou moins la même indication que TPOT vu qu’il s’agit d’une moyenne du temps de génération entre chaque token. Á la différence près que le P99 peut nous indiquer des irrégularités dans la génération que la moyenne cache.
Augmentons maintenant, j’exécute 100 requêtes avec un débit de 5 requêtes par seconde :
============ Serving Benchmark Result ============
Successful requests: 100
Failed requests: 0
Request rate configured (RPS): 5.00
Benchmark duration (s): 53.61
Total input tokens: 23401
Total generated tokens: 16677
Request throughput (req/s): 1.87
Aucune erreur et un temps d'exécution plus que correct, mais on voit quand même que le serveur traite et répond à une requête toutes les 1.8 secondes, alors qu’il lui en fallait 0.5 avant. Et quant aux catégories vues juste avant, l’impact est réel :
---------------Time to First Token----------------
Mean TTFT (ms): 808.24
Median TTFT (ms): 749.71
P99 TTFT (ms): 2121.00
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms): 97.83
Median TPOT (ms): 80.08
P99 TPOT (ms): 334.48
---------------Inter-token Latency----------------
Mean ITL (ms): 72.19
Median ITL (ms): 49.45
P99 ITL (ms): 756.09
On voit directement le changement : dans le pire cas, les utilisateurs ont dû attendre 0.7 seconde entre deux tokens, et jusqu'à 2 secondes une fois la question posée avant que le premier token n'apparaisse. Heureusement, cela reste plus qu'acceptable si la qualité des réponses est au rendez-vous.
6.2 Le futur ?
Après avoir vu tout cela, la question légitime serait : "Qu'est-ce qu'on peut mettre en place concrètement ?"
Aujourd'hui notre priorité serait de gérer le routing. Par là, j'entends avoir un outil qui route intelligemment les requêtes selon leur complexité. Notre modèle entièrement hébergé pour les questions simples, un modèle plus puissant sur internet pour les tâches complexes (avec llm-d ou litellm par exemple).
Ou encore router selon la nature de la requête : y a-t-il des données client ? Si oui, cela sera traité sur notre modèle self-hosted. Sinon, on peut se permettre d'utiliser un modèle externe comme Mistral ou Claude.
Tous ces sujets sujets abordés dans ces articles comme vLLM et le parallélisme, KServe ou encore la gestion des drivers dans Kubernetes… mériteraient également un article à part entière pour plonger dans leur complexité. Peut-être que ça arrivera, stay tuned !
