Den Ring auf p4 umziehen

Sieben Vertragsänderungen zwischen einer Engine und ihrem Aufrufer. Jede scheiterte anders, und nur eine davon sah wie ein Fehler aus.

Wir haben ein neues Release der p4-Engine gemerged, und der Ring hörte auf zu bedienen. Nicht mit einem Absturz — der Loader meldete Erfolg, die Agents meldeten ready, und nichts geschah. Aus dieser Stille zurückzuarbeiten kostete einen Tag und förderte sieben Stellen zutage, an denen unser Aufrufer und die Engine auseinandergedriftet waren. Aufschreibenswert macht sie nicht die Anzahl. Sondern dass sechs der sieben keinen Fehler erzeugten.

Fehler eins

Ein Event für einen nicht existierenden Node wird weitergeleitet, nicht abgelehnt

Unser Loader adressierte das LOAD-Kommando an den Node, den es erzeugen wollte. Aber ein Node existiert erst, wenn LOAD ihn erzeugt, und die Regel des Brokers für ein Event, das einen unbekannten Node nennt, lautet: nach außen weiterleiten statt ablehnen. Das Kommando verließ den Agent auf der Suche nach einem anderen Ziel, fand keines und wurde verworfen. Keine Logzeile, denn aus Sicht des Brokers war nichts schiefgegangen.

Die Lösung war, LOAD an den *Agent* zu adressieren, verpackt in die backend-neutrale Lifecycle-Hülle der Engine, mit dem eigenen Kommando des Adapters als opakem Body. Im Nachhinein offensichtlich; von außen unsichtbar.

Fehler zwei

Eine Zahl, die einer anderen Zahl gleichen muss

Ein Modell wird unter einer Load-Generation geladen, und jeder Node wird mit einer Node-Generation registriert. Wir hatten die beiden als unabhängig behandelt — ein Zeitstempel für die eine, 1 für die andere — und alles funktionierte. Der Ring lud. Er beantwortete eine Anfrage korrekt. Dann starb der Kopf-Node.

Die Prüfung, in der Release-Verbuchung des Adapters.

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

Die Quittung, die eine abgeschlossene Anfrage schließt, trägt die Load-Generation, und der Node, der sie sendet, trägt seine eigene. Weichen sie ab, wird der Node gestoppt. Die Gestalt des Bugs ist also: Load gelingt, erste Anfrage gelingt, der Kopf stirbt, jede Session danach hängt halb geladen. Das liest sich exakt wie ein Absturz unter Last und überhaupt nicht wie eine Abweichung. Unser Loader verweigert jetzt einen Plan, dessen zwei Zahlen sich widersprechen, bevor irgendetwas geladen wird.

Fehler drei

Eine Schranke, die nie erfüllbar war

Der Adapter vergleicht das größte Ergebnis, das eine Stage zurückgeben darf, auf exakte Gleichheit mit der Zahl, die der Stage-Server beim Hochfahren meldet. Diese Zahl konnten wir ohne Laden des Modells nicht kennen — also luden wir mit einer Schätzung, und der Fehlschlag nannte uns die echten Zahlen aller vier Stages auf einmal:

Ein Lauf, vier Antworten.

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. Die vorgehaltenen Speicher des Agents sind 256 MiB, dieser Ring hätte also nie zugelassen werden können. Die Herleitung aus dem Stage-Server zeigte, warum: Die Schranke skaliert mit n_batch × n_ubatch, und wir hatten eine Batch-Breite von 2048 aus einer Konfiguration geerbt, die älter war als diese Prüfung. Bei 128 Zeilen — der Breite, die das Produktions-Layout nutzt — liegt die Schranke bei 138 MB und passt. Wir leiten sie jetzt im Plan aus derselben Formel ab, statt eine gemerkte Konstante mitzuschleppen, und die vorhergesagte Zahl der Schluss-Stage traf exakt den Wert, den ein anderes Deployment aufgezeichnet hatte — die Art von Übereinstimmung, die man gern hat, bevor man zwanzig Minuten in einen Load steckt.

Die anderen vier

In Kürze

  • Loopback-Adressen in einem Zwei-Host-Ring. Eine Stage wählt den Agent der nächsten Stage unter der Adresse an, die dieser Agent bewirbt. Bewirb 127.0.0.1, und Host A wählt sich selbst an. Die aufgezeichnete Konfiguration des Rings war die ganze Zeit Loopback — sie hätte über Hosts hinweg nie funktionieren können.
  • Das Journal ist Pflicht. Ein Modell lädt nicht ohne das Betriebsjournal des Agents. Wir schalteten es ab, während wir einem anderen Fehler nachjagten, und verschlimmerten das Symptom auf eine Weise, die nach Fortschritt aussah.
  • Der Gerätename ist backend-spezifisch. Der Referenz-Plan-Builder zielt auf CUDA und gibt --device CUDA0 aus. Der HIP-Build nennt seine Geräte ROCm0. Dieser Plan lädt das gesamte Modell und findet *dann* das Gerät nicht.
  • Der native Server gehört zum Release. Ein Agent aus einem neueren Baum will Capabilities, die der installierte Stage-Server nicht meldet. Ebenfalls erst nach einem vollständigen Modell-Load entdeckt.
Was wir daraus mitgenommen haben

Stille ist der teure Fehlermodus

Jeder dieser Punkte war billig zu beheben und teuer zu finden, und das Muster ist durchgängig: Die kostspieligen Fehlschläge waren die, bei denen ein korrekt aussehendes System nichts tat — oder etwas genau einmal tat. Die Wächter, die wir eingebaut haben, haben alle dieselbe Gestalt: früh ablehnen, an der Stelle, an der der Fehler noch lesbar ist. Der Loader schreibt die Load-Generation auf die Platte, *bevor* das erste Kommando hinausgeht, weil sie sonst unwiederbringlich ist. Er verweigert eine abweichende Generation, statt sie nach der ersten Anfrage zu entdecken. Er leitet die Ergebnis-Schranke ab, statt sie sich zu merken.

Der Ring bedient. Vier Stages über zwei Maschinen, 113 GiB Gewichte resident, erstes Token nach 1.4 s kalt und ~0.3 s warm, und der Beitrag je Node fließt zum ersten Mal bis ins Abrechnungs-Ledger durch.