Deux machines de classe DGX Spark, un seul modèle : une copie sur chacune, au lieu de le diviser

Opérations

Pendant sept semaines, notre modèle de langage local tournait réparti sur deux machines NVIDIA GB10 avec parallélisme tensoriel. Le 22 octobre, nous avons remplacé cette configuration par une copie complète du modèle sur chaque machine, avec un proxy à moindre connexion devant. Le débit est passé d'environ 180 à 210 jetons par seconde, et l'une ou l'autre machine peut désormais redémarrer sans interrompre le service du modèle.

Pourquoi nous l'avions divisé

Chaque machine dispose de 128 Go de mémoire partagée entre le CPU et le GPU. En septembre, le modèle tenait sur une machine, mais son cache y tenait à peine : la version vLLM que nous utilisions ne pouvait pas conserver le cache KV de ce modèle en FP8, le cache était donc en BF16, et une machine offrait de la place pour 499 110 jetons de cache. Cela représente moins de deux requêtes au contexte complet du modèle de 262 144 jetons. Réparti sur les deux machines avec parallélisme tensoriel via leur liaison réseau directe, le cache atteignait plus de trois millions de jetons. Nous avions divisé le modèle pour gagner de la place, pas pour la vitesse.

Ce qui a changé

Une version 4 bits plus récente du même modèle (NVFP4, obtenue par distillation consciente de la quantification) occupe environ 71 GiB de mémoire, et une version communautaire de vLLM pour GB10 (eugr/spark-vllm-b12x) exécute son cache en FP8. Désormais, une machine héberge l'ensemble du modèle et un cache de 0,76 à 1,08 million de jetons, soit de la place pour 2,9 à 4,1 requêtes au contexte complet, selon la mémoire que la machine lui alloue. Les deux copies utilisent le décodage spéculatif propre au modèle (MTP, quatre jetons de brouillon). Avant la bascule, nous avons lancé notre test d'appels d'outils sur la nouvelle version : elle a obtenu un score de 86, contre 75 pour la version qu'elle remplace.

Ce que nous avons mesuré

Réparti sur deux machinesUne copie par machine
Cache KVplus de 3 millions de jetons0,76 et 1,08 million de jetons
Requêtes au contexte completenviron 122,9 et 4,1
Débit sous notre chargeenviron 180 jetons/s210 jetons/s (111 + 99)
Démarrageenviron 20 minutes5 à 13 minutes par copie
Redémarrage d'une machinele modèle est indisponiblel'autre copie sert les requêtes

Une copie seule, selon le nombre de requêtes simultanées : avec des prompts courts, 1 requête donnait 36 tokens par seconde, 4 en donnaient 82, 8 en donnaient 109 et 16 en donnaient 150. Avec les prompts plus longs de notre agent e-mail, 4 en donnaient 81, 8 en donnaient 113 et 16 seulement 87 : au-delà de huit requêtes de ce type, en ajouter ralentissait le système.

Pendant un après-midi, une copie a traité au maximum 12 requêtes simultanées et l'autre 8. La première produisait 91 à 93 tokens par seconde et terminait 26 à 27 requêtes par minute ; la seconde 81 à 83 tokens par seconde et 22 requêtes par minute. Les deux prennent désormais 12 requêtes.

Ce que nous avons sacrifié

L'espace. Le modèle scindé disposait d'un cache de plus de trois millions de tokens ; les copies en ont deux plus petits, et une requête ne peut utiliser que le cache de la copie qui la sert. Nos agents envoient de nombreuses requêtes courtes et moyennes, et pour elles, le débit comptait davantage. Si vous servez quelques contextes très longs à la fois, la scission peut rester le meilleur choix. Nous avons conservé la configuration scindée, et le retour en arrière se fait par un simple script.

Ce qui a mal tourné lors de la bascule

  1. Onze minutes sans modèle, puis 30 secondes d'erreurs 502. Le modèle scindé devait s'arrêter avant qu'une copie puisse se charger sur les mêmes machines, si bien que nos agents ont attendu environ onze minutes. Quand la première copie s'est levée, le proxy a continué à répondre « 502 no live upstreams » pendant environ 30 secondes de plus : il avait marqué les backends comme défaillants pendant leur arrêt (max_fails=3, fail_timeout=30s) et ne les a pas réessayés avant la fin de ce délai.
  2. Un client qui contournait le proxy. L'un de nos traitements par lots avait l'adresse de la première machine dans sa configuration, si bien que toute sa charge allait vers une seule copie tandis que l'autre avait de la capacité disponible. Tous les clients passent désormais par le proxy ; les adresses propres aux machines ne servent plus qu'aux vérifications de santé.
  3. Des métriques qui sautaient. Notre moniteur de débit lisait les /metrics de vLLM via le proxy. Avec deux backends, les lectures consécutives provenaient de copies différentes, les compteurs faisaient des allers-retours et le moniteur signalait le modèle comme affamé. Il lit désormais chaque copie directement et additionne les résultats.
  4. Un ancien chien de garde qui aurait arrêté la nouvelle copie. La configuration divisée disposait d'un chien de garde qui redémarrait son service lorsque le modèle cessait de répondre. Le nouveau service déclare Conflicts= avec l'ancien, de sorte que ce redémarrage aurait arrêté la nouvelle copie. Il a fallu le désactiver avec systemctl disable --now, et pas seulement l'arrêter, car notre couche d'opérations redémarre les unités activées mais non exécutées.
  5. Mémoire sur la machine qui effectue d'autres tâches. Une machine exécute également nos bases de données et nos builds de sites. Avec gpu-memory-utilization de vLLM à 0,78, il ne restait que 5 GiB pour tout le reste, nous avons donc abaissé cette valeur à 0,74 et redémarré uniquement cette copie pendant que l'autre assurait la charge. Sur ces machines, la part de mémoire que vous attribuez à vLLM est prise sur la même mémoire que tout le reste.

Si vous disposez de deux de ces machines

Factory Floor fournit le proxy et les configurations de lancement pour une ou deux machines, le contrôle de santé et le test d'utilisation d'outils que nous exécutons avant de changer de modèle, avec les chiens de garde et les garde-fous mémoire pour le reste de la machine.

Mis à jour: