smallbard

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 :

ChargeFaçon de servirRequêtes/s1er token, p99Entre deux tokens, p99Bout en bout, p99
25/slots fixes25,71 035 ms2,6 ms1,4 s
lots continus25,86 ms2,4 ms0,7 s
avec des pages25,86 ms2,5 ms0,7 s
50/slots fixes50,41 537 ms3,0 ms2,3 s
lots continus51,0144 ms3,7 ms1,3 s
avec des pages51,08 ms3,5 ms1,3 s
100/slots fixes60,013 376 ms2,9 ms13,9 s
lots continus77,45 634 ms6,6 ms6,7 s
avec des pages92,41 308 ms16,1 ms3,6 s
200/slots fixes60,922 455 ms3,0 ms23,1 s
lots continus78,114 945 ms7,0 ms15,9 s
avec des pages91,810 924 ms20,0 ms12,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èleNombres par positionPositions dans 256 MioRequêtes/s, 256 MioRequêtes/s, 1 Gio
clés et valeurs complètes8 1928 19251,593,3
attention latente, latent de 1281 28052 42861,055,0
attention latente, latent de 2562 30429 12747,045,3
clés de 4 couches sur 84 09616 38479,996,0
clés de 2 couches sur 82 04832 76898,892,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

ArticleCe qui a été construitRésultat
1Un profil, un kernel pour les matrices étroites, un cache en tables225 → 969 tokens/s
2La génération par lots44 000 tokens/s à 256 séquences
3La quantification, GPTQint8 sans perte ; int4 à +0,0035 nat/octet, pas plus rapide
4Le décodage spéculatif×1,53 avec le module multi-token
5Les 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.