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.

Allocation naïve seq A seq B seq C réservé, inutilisé mémoire réservée pour la longueur max gaspillage si la séquence réelle est plus courte PagedAttention seq A seq B seq C libre pages de taille fixe, table d'indirection libérées et réaffectées dès qu'une séquence finit
À gauche, chaque requête réserve un bloc contigu dimensionné pour le pire cas : la partie non utilisée reste bloquée. À droite, le cache KV est découpé en pages de taille fixe piochées dans un pool commun et rendues dès qu'une séquence se termine — plus proche de la pagination mémoire d'un système d'exploitation que d'une allocation classique.

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égieProblème viséMécanismeGain typique
PagedAttentionFragmentation mémoirePages de taille fixe + table d'indirectionBeaucoup plus de séquences concurrentes par GPU
Prefix cachingRecalcul redondant d'un préfixe partagéPartage du cache déjà calculé entre requêtesPrefill quasi gratuit sur un préfixe déjà vu
Éviction (LRU)Saturation de la VRAM sous chargeLibération des pages les moins récemment utiliséesAbsorbe les pics, au prix d'un re-prefill différé
Cache KV quantifiéEmpreinte mémoire par tokenStockage K/V en int8 ou fp8 plutôt qu'en fp16Environ 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.