Articles · Smallem : servir un modèle
Servir plusieurs requêtes
Cinquième et dernier article de la série Smallem : servir un modèle. Précédent : Deviner, puis vérifier.
Jusqu'ici, nous avons mesuré des générations que nous lancions nous-mêmes : une séquence, ou un lot de soixante-quatre parties ensemble et finies ensemble. Un service ne choisit pas ses requêtes. Elles arrivent à tout moment, avec des longueurs quelconques, et chacune attend sa réponse. Ce qui compte n'est plus seulement le débit, mais aussi ce qu'attend chaque utilisateur : avant le premier token, puis entre deux tokens.
Un simulateur
Un programme, serve, joue ce service. Les requêtes arrivent selon un processus de
Poisson : les écarts entre deux arrivées sont tirés d'une loi exponentielle, comme pour
des clients indépendants. Chacune a une amorce de 16 à 128 tokens prise dans le texte de
validation, et demande de 32 à 384 tokens. Le serveur tourne pour de vrai, sur la carte,
et l'horloge du simulateur est l'horloge réelle : une requête qui arrive pendant un pas
attend la fin du pas.
On mesure le débit, en requêtes et en tokens par seconde, et trois latences, médiane et 99ᵉ centile : jusqu'au premier token, entre deux tokens, et de bout en bout.
Le cache a une taille fixe : 1 Gio, soit 32 768 positions de notre modèle, dont chacune garde 8 192 nombres. C'est lui qui borne le nombre de requêtes servies ensemble.
Trois façons de servir
Les lots fixes. Quand le lot en cours est fini, le serveur prend les requêtes en attente, autant que le cache en permet, et les mène ensemble à leur terme. Chacune réserve dans le cache tout le contexte du modèle, 512 positions, puisqu'on ne sait pas d'avance jusqu'où elle ira : 64 requêtes au plus. Une requête qui arrive pendant un lot attend la fin du lot entier, et la plus longue du lot fait attendre toutes les autres.
Les lots continus (Yu et al., 2022,
le système Orca). Une requête entre dans le lot dès qu'une place se libère, et en sort dès
qu'elle est finie. Le Batch de l'article 2 le permet sans rien changer : chaque pas
traite les séquences en cours, quelles qu'elles soient, et une amorce peut être lue dans
le même pas que les tokens générés des autres. La réservation reste la même : 64 requêtes
au plus.
Les lots continus avec des pages. C'est l'idée de vLLM (Kwon et al., 2023) : ne plus réserver le pire, mais donner les lignes du cache par pages de 16, à mesure que les séquences s'allongent, comme un système d'exploitation donne sa mémoire. Notre cache le fait depuis le premier article ; il suffit de régler la taille des pages, qui valait jusqu'ici le contexte entier pour les deux premières façons de servir.
Avec des pages, il faut une règle d'admission. Une requête entre s'il reste de quoi lire son amorce, plus une réserve : nous avons pris 10 % des pages. Sans réserve, les séquences admises grandissent jusqu'à manquer de pages, et il faut alors en suspendre une. Ses pages sont rendues, et elle repartira plus tard, son texte déjà écrit lui servant d'amorce : on paie de nouveau la lecture de tout ce texte. Avec une réserve de 10 %, il n'y a presque plus de suspensions.
Les mesures
2 000 requêtes à chaque charge :
| Charge | Façon de servir | Requêtes/s | 1er token, p99 | Entre deux tokens, p99 | Bout en bout, p99 |
|---|---|---|---|---|---|
| 25/s | lots fixes | 25,7 | 1 035 ms | 2,6 ms | 1,4 s |
| lots continus | 25,8 | 6 ms | 2,4 ms | 0,7 s | |
| avec des pages | 25,8 | 6 ms | 2,5 ms | 0,7 s | |
| 50/s | lots fixes | 50,4 | 1 537 ms | 3,0 ms | 2,3 s |
| lots continus | 51,0 | 144 ms | 3,7 ms | 1,3 s | |
| avec des pages | 51,0 | 8 ms | 3,5 ms | 1,3 s | |
| 100/s | lots fixes | 60,0 | 13 376 ms | 2,9 ms | 13,9 s |
| lots continus | 77,4 | 5 634 ms | 6,6 ms | 6,7 s | |
| avec des pages | 92,4 | 1 308 ms | 16,1 ms | 3,6 s | |
| 200/s | lots fixes | 60,9 | 22 455 ms | 3,0 ms | 23,1 s |
| lots continus | 78,1 | 14 945 ms | 7,0 ms | 15,9 s | |
| avec des pages | 91,8 | 10 924 ms | 20,0 ms | 12,3 s |
À faible charge, les trois servent tout ce qui arrive. Mais les lots fixes font attendre : une requête arrivée pendant un lot attend sa fin, plus d'une seconde au 99ᵉ centile, là où les lots continus répondent en quelques millisecondes.
À 100 requêtes par seconde, plus que le serveur ne peut en servir, les écarts se creusent. Les lots fixes plafonnent à 60 requêtes par seconde : chaque lot attend sa plus longue requête, avec des places vides. Les lots continus montent à 77, mais ne peuvent pas dépasser 64 requêtes en même temps. Avec des pages, le serveur en mène jusqu'à 179 ensemble, et sert 92 requêtes par seconde : 54 % de plus que les lots fixes, avec une latence de bout en bout quatre fois plus courte au 99ᵉ centile.
Il y a un prix : entre deux tokens, le 99ᵉ centile passe de 3 à 16 ms. Un pas sur 179 séquences dure plus longtemps qu'un pas sur 64, et chaque utilisateur voit son texte arriver moins vite. Les vrais serveurs bornent aussi le nombre de séquences d'un lot pour cette raison, le nôtre ne borne que la mémoire.
Rejouer les étapes 10.2 et 11.3
La première série a essayé deux façons de réduire le cache : l'attention latente de DeepSeek, qui ne garde par position qu'un petit vecteur par couche, et les clés partagées, où seules les premières couches calculent des clés et des valeurs. À l'entraînement, elles n'avaient rien apporté. C'est ici qu'elles devaient servir.
Même simulateur, à 400 requêtes par seconde, pour saturer le serveur, avec chaque forme de cache, et deux tailles de cache :
| Modèle | Nombres par position | Positions dans 256 Mio | Requêtes/s, 256 Mio | Requêtes/s, 1 Gio |
|---|---|---|---|---|
| clés et valeurs complètes | 8 192 | 8 192 | 51,5 | 93,3 |
| attention latente, latent de 128 | 1 280 | 52 428 | 61,0 | 55,0 |
| attention latente, latent de 256 | 2 304 | 29 127 | 47,0 | 45,3 |
| clés de 4 couches sur 8 | 4 096 | 16 384 | 79,9 | 96,0 |
| clés de 2 couches sur 8 | 2 048 | 32 768 | 98,8 | 92,1 |
Avec 256 Mio de cache, le serveur est limité par la mémoire : le modèle complet n'y fait tenir qu'une soixantaine de requêtes à la fois. Diviser le cache par quatre, avec deux couches de clés, en fait tenir quatre fois plus, et le débit double presque.
Avec 1 Gio, la mémoire ne limite plus : le modèle complet mène déjà 256 séquences à la fois, et en mener davantage ne sert plus à rien, puisque chaque pas devient plus long. Les quatre formes à clés et valeurs classiques se valent.
L'attention latente déçoit, pour une raison qui n'est pas la taille de son cache. Elle garde six fois moins de nombres, mais chaque pas lui coûte bien plus : la requête est projetée dans l'espace des latents, tête par tête, et le kernel d'attention lit des clés de 160 composantes au lieu de 64. Avec 1 Gio, elle admet près de 2 000 séquences à la fois, et chaque pas prend 100 ms. Notre implémentation n'est pas faite pour elle. Celle de DeepSeek fusionne justement ces projections dans le kernel.
Ces modèles ne sont pas équivalents : la première série a mesuré que les clés partagées perdent un peu en qualité, et l'attention latente un peu plus. Ce tableau ne compare que ce que leur cache permet.
Le bilan de la série
| Article | Ce qui a été construit | Résultat |
|---|---|---|
| 1 | Un profil, un kernel pour les matrices étroites, un cache en tables | 225 → 969 tokens/s |
| 2 | La génération par lots | 44 000 tokens/s à 256 séquences |
| 3 | La quantification, GPTQ | int8 sans perte ; int4 à +0,0035 nat/octet, pas plus rapide |
| 4 | Le décodage spéculatif | ×1,53 avec le module multi-token |
| 5 | Les lots continus, le cache par pages | +54 % de requêtes servies |
Trois leçons traversent la série.
Mesurer avant de supposer. On supposait que le coût des lancements de kernels bornait la génération. Le profil a montré un produit matriciel quarante fois trop lent. On supposait également que la quantification accélérerait la génération : elle ne l'a pas fait, parce que nous ne sommes pas limités par les octets lus.
Le temps fixe d'une passe est une ressource. Une passe sur huit tokens coûte presque autant que sur un. La génération par lots, le décodage spéculatif et les lots continus exploitent tous les trois ce même fait.
La mémoire du cache décide du service. Une séquence ne coûte pas seulement les calculs de ses tokens, mais aussi la place de son passé. Réserver le pire la gaspille. La donner par pages, ou la réduire à la source avec des clés partagées, double le nombre de requêtes servies quand c'est elle qui manque.
Ce qui reste à faire est clair : fusionner davantage, pour que les kernels de la génération attendent enfin la mémoire, et non leur propre lancement. Les graphes CUDA, la quantification et l'attention latente en profiteraient tous.
Ce qu'il faut retenir
- Un service se mesure en débit et en latence : premier token, et chaque token suivant.
- Les lots continus suppriment l'attente de la fin du lot ; le cache par pages supprime la réservation du pire. Ensemble : 54 % de requêtes en plus, et quatre fois moins d'attente au 99ᵉ centile, contre un débit de tokens plus lent pour chaque utilisateur.
- Un cache plus petit ne paie que si la mémoire limite : à 256 Mio, les clés partagées doublent presque le débit, à 1 Gio, elles ne changent rien.