Le passage de l'anneau sur p4

Sept changements de contrat entre un moteur et son appelant. Chacun a échoué à sa manière, et un seul avait l'air d'une erreur.

Nous avons intégré une nouvelle release du moteur p4 et l'anneau a cessé de servir. Sans crash — le chargeur annonçait un succès, les agents annonçaient ready, et rien ne se passait. Remonter depuis ce silence a pris une journée et a mis au jour sept endroits où notre appelant et le moteur avaient divergé. Ce qui vaut qu'on les écrive n'est pas leur nombre. C'est que six des sept n'ont produit aucune erreur.

Échec un

Un événement destiné à un nœud inexistant est transmis, pas refusé

Notre chargeur adressait la commande LOAD au nœud qu'elle devait créer. Mais un nœud n'existe pas tant que LOAD ne l'a pas créé, et la règle du broker pour un événement nommant un nœud inconnu est de le transmettre vers l'extérieur plutôt que de le rejeter. La commande a quitté l'agent en cherchant un autre endroit où aller, n'en a trouvé aucun, et a été jetée. Aucune ligne de log, parce que du point de vue du broker rien n'avait mal tourné.

Le correctif a été d'adresser LOAD à l'*agent*, enveloppé dans l'enveloppe de cycle de vie du moteur, neutre vis-à-vis du backend, avec la commande propre à l'adaptateur en corps opaque. Évident après coup ; invisible de l'extérieur.

Échec deux

Un nombre qui doit être égal à un autre nombre

Un modèle se charge sous une load generation, et chaque nœud s'enregistre avec une node generation. Nous les traitions comme indépendantes — un timestamp pour l'une, 1 pour l'autre — et tout fonctionnait. L'anneau se chargeait. Il répondait correctement à une requête. Puis le nœud de tête mourait.

Le contrôle, dans la comptabilité de libération de l'adaptateur.

let Endpoint::Node { generation, .. } = &event.envelope.source;
if *generation != receipt.load_generation {
    return Err("release owner census generation differs from source");
}

Le reçu qui clôt une requête terminée porte la load generation, et le nœud qui l'envoie porte la sienne. Quand elles diffèrent, le nœud est arrêté. La forme du bug est donc : le chargement réussit, la première requête réussit, la tête meurt, et toutes les sessions suivantes restent bloquées à demi chargées. Cela se lit exactement comme un crash sous charge, et pas du tout comme un désaccord entre deux nombres. Notre chargeur refuse désormais un plan dont les deux nombres ne concordent pas, avant que quoi que ce soit ne soit chargé.

Échec trois

Une borne qui ne pouvait jamais être satisfaite

L'adaptateur compare le plus gros résultat qu'un stage peut renvoyer au chiffre que le stage server annonce à son démarrage, pour égalité exacte. Nous ne pouvions pas connaître ce chiffre sans charger le modèle — nous avons donc chargé avec une estimation, et l'échec nous a livré d'un coup les vrais nombres des quatre stages :

Une seule exécution, quatre réponses.

step37-s0: profile=33554432, READY=34419218444
step37-s1: profile=33554432, READY=34419218444
step37-s2: profile=33554432, READY=34419218444
step37-s3: profile=33554432, READY=59136012

34 GB. Les réserves retenues de l'agent font 256 MiB : cet anneau n'aurait jamais pu être admis. Lire la dérivation dans le stage server a montré pourquoi : la borne croît avec n_batch × n_ubatch, et nous avions hérité d'une largeur de batch de 2048 venue d'une configuration antérieure à ce contrôle. À 128 lignes — la largeur qu'utilise la disposition de production — la borne tombe à 138 MB et tient. Nous la dérivons désormais dans le plan à partir de la même formule au lieu de traîner une constante mémorisée, et le chiffre prédit pour le stage de queue est tombé exactement sur le nombre qu'un autre déploiement avait consigné — le genre d'accord qu'il vaut mieux obtenir avant de passer vingt minutes sur un chargement.

Les quatre autres

En bref

  • Des adresses loopback dans un anneau à deux hôtes. Un stage compose vers l'agent du stage suivant à l'adresse que cet agent annonce. Annoncez 127.0.0.1 et l'hôte A se compose lui-même. La configuration consignée de l'anneau était en loopback depuis le début — elle n'aurait jamais pu fonctionner entre hôtes.
  • Le journal est obligatoire. Un modèle ne se charge pas sans le journal opérationnel de l'agent. Nous l'avons désactivé en poursuivant une autre erreur, et avons aggravé le symptôme d'une manière qui ressemblait à un progrès.
  • Le nom de l'appareil dépend du backend. Le constructeur de plan de référence vise CUDA et émet --device CUDA0. Le build HIP nomme ses appareils ROCm0. Ce plan charge le modèle entier et *ensuite* ne trouve pas l'appareil.
  • Le serveur natif fait partie de la release. Un agent compilé depuis un arbre plus récent réclame des capacités que le stage server installé n'annonce pas. Découvert, là encore, après un chargement complet du modèle.
Ce que nous en retenons

Le silence est le mode de défaillance coûteux

Chacun de ces défauts était bon marché à corriger et cher à trouver, et le motif est constant : les échecs coûteux sont ceux où un système d'apparence correcte ne faisait rien, ou faisait quelque chose une seule fois. Les garde-fous que nous avons ajoutés ont tous la même forme — refuser tôt, à l'endroit où l'erreur est encore lisible. Le chargeur écrit la load generation sur disque *avant* que la première commande ne parte, parce qu'elle est autrement irrécupérable. Il refuse un désaccord de generation au lieu de le découvrir après la première requête. Il dérive la borne de résultat au lieu de s'en souvenir.

L'anneau sert. Quatre stages sur deux machines, 113 GiB de poids résidents, premier token en 1.4 s à froid et ~0.3 s à chaud, et la contribution par nœud qui remonte jusqu'au registre de règlement pour la première fois.