Comment optimiser le KV cache des LLM ?

Le KV cache des LLM s’optimise en traitant deux vrais problèmes : la mémoire GPU gaspillée et les calculs répétés. Je vais montrer pourquoi PagedAttention et RadixAttention changent vraiment le serving LLM, surtout quand les contextes deviennent longs et que la production commence à coûter cher.



Pourquoi le KV cache bloque les LLM ?



Le KV cache bloque les LLM parce qu’il grossit avec la longueur du contexte et consomme rapidement la mémoire GPU disponible. Pendant l’inférence autoregressive, le modèle génère un token, puis un autre, puis encore un autre. À chaque nouveau token, il doit consulter ce qui a déjà été généré ou lu dans le prompt.

Comment optimiser le KV cache des LLM ?

Le KV cache sert à éviter de recalculer toute l’attention depuis zéro à chaque fois. Il stocke deux types de vecteurs pour les tokens précédents : les Key, qui servent à retrouver “où regarder”, et les Value, qui contiennent l’information à réutiliser. Dit simplement, les Keys aident le modèle à choisir les bons morceaux du contexte, les Values sont les morceaux qu’il récupère.

Le piège, c’est que le problème n’est pas seulement la taille du modèle. En production, le vrai mur arrive souvent avec la mémoire nécessaire pour servir plusieurs requêtes en même temps. Je l’ai vu plusieurs fois sur des projets IA : tout le monde regarde d’abord si le modèle fait 7B, 13B ou 70B paramètres, alors que le plafond arrive vite côté serving, quand 20 utilisateurs balancent chacun des prompts longs.

Concrètement, un KV cache trop lourd veut dire :

  • Moins de requêtes concurrentes sur le même GPU.
  • Un débit plus faible, donc moins de tokens générés par seconde.
  • Une latence qui monte dès que les files d’attente se remplissent.
  • Des coûts GPU plus élevés, parce qu’il faut plus de machines pour tenir la charge.

Les longues fenêtres de contexte aggravent tout. Passer de 4 000 à 32 000 tokens, ce n’est pas juste “un peu plus de mémoire”. Le KV cache augmente linéairement avec le nombre de tokens, le nombre de couches du modèle, le nombre de têtes KV et la dimension de chaque tête. C’est exactement pour ça que des moteurs comme vLLM ont travaillé spécifiquement sur la gestion mémoire du KV cache, notamment avec PagedAttention, une approche qui gère le cache en blocs mémoire plus efficaces.

Ce petit code donne une estimation simplifiée. Il est utile pour sentir les ordres de grandeur, pas pour remplacer le calcul exact d’un moteur de serving.

def estimate_kv_cache_memory(
    num_layers,
    num_kv_heads,
    head_dim,
    sequence_length,
    bytes_per_value=2
):
    # On stocke Key et Value, donc on multiplie par 2.
    kv_vectors = 2

    # Calcul simplifié de la mémoire totale en octets.
    total_bytes = (
        num_layers
        * num_kv_heads
        * head_dim
        * sequence_length
        * kv_vectors
        * bytes_per_value
    )

    # Conversion en Go pour une lecture plus simple.
    total_gb = total_bytes / (1024 ** 3)

    return total_gb


memory_gb = estimate_kv_cache_memory(
    num_layers=32,
    num_kv_heads=8,
    head_dim=128,
    sequence_length=32000,
    bytes_per_value=2  # Float16 ou bfloat16 utilisent souvent 2 octets.
)

print(f"KV cache estimé : {memory_gb:.2f} Go par requête")

Le point important est là : plus le contexte s’allonge, plus chaque requête garde de mémoire GPU occupée. Et tant que cette mémoire reste bloquée, le GPU ne peut pas accueillir autant d’autres utilisateurs.



Quels sont les deux problèmes à séparer ?



Quand j’optimise un KV cache, je sépare toujours deux sujets dès le départ : le gaspillage mémoire et les calculs redondants. Ça se ressemble vu de loin, parce que dans les deux cas le GPU souffre. Mais ce ne sont pas les mêmes problèmes, donc pas les mêmes solutions.

Comment optimiser le KV cache des LLM ?

La fragmentation mémoire arrive quand la mémoire GPU est réservée ou libérée de manière inefficace. On se retrouve avec des trous, des blocs trop grands, ou des zones difficiles à réutiliser proprement. Résultat assez frustrant : théoriquement il reste de la mémoire, mais en pratique le moteur ne peut pas toujours y placer une nouvelle séquence. J’ai déjà vu ça en prod sur des workloads avec beaucoup de requêtes courtes et longues mélangées. Le GPU n’était pas “plein” au sens brut, mais le serveur refusait quand même de monter en charge.

La recomputation redondante, c’est autre chose. Là, le problème vient du fait que plusieurs requêtes partagent le même début de prompt. Un system prompt, un template d’agent, des consignes métier, une base documentaire injectée à chaque appel… Sans mécanisme de réutilisation, le moteur recalcule les mêmes clés et valeurs KV encore et encore. Avec des prompts longs, ça coûte vite très cher, parce qu’on refait du calcul qui aurait pu être mutualisé.

Donc non, PagedAttention et RadixAttention ne sont pas concurrents au sens strict. PagedAttention optimise surtout l’allocation mémoire du KV cache, un peu comme une pagination mémoire côté GPU. RadixAttention optimise surtout la réutilisation des préfixes communs, avec une structure de type arbre pour retrouver les débuts de prompts déjà calculés. Si je mélange les deux, je risque juste de mal diagnostiquer mon vrai goulot d’étranglement.

Problème Symptôme en production Technique utile
Fragmentation mémoire Le GPU semble avoir de la mémoire restante, mais le moteur accepte moins de séquences que prévu. PagedAttention
Cache surdimensionné Chaque requête réserve trop de KV cache, même quand elle n’utilise pas tout son contexte. Allocation par blocs, pagination, limites de contexte mieux calibrées.
Préfixes recalculés Les mêmes system prompts ou templates d’agent sont recalculés à chaque requête. RadixAttention, prefix caching.
Contexte long La latence et le coût explosent quand les prompts deviennent très longs. Réutilisation de préfixes, chunking, réduction du contexte inutile.

La bonne lecture, c’est celle-là : PagedAttention traite d’abord la partie mémoire. Et c’est souvent le premier mur qu’on rencontre quand on veut servir plus de requêtes avec le même GPU.



Comment PagedAttention économise la mémoire ?



PagedAttention économise la mémoire en découpant le KV cache en blocs fixes alloués à la demande, au lieu de réserver un grand espace contigu pour toute la séquence. L’idée a été popularisée par vLLM avec les travaux Efficient Memory Management for Large Language Model Serving with PagedAttention. Et franchement, c’est une idée simple mais très efficace.

Comment optimiser le KV cache des LLM ?

L’analogie la plus proche, c’est la mémoire virtuelle d’un système d’exploitation. Un programme voit une mémoire logique continue, mais derrière, le système mappe ça vers des pages physiques dispersées. PagedAttention fait pareil avec le KV cache. Sans pousser l’image trop loin, parce qu’on reste sur de la mémoire GPU et des tenseurs, pas sur un OS complet.

  • Blocs logiques : Ce que la séquence croit utiliser, token après token.
  • Blocs physiques : Les vrais morceaux de mémoire GPU qui stockent les clés et valeurs.
  • Table de blocs : Le mapping entre les blocs logiques d’une séquence et les blocs physiques disponibles.
  • Allocation progressive : La séquence reçoit un nouveau bloc seulement quand elle en a besoin.

Ça évite de réserver 8 000 tokens de KV cache pour une requête qui va finalement s’arrêter à 600 tokens. Ça réduit la fragmentation interne, quand de la mémoire réservée reste vide dans un bloc trop grand. Ça réduit aussi la fragmentation externe, quand il reste plein de petits trous inutilisables parce qu’on voulait un gros espace contigu. L’objectif n’est pas de rendre le modèle plus intelligent. L’objectif, c’est de mieux remplir la mémoire GPU.

Voici une version volontairement simplifiée d’un allocateur de blocs KV. Ça ne gère pas les tenseurs réels, mais ça montre bien le principe de table par séquence et d’allocation à la demande.

class KVBlockAllocator:
    def __init__(self, total_blocks):
        # Blocs physiques disponibles en mémoire GPU
        self.free_blocks = list(range(total_blocks))

        # Table de mapping : sequence_id -> blocs physiques utilisés
        self.sequence_tables = {}

    def allocate_token_block(self, sequence_id):
        # Crée une table vide si la séquence arrive pour la première fois
        if sequence_id not in self.sequence_tables:
            self.sequence_tables[sequence_id] = []

        # Vérifie qu'il reste au moins un bloc physique libre
        if not self.free_blocks:
            raise MemoryError("Plus de blocs KV disponibles")

        # Alloue un bloc physique à la séquence
        physical_block = self.free_blocks.pop()

        # Ajoute ce bloc dans la table logique de la séquence
        self.sequence_tables[sequence_id].append(physical_block)

        return physical_block

    def free_sequence(self, sequence_id):
        # Libère tous les blocs utilisés par une séquence terminée
        blocks = self.sequence_tables.pop(sequence_id, [])

        # Remet les blocs dans la liste des blocs libres
        self.free_blocks.extend(blocks)


allocator = KVBlockAllocator(total_blocks=4)

allocator.allocate_token_block("req_1")
allocator.allocate_token_block("req_1")
allocator.allocate_token_block("req_2")

print(allocator.sequence_tables)
print(allocator.free_blocks)

allocator.free_sequence("req_1")

print(allocator.sequence_tables)
print(allocator.free_blocks)

En production, ce mécanisme change beaucoup de choses. On utilise mieux le GPU, on accepte plus de requêtes simultanées, on gaspille moins de mémoire, et le serving devient plus stable quand les longueurs de prompts et de réponses varient. J’ai vu ce problème chez des clients avec des prompts très irréguliers : sans gestion fine du KV cache, le GPU semblait plein alors qu’une partie de la mémoire était juste mal découpée.

La limite, c’est que PagedAttention gère mieux la mémoire, mais il ne sait pas à lui seul éviter de recalculer deux prompts qui commencent pareil. Pour ça, il faut regarder du côté du prefix caching.



Comment RadixAttention réutilise les prompts ?



RadixAttention réutilise les parties déjà calculées des prompts en indexant les préfixes communs et leurs états KV. Le KV cache, c’est la mémoire des clés et valeurs déjà produites par le modèle pendant l’attention. Si deux prompts commencent pareil, on n’a pas envie de recalculer cette partie. On veut la reprendre directement.

Comment optimiser le KV cache des LLM ?

Exemple simple. Vous avez plusieurs requêtes qui commencent par le même system prompt : “Tu es un assistant juridique, réponds en français, cite les risques”. Puis chaque utilisateur pose une question différente. La partie système est identique. RadixAttention retrouve ce préfixe commun, récupère son KV cache, puis le modèle ne calcule que la suite qui change.

L’idée vient d’une structure radix, ou arbre de préfixes. Les segments communs sont stockés une seule fois. Dès que les prompts divergent, l’arbre crée des branches. C’est utilisé dans des systèmes de serving LLM comme SGLang avec RadixAttention, surtout quand les workloads contiennent beaucoup de prompts répétitifs. Et franchement, en prod, c’est fréquent. Les agents, les templates RAG, les assistants métiers… tout le monde répète les mêmes instructions.

Voici une version développeur très simplifiée. Elle ne manipule pas de tensors GPU, pas de références mémoire, pas d’éviction de cache. Un vrai moteur fait tout ça. Ici, je montre juste l’idée : stocker des tokens et retrouver le plus long préfixe déjà disponible.

class RadixNode:
    def __init__(self):
        # Chaque enfant correspond au token suivant.
        self.children = {}

        # Identifiant simulé du KV cache pour ce préfixe.
        self.kv_cache_id = None


class PrefixKVCache:
    def __init__(self):
        self.root = RadixNode()

    def insert(self, tokens, kv_cache_id):
        # Stocke un préfixe token par token dans l'arbre.
        node = self.root

        for token in tokens:
            if token not in node.children:
                node.children[token] = RadixNode()
            node = node.children[token]

        # Le dernier noeud pointe vers le KV cache du préfixe complet.
        node.kv_cache_id = kv_cache_id

    def longest_prefix_match(self, tokens):
        # Retrouve le plus long préfixe déjà présent dans le cache.
        node = self.root
        best_match_length = 0
        best_kv_cache_id = None

        for index, token in enumerate(tokens):
            if token not in node.children:
                break

            node = node.children[token]

            if node.kv_cache_id is not None:
                best_match_length = index + 1
                best_kv_cache_id = node.kv_cache_id

        return best_match_length, best_kv_cache_id


cache = PrefixKVCache()

cache.insert(
    ["system", "assistant", "juridique", "français"],
    "kv_system_001"
)

tokens = ["system", "assistant", "juridique", "français", "question", "contrat"]

length, kv_id = cache.longest_prefix_match(tokens)

print(length)  # 4
print(kv_id)  # kv_system_001

RadixAttention devient très utile dans quelques cas très concrets :

  • Agents avec le même system prompt à chaque appel.
  • Workflows RAG avec des templates répétitifs.
  • Chat multi-tours, où l’historique partage souvent de longs préfixes.
  • Lots de requêtes proches, par exemple en support client ou analyse documentaire.
  • Assistants métiers avec des consignes longues et stables.

La limite est simple. Si les prompts n’ont presque aucun préfixe commun, le gain baisse fortement. RadixAttention évite de recalculer ce qui existe déjà. PagedAttention, lui, aide à stocker ce cache proprement en mémoire. Les deux se complètent très bien.



Quand utiliser PagedAttention et RadixAttention ?



J’utilise PagedAttention quand mon problème principal, c’est la mémoire du KV cache. Et j’utilise RadixAttention quand je veux éviter de recalculer des préfixes déjà vus. Dit autrement, PagedAttention optimise l’allocation mémoire, RadixAttention optimise la réutilisation de calcul.

Le choix dépend surtout du symptôme que vous voyez en production. Si vous avez des OOM GPU, donc des erreurs “out of memory”, une concurrence faible alors que le GPU semble encore capable, ou une mémoire fragmentée avec beaucoup de petits trous inutilisables, je regarde PagedAttention. Son intérêt, c’est de découper le KV cache en blocs, un peu comme un système de pagination mémoire. Ça évite de réserver de gros espaces contigus pour chaque requête.

Si vos prompts se ressemblent beaucoup, avec des templates communs, des instructions système identiques, des historiques qui partagent les mêmes débuts, là je regarde RadixAttention. L’idée est simple : si un préfixe a déjà été calculé, on le réutilise. Ça évite de refaire le même travail encore et encore. J’ai vu ça chez un client avec des assistants internes : 70% du prompt était identique entre les utilisateurs, mais tout était recalculé à chaque appel. C’est typiquement le genre de cas où RadixAttention devient intéressant.

Critère PagedAttention RadixAttention
Objectif Mieux gérer la mémoire KV Réutiliser les préfixes déjà calculés
Problème traité OOM GPU, fragmentation, faible concurrence Prompts répétitifs, préfixes communs
Niveau d’action Allocation mémoire Réutilisation de calcul
Bénéfice principal Plus de requêtes servies en parallèle Moins de tokens à recalculer
Limite Ne réduit pas le calcul si les prompts changent tout le temps Peu utile si les préfixes sont uniques
Exemple d’usage Serving LLM avec contextes longs et forte concurrence Chatbot avec template système récurrent

Je ne les oppose pas. Dans un vrai système de production, les deux peuvent très bien se compléter. PagedAttention garde le GPU plus stable côté mémoire. RadixAttention évite du calcul inutile côté préfixes. Sur des contextes longs avec des templates récurrents, c’est souvent une très bonne combinaison.

Avant de choisir, je mesure quelques choses simples :

  • La mémoire KV réellement consommée par requête.
  • La concurrence réelle, pas juste celle espérée sur le papier.
  • Les longueurs de prompts et leur distribution.
  • Les préfixes répétitifs dans les prompts métier.
  • Les performances avec les vrais prompts, pas avec des exemples trop propres.

En production IA, les gains viennent rarement d’un seul réglage magique. C’est souvent l’empilement de bonnes décisions simples, bien mesurées, au bon endroit.



Alors on optimise quoi en premier ?



Le KV cache est devenu un sujet central pour servir des LLM sérieusement. Plus les contextes s’allongent, plus la mémoire GPU devient le vrai plafond. PagedAttention aide à mieux allouer cette mémoire avec des blocs utilisés à la demande. RadixAttention évite de recalculer les préfixes déjà vus. Ce sont deux réponses différentes à deux problèmes différents, et c’est justement pour ça qu’elles se complètent bien. Si vous mesurez correctement vos prompts, votre concurrence et votre mémoire, vous pouvez gagner en débit, réduire la latence et servir plus d’utilisateurs avec la même infrastructure.



FAQ



  • Qu’est-ce que le KV cache dans un LLM ?
    Le KV cache stocke les clés et valeurs calculées pour les tokens déjà traités. Pendant la génération, le modèle s’en sert pour produire les tokens suivants sans recalculer toute l’attention depuis zéro à chaque étape.
  • Pourquoi le KV cache consomme autant de mémoire GPU ?
    Sa taille augmente avec la longueur du contexte, le nombre de couches du modèle, les dimensions d’attention et la précision utilisée. Sur des contextes longs, il peut devenir le principal facteur qui limite le nombre de requêtes parallèles.
  • À quoi sert PagedAttention ?
    PagedAttention sert à mieux gérer la mémoire du KV cache. Au lieu de réserver un grand bloc continu, il découpe le cache en blocs fixes et les alloue au fur et à mesure. Ça réduit le gaspillage mémoire et améliore la concurrence.
  • À quoi sert RadixAttention ?
    RadixAttention sert à réutiliser les préfixes de prompts déjà calculés. Quand plusieurs requêtes commencent pareil, le système peut récupérer les états KV correspondants au lieu de recalculer les mêmes tokens.
  • PagedAttention et RadixAttention sont-ils concurrents ?
    Pas vraiment. PagedAttention traite surtout le problème d’allocation mémoire. RadixAttention traite surtout le problème de recomputation des préfixes. Dans un service LLM solide, les deux approches peuvent se compléter.

 

 

A propos de l’auteur



Je suis Franck Scandolera, expert et formateur en Tracking avancé server-side, Analytics Engineering, automatisation No/Low Code avec n8n, intégration de l’IA en entreprise et SEO/GEO. J’accompagne des équipes qui doivent rendre leurs systèmes data et IA plus fiables, plus mesurables et plus efficaces en production. Je dirige l’agence webAnalyste et l’organisme Formations Analytics. J’ai travaillé avec des références comme Logis Hôtel, Yelloh Village, BazarChic, la Fédération Française de Football ou Texdecor. Si vous voulez cadrer, mesurer ou industrialiser vos usages IA, contactez-moi.

Défiler vers le haut
AIgenierie