TurboQuant : compresser le KV-cache des LLM par 7x sans perdre en qualité
Tu fais tourner Llama 3.1 8B sur ton GPU de 24 Go. Tu lances une requête avec 128K tokens de contexte. Et là, surprise : le modèle ne fait que 16 Go, mais le KV-cache en consomme 32. Ta carte graphique explose. Tu réduis le contexte à 16K. Ça passe, mais tu viens de perdre 87 % de ta fenêtre de travail.
Ce scénario, des milliers de développeurs le vivent chaque jour. Et la solution ne vient pas d'un GPU plus gros — elle vient d'un algorithme de compression élégant publié par Google Research.
Qu'est-ce que le KV-cache (et pourquoi il dévore ta VRAM)
Pour comprendre TurboQuant, il faut d'abord comprendre ce qu'on compresse.
Quand un LLM génère du texte, il utilise le mécanisme d'attention : chaque nouveau token « regarde » tous les tokens précédents pour décider quoi écrire ensuite. Pour éviter de recalculer l'attention complète à chaque token (ce qui serait exponentiellement lent), le modèle stocke les clés (Keys) et les valeurs (Values) de l'attention dans un cache — le KV-cache.
Le problème ? Ce cache grandit linéairement avec la longueur du contexte. Voici les chiffres concrets :
| Modèle | KV-cache FP16 @ 128K tokens | Le modèle lui-même |
|---|---|---|
| Llama 3.1 8B | 16 Go | 16 Go |
| Qwen2.5 14B | 24 Go | 28 Go |
| Llama 3.1 70B | 40 Go | 140 Go |
| Llama 3.1 405B | 64 Go | 810 Go |
Pour le 8B, le KV-cache fait autant que le modèle. Pour le 405B, il représente 8 % du modèle — mais 64 Go, c'est quand même un serveur dédié. Et le pire : ce cache est en FP16 (16 bits par valeur). Chaque vecteur clé ou valeur est un vecteur de dimension 128 en virgule flottante, stocké avec une précision dont le modèle n'a pas réellement besoin.
L'intuition mathématique (sans les maths)
En avril 2025, Google Research publie un papier sur arXiv : « TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate ». Derrière le titre académique, une idée opérationnelle.
Imagine que tu as un vecteur de 128 nombres décimaux à stocker. En FP16, ça prend 256 octets. Mais si tu pouvais les stocker sur 4 bits chacun (au lieu de 16), ça ne prendrait que 64 octets — une compression 4x. Le problème, c'est que passer de 16 bits à 4 bits naïvement (en arrondissant chaque valeur au plus proche dans une grille uniforme) perd beaucoup d'information.
TurboQuant résout ce problème en deux étapes élégantes :
Étape 1 : Rotation aléatoire. Les vecteurs du KV-cache ne sont pas « isotropes » — certaines composantes ont une variance beaucoup plus grande que d'autres. Quantifier directement donnerait une erreur dominée par ces composantes. La rotation redistribue l'information uniformément sur toutes les dimensions, rendant chaque composante aussi « importante » que les autres.
Étape 2 : Quantification Lloyd-Max optimale. Après rotation, chaque composante suit une distribution Beta sphérique. Pour cette distribution, le quantificateur optimal (au sens de l'erreur quadratique) est connu analytiquement. On précalcule un « codebook » de 16 valeurs (pour 4 bits) — et quantifier un scalaire revient à une recherche binaire dans 16 frontières. Moins de 10 nanosecondes.
Le résultat est spectaculaire :
| Configuration | Perplexité WikiText-2 | Δ vs FP16 |
|---|---|---|
| FP16 (baseline) | 6,24 | — |
| TurboQuant 4-bit | 6,29 | +0,8 % |
| TurboQuant 3-bit | 6,41 | +2,7 % |
| Arrondi naïf 4-bit | 6,52 | +4,5 % |
| Arrondi naïf 3-bit | 7,18 | +15,1 % |
TurboQuant à 3 bits surpasse l'arrondi naïf à 4 bits. Un bit de moins, une meilleure qualité. C'est l'écart entre une compression optimale et une compression brute.
L'architecture en 7 couches
Mon implémentation (github.com/phuetz/TurboQuant) décompose l'algorithme en 7 couches distinctes. Chacune a un rôle précis :
Couche 1 — Rotation orthogonale aléatoire
La Walsh-Hadamard randomisée, implémentée en Rust :
pub fn random_hadamard_rotate(
x: &mut [f32],
signs: &[i8], // +1 ou -1, généré une seule fois
) {
let n = x.len();
for i in 0..n {
x[i] *= signs[i] as f32;
}
let mut h = 1;
while h < n {
for i in (0..n).step_by(h * 2) {
for j in i..(i + h) {
let a = x[j];
let b = x[j + h];
x[j] = a + b;
x[j + h] = a - b;
}
}
h *= 2;
}
let norm = (n as f32).sqrt();
for v in x.iter_mut() {
*v /= norm;
}
}La matrice de rotation est générée une seule fois à l'initialisation du cache. Coût fixe négligeable.
Couche 2 — Quantification scalaire Lloyd-Max
Le codebook optimal pour la distribution Beta sphérique. 50 itérations de Lloyd suffisent pour converger. Ensuite, quantifier = recherche binaire dans 2^b frontières.
Couche 3 — Stockage compacté (Packed Integers)
En 4 bits, on stocke 2 indices par octet. En 2 bits, 4 par octet. Le packing/unpacking est implémenté avec des opérations bit-à-bit en Rust pour des performances maximales. C'est un détail d'implémentation, mais il fait une vraie différence : sans packing, les indices 4-bit occuperaient un octet chacun (gaspillant 50 % de l'espace), et on perdrait la moitié du gain de compression.
Couche 4 — Stockage des normes
La norme de chaque vecteur est stockée séparément en FP32 (4 octets par vecteur). Cette précision est critique car la norme contrôle l'amplitude du vecteur reconstruit. Si tu quantifies la norme aussi agressivement que les composantes, tu introduis un biais systématique sur l'échelle — et ça se voit dans les benchmarks. Garder la norme en FP32 coûte seulement 4 octets par vecteur (contre 256 octets pour le vecteur complet en FP16), c'est un compromis excellent.
Couche 5 — Fenêtre résiduelle
Couche 6 — Skip layers automatique
Certaines couches du modèle ont des distributions de KV-cache « anomales » — des outliers qui cassent la quantification. TurboQuant les détecte automatiquement via la kurtosis de la distribution et les garde en pleine précision. En pratique, sur Llama 3.1 8B, 2-3 couches sur 32 sont marquées comme anomales. Le surcoût mémoire est marginal, mais le gain en qualité est mesurable.
Couche 7 — Interface HuggingFace
Un remplacement drop-in de DynamicCache :
from turboquant import TurboQuantCache
# Avant : cache = DynamicCache()
cache = TurboQuantCache(bits=4, residual_window=128)
output = model.generate(
input_ids,
past_key_values=cache,
max_new_tokens=1024,
)Une ligne. C'est tout.
Les résultats en production
Voici ce que donne TurboQuant en pratique :
| Modèle | KV-cache FP16 | Après TurboQuant 4-bit | Compression |
|---|---|---|---|
| Llama 3.1 8B | 16 Go | 2,3 Go | 7x |
| Qwen2.5 14B | 24 Go | 3,4 Go | 7x |
| Llama 3.1 70B | 40 Go | 5,7 Go | 7x |
| Llama 3.1 405B | 64 Go | 9,1 Go | 7x |
Concrètement, ça signifie que tu peux faire tourner Llama 8B avec 128K tokens de contexte sur une RTX 4090 (24 Go) — là où il te fallait une A100 (80 Go) avant.
L'implémentation est pure PyTorch — pas de kernels CUDA custom. Ça fonctionne sur NVIDIA, AMD, Apple MPS, et Intel. Et elle est couverte par 99 tests : 75 en Rust pour les algorithmes de compression, 24 en Python pour l'intégration HuggingFace.
Pourquoi j'en ai fait un chapitre de livre
TurboQuant est fascinant, mais il ne vit pas dans le vide. Pour vraiment comprendre pourquoi la compression du KV-cache est importante, il faut comprendre l'ensemble du pipeline de mémoire des LLM : la dégradation du contexte long, Flash Attention et Paged Attention, le context engineering, les systèmes de mémoire des agents IA.
C'est exactement ce que j'ai fait dans « La Mémoire des Machines ». Le chapitre TurboQuant fait 4 600 mots avec du code Rust pour chacune des 7 couches — mais il s'inscrit dans un livre de 16 chapitres qui couvre tout le spectre, de la théorie de l'information à l'architecture d'agents en production.
Si tu t'intéresses plus largement aux agents LLM et à la façon de les construire en Python — pas juste la mémoire, mais les outils, la sécurité, le raisonnement — j'ai un autre livre qui couvre exactement ça.
Soutenez mon travail sur Patreon
Accès anticipé aux articles, contenu exclusif, et la satisfaction de soutenir un auteur indépendant.
Rejoindre — à partir de 3€/mois