Qwen3.8 auf vier Blackwell-Karten: doppeltes Tempo, dreifacher Platz
Auf zwei Maschinen arbeiten je vier NVIDIA RTX PRO 4000 mit 24 GB, Blackwell, dazu eine dritte Maschine mit einer RTX PRO 4500 mit 32 GB für die Nacht. Kubernetes mit Flux, alles aus dem Git-Repo. Auf den großen Maschinen läuft Qwen3.8-27B: 27 Milliarden Parameter, Gewichte in NVFP4, in vier Scheiben über die Karten verteilt, gedient mit vLLM, mit einem Kontextfenster von 512k Tokens, per YaRN auf das Doppelte der trainierten Länge gestreckt. Davor sitzt ein LiteLLM-Proxy: mehrere Deployments hinter einem Modellnamen, und ein Gespräch landet nach Möglichkeit immer auf demselben Container. Der Grund ist der Prefix-Cache, der pro Container lebt: dieselbe Anfrage auf der „falschen" Maschine zahlt den vollen Prefill neu, bei gleichem Prompt gemessen 12,5 Sekunden statt 3,7. Das Modell trägt den Tag: Assistenz, Code, lange Dokumente, Cron-Jobs. Das kommerzielle Modell ist seit dem Frühjahr nur noch der Notfall.
Sonntagmorgen, sieben Uhr, ist mir zum ersten Mal aufgefallen, dass das bei großem Kontext nicht mehr rund läuft. Kurze Fragen kamen nach fünf Sekunden, lange Aufgaben nach einer halben Minute, und in der Frühschicht mit vierzehn parallelen Anfragen brachen einzelne nach einer Viertelstunde ab. Der Blick in die Messwerte zeigte, wo es hakt:
| Sonntag, 07:00 | gemessen |
|---|---|
| 133k Tokens einlesen (kalt) | 81 bis 84 s |
| Antworttempo, echte Anfrage | 71 Tokens/s |
| Leistungsaufnahme je Karte unter Last | 75 bis 85 W von 145 |
| Bandbreite zwischen den Karten | 7,6 GB/s |
Die dritte Zeile ist der Hinweis. Eine Karte, die rechnet, zieht ihre 145 Watt. Eine Karte, die auf die anderen drei wartet, zieht die Hälfte, und die Auslastungsanzeige steht trotzdem auf 100 Prozent, weil Warten für sie Arbeit ist. Vier Karten, die sich gegenseitig warten lassen, und eine einzelne Karte der nächstgrößeren Klasse war im Antworttempo schneller als alle vier zusammen. Das ist kein Rechenproblem, das ist ein Kommunikationsproblem. Zwei Tage Messen später, gleiche Hardware, kein Kartentausch: fast doppeltes Tempo und fünf Gespräche im Fenster statt eineinhalb. Der Weg hatte drei Stufen, fünf eigene Fehler und am Ende einen Test im Upstream-Projekt, der auf jeder Karte dieser Generation fehlschlägt, ohne dass es jemand sieht.
Wer den letzten Post gelesen hat: dort ging es darum, wie wir auf ein halbes Megatoken Kontext gekommen sind und was das kostet. Dieser Post fängt an, wo der aufhörte: bei einem Kontextfenster, das groß genug war, und einer Maschine, die darunter nicht mehr flüssig lief.
Sonntag: die vier Karten reden lassen
Erst das Referenzwerkzeug, dann die eigene Meinung. Der Standard-Bandbreitentest zwischen den Karten sagte 7,6 GB/s, und die Ursache stand im selben Log: die Karten hängen alle am Prozessor, ohne Switch dazwischen, und die Kommunikationsbibliothek hält diese Topologie für zu weit für direkte Karte-zu-Karte-Transfers. Sie schickt alles über den Arbeitsspeicher. Eine Umgebungsvariable erlaubt den direkten Pfad, und derselbe Test sagte danach 40,1 GB/s. Faktor fünf, eine Zeile.
Danach die Engine auf die neue Version mit einem fusionierten Decode-Kernel für genau diese Modellfamilie (die für sich allein erst mal langsamer war, 73 statt 88, ein Effekt, den ich bis heute nicht sauber erklären kann), dann der Attention-Kernel gewechselt und spekulatives Decoding vermessen, mit Tiefe vier als bester Stufe. Zum Schluss asynchrones Scheduling, plus 15 Prozent, gratis. Jede Stufe einzeln, jede gegen dieselben Prompts (P2P ist der direkte Kartenpfad, MTP das spekulative Decoding):
| Sonntag, gleiche Prompts | 07:00 | 15:00 | Faktor |
|---|---|---|---|
| 133k Tokens einlesen (kalt) | 81 bis 84 s | 35 bis 37 s | 2,2 |
| Antworttempo, echte Anfrage | 71 Tokens/s | 132 bis 140 Tokens/s | 1,9 |
| Bandbreite zwischen den Karten | 7,6 GB/s | 40 GB/s | 5,3 |
| Leistungsaufnahme je Karte | 75 bis 85 W | 145 bis 148 W | am Limit |
| Platz für Kontext | 1,45 M Tokens | 0,88 M Tokens | 0,6 |
Die letzte Zeile ist der Haken. Der schnellere Attention-Kernel kann den Kontext nur in voller Breite ablegen, und das kostete 40 Prozent des Platzes. Am Sonntagabend hatte ich also eine Maschine, die doppelt so schnell antwortet und weniger Gespräche gleichzeitig hält als vorher. Zwölf gleichzeitige echte Anfragen liefen trotzdem ohne Verdrängung durch, für den Alltag reichte es. Aber es war ein Tausch, kein Gewinn, und der Montag hatte genau eine Aufgabe: den Platz zurückholen, ohne das Tempo wieder herzugeben.
Was die Begriffe heißen
Kontext-Speicher: das Fach, in dem ein Modell ablegt, was es vom bisherigen Gespräch verstanden hat, während es schreibt. Größeres Fach heißt: mehrere lange Gespräche laufen gleichzeitig, statt dass sie sich in die Warteschlange stellen. Ich schreibe im Folgenden KV-Cache dafür, so wie es in jedem Log dieses Fachs heißt.
Kompakte Ablage: eine Speichersparstufe für genau dieses Fach. Die bekannten Stufen heißen fp8 und nvfp4, nach der Zahl ihrer Bits pro Wert. Die Gewichte des Modells liegen schon länger in NVFP4; neu ist, dass jetzt auch der KV-Cache in dem Format liegt. Ich benutze die Namen so, wie sie in den Logs stehen.
Die Zahlen
Gemessen am 8. September, jeweils mit identischen Anfragen auf derselben Maschine, nur die Ablagestufe geändert. Die Tabelle zeigt das Fach in Tokens und daneben, wie viele Sessions mit dem vollen Fenster (512k Tokens, ein halbes Buchregal pro Gespräch) gleichzeitig reinpassen:
| Ablagestufe | Tokens im Fach | Sessions gleichzeitig |
|---|---|---|
| bf16 (die vorige Config) | 874.948 | 1,67 |
| fp8 | 1.685.622 | 3,21 |
| nvfp4 | 2.914.298 | 5,56 |
Auf dem kleinen Node mit 32 GB dasselbe Bild im kleineren Maßstab: 239.693 über 387.658 Tokens, also 1,69 parallele Sessions im 224k-Fenster statt 1,04.
Das Antworttempo bleibt dabei stehen: 124 bis 126 Tokens pro Sekunde auf den großen Maschinen, gegen 129 mit fp8. Der Platz hat sich gegen Sonntagmorgen verdreifacht, das Tempo fast verdoppelt, und beides gleichzeitig war am Sonntagabend noch nicht drin.
Der volle Kontext bleibt korrekt: bei 153k Tokens Promptlänge sitzt der Merksatz in allen Läufen an der richtigen Stelle und wird fehlerfrei zitiert, auf allen drei Maschinen. Das ist die Abnahme, die über „passt halt mehr" entscheidet.
Warum aus halbiertem Speicher keine doppelte Kapazität wird
Qwen3.8 ist ein Hybrid: 16 seiner 64 Schichten arbeiten mit dem normalen Attention-Aufbau, die anderen 48 (Gated DeltaNet) führen pro Gespräch einen Zustandsspeicher fester Größe mit. Dieser Zustandsspeicher bleibt in voller Breite stehen, die Kompaktstufe gilt nur für den ersten Teil. Dazu kommt pro Block ein kleiner Skalierungswert, der nicht mitkomprimiert wird. Deshalb landet man bei +62 bis +73 Prozent statt bei +100. Meine Rechnung vom Abend hatte die Halbierung auf das ganze Fach angewendet, und das war schlicht zu gut gedacht.
Für den kleinen Node heißt das konkret: 512k passen nicht, und zwar knapp. Die Engine sagt beim Start, das Fenster brauche 10,07 GiB, nach den Gewichten bleiben 7,99 GiB. Es fehlt also ein Fünftel. Das ist keine Tuning-Frage, das ist die Karte.
Montag: ein Tag, 27 Commits
Der Montag ist im Ops-Repo nachlesbar, und die Commit-Zeiten erzählen ihn ehrlicher als ich es aus dem Kopf könnte. Er hat drei Stufen.
Stufe eins, 04:34 bis 06:18. Nachts die Zwischenstufe fp8 auf den großen
Maschinen, gegen den bisherigen Stand gemessen: Pool +93 Prozent, 4966 Anfragen in
einer Stunde ohne einen einzigen Fehler (e56ef7de). Auf der kleinen Karte dieselbe
Umstellung, dort ohne Gewinn, also wieder zurück (1aa87871). Das ist der Punkt,
an dem eine Config für eine Kartenklasse nicht automatisch für die andere gilt.
Bei der Ablagestufe hängt das am Attention-Backend, und das ist uns zweimal über den Weg gelaufen. Der schnelle Attention-Kernel vom Sonntag lehnt fp8 im Cache nämlich ab, im Log mit dem fertigen Hinweis: das Format verlangt neuere Attention-Kernel, als diese Kartengeneration hat, „FP8 KV cache requires FA3 on SM90 or FA4 on SM100". Die beiden anderen Backends, FlashInfer und Triton, akzeptieren es ohne Weiteres, die Karte hat die fp8-Tensorcores, die Lücke ist in den Attention-Kernels, nicht auf der Platine. Ende August ist die Kombination fp8 plus FlashInfer auf der alten Engine das erste Mal vollständig durch die Abnahme, mit dem Umstieg auf vLLM 0.28 sitzt sie dann auf allen drei Maschinen und trägt bis heute.
Stufe zwei, 07:39 bis 08:58. Die Frage zum zweiten Schritt: Die Gewichte
liegen seit dem Frühjahr in NVFP4, die Karten kennen das Format, warum nicht auch
den Cache? Nach unten war das Thema offen: für unsere Kartengeneration ist die
Ablagestufe upstream nicht freigeschaltet, für die großen
Rechenzentrums-Karten von NVIDIA längst. Auf den großen Maschinen bringt nvfp4
gegen fp8 noch mal +73 Prozent Pool, auf der kleinen +62, gemessen unten in der
Tabelle. Der Versuch, die kompakte Stufe mit einem
Sieben-Zeilen-Patch aus einem offenen Pull Request freizuschalten: Start läuft
durch, die erste echte Anfrage stirbt an einer Zusicherung im Decode-Pfad
(d5f0770a). Danach das eigene Image, auf der Release-Version des Containers,
mit vier Patches aus einem fremden Repo (MIT, Attribution liegt bei) und einem
Kernel-Overlay für unsere Kartengeneration. Drei Builds bis zum ersten grünen:
der erste verlor Dateien zwischen zwei Build-Schritten (701655dd), der zweite
kopierte Symlinks statt Inhalte (08e198e8). Das Dockerfile prüft sich seitdem
selbst, jede Stufe mit einem Marker:
OVERLAY-BUILT vllm_sm12x_nvfp4kv.so
torch.ops.vllm_sm12x.reshape_and_cache_nvfp4 registered
NVFP4KV-SM120 IMAGE OK (vllm 0.28.0)
Stufe drei, 10:06 bis 12:14. Gestaffelt auf die drei Maschinen, jede mit
derselben Abnahme (277b7a7d, 513ae208, 1fdf8129): Marker im Log, Pool,
Antworttempo, Merksatz bei 51k, 107k und 153k. Erst wenn eine Maschine alles
bestanden hatte, kam die nächste dran. Zwischendurch der Versuch, die kleine Karte
auf dasselbe Fenster zu heben wie die großen: abgelehnt von der Engine
(1d502b7a), dann ein kleineres Fenster, das bei 153k Müll lieferte, und zurück
auf den bewiesenen Stand (afbb6c49). Der Fehler ist noch offen; ich weiß erst,
dass er da ist, nicht warum.
Fünf eigene Fehler
Aus zwei Tagen die fünf, die ich selbst gebaut hatte. Der fünfte steht weiter unten bei dem Test.
Am Sonntag stand am Ende des Startbefehls eine zweite Zeile für das spekulative Decoding, eine Altlast. Die Engine nimmt die letzte, also liefen alle drei Stufen meines Vergleichs mit derselben Tiefe, und ich hatte daraus „bringt nichts" abgeleitet. Falsch, und zwar wegen einer Zeile, die ich nicht gelesen hatte. Seit dem Nachmittag druckt das Abnahmeskript die wirksame Tiefe aus dem Engine-Log, bevor eine Messung zählt.
Ein Kommentarblock im Startbefehl der Container schnitt die Zeilenkette ab. Neun von zweiundzwanzig Startoptionen waren nach dem ersten Rollout tatsächlich gesetzt, der Rest lag still auf Defaults. Bei der Abnahme fielen zwei Antworten kürzer aus als gewohnt, und der Blick in die Prozess-Commandline zeigte es sofort. Seither steht im Abnahmeskript: Anzahl der Optionen zählen, Kommentare in der Kette suchen, bevor irgendetwas anderes gemessen wird.
Ich hatte die Größe des Fachs auf der kleinen Karte am Abend vorher auf etwa 560k Tokens geschätzt und dabei vergessen, dass der Zustandsspeicher in voller Breite bleibt. Gemessen wurden 388k. Eine Schätzung, die wie eine Messung behandelt wird, ist der teuerste Fehler in dieser Liste, weil ihr niemand widerspricht.
Und während der letzten Abnahme zog der Streaming-Scheduler das Modell mitten im Test von der Karte, weil der Stream anging. Der Container war weg, und „Merksatz nicht gefunden" war deshalb kein Testfehler, sondern ein fehlender Prüfling. Seitdem prüfe ich vor jeder Messung auf diesem Node, ob gerade Stream ist. Die Lektion ist banal und hat trotzdem einen kompletten Durchlauf gekostet. Der Durchlauf danach war der, der zählt.
Was jetzt läuft
Seit diesem Durchlauf tragen die großen Maschinen den Tag über den ganzen Verbund: fünf Sessions pro Maschine, 512k Fenster, Antwortzeiten um die zwei Zehntelsekunden für kurze Fragen. Der kleine Node ist nachts dran, wenn die anderen schlafen, mit seinem 224k-Fenster und zwei Sessions. Ein Blick ins Protokoll der letzten Stunde: null Neustarts, null Verdrängungen, und über den Proxy kamen sechzehn von sechzehn Antworten vollständig zurück, von allen drei Maschinen.
Upstream ist das Thema offen: die Freischaltung dieser Ablagestufe für unsere Kartengeneration liegt in einem offenen Pull Request, die Korrektur des Schreibpfads in einem zweiten. Mein Image legt beide auf die Release-Version des Containers. Wenn die Upstream-Pfade merged sind, fällt das eigene Image weg und die Config bleibt. Damit das passiert, muss das Projekt unsere Zahlen kennen, und beim Zurückgeben ist mir der fünfte Fehler passiert.
Der Test, den niemand laufen ließ
Bevor ich die Zahlen ins Upstream-Projekt zurückgebe, wollte ich den Schreibpfad mit dem Test des Projekts selbst rot und grün sehen: rot mit dem Original-Kernel, grün mit der Korrektur. Der Test existiert, er heißt so, wie man ihn suchen würde, und er lief in dem Container, der gerade meine Anfragen beantwortet, in vier Sekunden. Rot, wie erwartet. Nur an der falschen Stelle:
Mismatched elements: 989 / 10752 (9.2%)
Greatest absolute difference: nan at index (7, 6, 48)
Das war die Prüfung des ersten der beiden Speicherbereiche, nicht des zweiten, in dem der bekannte Fehler sitzt. Der Kernel legt die Skalierungswerte des ersten Bereichs linear ab, auf jeder Kartengeneration. Die Referenz des Tests entwirrt beide Bereiche so, als wären sie verschränkt abgelegt, und vergleicht den ersten damit gegen eine durchgewürfelte Vorlage. Der Test ist auf jeder Karte dieser Generation rot, mit oder ohne den bekannten Fehler, mit oder ohne Korrektur.
Und niemand sieht es, weil ihn niemand ausführt: er überspringt sich unterhalb dieser Kartengeneration, und der eine CI-Job, der auf so einer Karte läuft, schließt genau dieses Verzeichnis aus.
Ich habe die Referenz repariert, in drei Testdateien, ohne den Kernel anzufassen, und den Beweis mit fünf Läufen aus dem Container dazugelegt: alte Referenz rot an der falschen Stelle, neue Referenz mit Original-Kernel rot an der richtigen, neue Referenz mit Korrektur grün. Das ist der Pull Request. Dazu die Messwerte von oben als Kommentare unter die beiden offenen Pull Requests, die den eigentlichen Fix tragen. Was ich nicht habe, steht auch drin: eine Karte der Rechenzentrumsklasse zum Gegentesten.
Was ich mitnehme
Zwei Tage, in denen jede Zahl, die ich vorher geschätzt hatte, falsch war, und jede Zahl, die ich gemessen habe, gehalten hat. Die Schätzung vom Sonntagabend über das Fach auf der kleinen Karte, die Annahme, das spekulative Decoding bringe nichts, die Vermutung heute Mittag, der Upstream-Test sei blind statt rot. Alle drei klangen plausibel, alle drei haben eine Messung nicht überlebt.
Das Modell antwortet jetzt doppelt so schnell und hält fünf Gespräche statt eineinhalb. Das ist das Ergebnis. Die Methode, die es gebracht hat, war langweiliger: erst das Referenzwerkzeug, dann eine Variable pro Schritt, dann die Abnahme mit dem Merksatz bei 153k Tokens, jedes Mal, bevor irgendetwas als fertig galt. Ohne den Merksatz hätte ich heute eine kleine Karte mit größerem Fenster und Müll ab 150k Tokens, und niemand hätte es gemerkt, bis ein langes Gespräch kippt.