Dans un transformer, chaque token traité est projeté en trois vecteurs : une requête (Q), une clé (K) et une valeur (V). L'attention compare la requête du token courant aux clés de tous les tokens précédents pour décider quel poids donner à leurs valeurs respectives — c'est ce mécanisme qui permet à un modèle de « regarder en arrière » dans son contexte. Les vecteurs K et V d'un token déjà traité ne changent plus une fois calculés : plutôt que de les recalculer à chaque nouveau token généré, on les garde en mémoire. C'est exactement ça, le cache KV : la mémoire des clés et valeurs déjà calculées, token par token, qui évite de rejouer tout le calcul d'attention à chaque tour.
L'article sur la mécanique de l'inférence LLM détaille pourquoi cette optimisation est indispensable — sans elle, générer une réponse de 500 tokens rejouerait le calcul d'attention complet 500 fois. Ce qu'il ne détaille pas, c'est comment un serveur d'inférence fait tenir des dizaines de ces caches, un par requête active, dans une VRAM qui ne s'agrandit pas avec le trafic. C'est un problème d'ingénierie mémoire à part entière, et c'est lui qui détermine combien de requêtes un GPU peut vraiment servir en même temps.
Un cache par requête, une mémoire commune
Chaque requête active a son propre cache KV, qui grossit d'un token à chaque tour de decode. Le problème : ce cache doit rester en VRAM du début à la fin de la génération, et la VRAM d'un GPU est partagée entre toutes les requêtes traitées en parallèle. Plus une génération est longue, plus son cache pèse ; plus il y a de requêtes concurrentes, plus la mémoire totale requise grimpe.
L'approche naïve consiste à réserver, pour chaque requête, un bloc de mémoire contigu dimensionné pour la longueur maximale possible — souvent la limite de contexte du modèle. Le souci : la plupart des générations sont bien plus courtes que ce maximum. Le reste du bloc réservé reste alloué, inutilisé, jusqu'à la fin de la requête. Sur un cluster qui sert du trafic réel, cette réservation pour le pire cas gaspille une part énorme de la VRAM disponible — et donc du nombre de requêtes qu'on peut traiter en parallèle.
PagedAttention : la pagination appliquée au cache KV
PagedAttention, popularisé par le serveur d'inférence vLLM, applique au cache KV une idée vieille comme les systèmes d'exploitation : au lieu d'allouer un unique bloc contigu par processus (ici, par requête), découper la mémoire en pages de taille fixe et maintenir une table qui fait correspondre chaque position logique dans la séquence à une page physique. Une requête n'occupe que le nombre de pages correspondant à sa longueur réelle à l'instant T, jamais plus. Dès qu'elle se termine, ses pages retournent immédiatement au pool commun, disponibles pour la requête suivante.
Le gain n'est pas cosmétique : en éliminant la réservation pour le pire cas, PagedAttention réduit drastiquement le gaspillage mémoire et permet de faire tenir beaucoup plus de requêtes concurrentes sur le même GPU, donc un débit nettement supérieur à charge matérielle égale. C'est devenu un composant standard des serveurs d'inférence modernes (vLLM, mais aussi repris ailleurs sous des noms proches), au même titre que le batching continu mentionné dans l'article sur la mécanique de l'inférence.
Partager un cache entre requêtes : le prefix caching
Deuxième levier, orthogonal au premier : quand plusieurs requêtes partagent un préfixe identique — un system prompt commun, les mêmes exemples few-shot, un long document de contexte réutilisé par plusieurs utilisateurs — il est inutile de recalculer et de stocker un cache KV distinct pour ce préfixe à chaque fois. Le serveur peut indexer les caches déjà calculés (souvent via une structure en arbre de préfixes, un peu comme un trie) et faire pointer plusieurs requêtes vers les mêmes pages physiques en lecture, tant que le préfixe correspond exactement.
C'est très exactement le mécanisme qui se cache derrière le prompt caching que proposent certains providers et qu'évoque déjà l'article sur l'inférence : côté serveur, un préfixe déjà vu ne repaie pas son prefill. Le prompt caching facturé par un provider n'est que la manifestation visible, côté API, de ce partage de cache KV entre requêtes. À ne pas confondre non plus avec le cache sémantique : celui-ci évite l'appel entier en reconnaissant des questions proches par similarité de sens, quitte à changer la réponse ; le prefix caching, lui, ne réutilise que des calculs internes strictement identiques, sur un préfixe de tokens exactement identique — la sortie du modèle n'est pas affectée.
Faire de la place : l'éviction
Même avec des pages et du partage de préfixe, la VRAM finit par saturer sous une charge suffisante. Le serveur doit alors choisir quoi sacrifier : évincer les pages les moins récemment utilisées (une politique LRU classique, appliquée aux préfixes plutôt qu'aux requêtes entières), ou préempter une requête de moindre priorité en renvoyant son cache vers la RAM CPU, voire en l'abandonnant purement et simplement pour la recalculer plus tard. Chaque éviction a un prix différé : la prochaine fois qu'on a besoin de ce préfixe, il faut repayer tout ou partie du prefill. C'est un arbitrage débit contre latence, pas une solution gratuite.
Réduire le poids par token : la quantification du cache
Un dernier levier agit sur la taille de chaque entrée plutôt que sur la façon dont elles sont rangées. La quantification qu'on applique d'ordinaire aux poids d'un modèle peut aussi s'appliquer au cache KV lui-même : stocker les vecteurs clé et valeur en int8 ou fp8 plutôt qu'en fp16 divise à peu près par deux leur empreinte mémoire par token, à précision numérique légèrement dégradée sur les scores d'attention. Concrètement, ça libère de la place pour davantage de séquences concurrentes ou pour des contextes plus longs, à VRAM constante — un levier indépendant de PagedAttention et du prefix caching, cumulable avec eux.
| Stratégie | Problème visé | Mécanisme | Gain typique |
|---|---|---|---|
| PagedAttention | Fragmentation mémoire | Pages de taille fixe + table d'indirection | Beaucoup plus de séquences concurrentes par GPU |
| Prefix caching | Recalcul redondant d'un préfixe partagé | Partage du cache déjà calculé entre requêtes | Prefill quasi gratuit sur un préfixe déjà vu |
| Éviction (LRU) | Saturation de la VRAM sous charge | Libération des pages les moins récemment utilisées | Absorbe les pics, au prix d'un re-prefill différé |
| Cache KV quantifié | Empreinte mémoire par token | Stockage K/V en int8 ou fp8 plutôt qu'en fp16 | Environ deux fois plus de contexte à VRAM égale |
FAQ
Le prefix caching, c'est la même chose que le prompt caching d'un provider ?
Oui dans le principe : le prompt caching facturé par un provider est la version exposée côté API du partage de cache KV côté serveur. Un préfixe identique entre deux appels évite de repayer son prefill, que ce soit visible dans la facture ou non.
Pourquoi ne pas simplement allouer moins de mémoire par requête dès le départ ?
Parce que la longueur finale d'une génération n'est pas connue à l'avance — le modèle produit token par token jusqu'à s'arrêter de lui-même. Une allocation naïve doit donc réserver le pire cas ; PagedAttention contourne le problème en allouant page par page, au fil de l'eau.
La quantification du cache KV dégrade-t-elle la qualité des réponses ?
Légèrement, mais généralement de façon peu perceptible en int8 ou fp8 : l'attention reste assez robuste à cette précision. La dégradation devient plus visible à des précisions plus agressives (4 bits), un compromis à valider selon l'usage plutôt qu'à appliquer par défaut.