Zwei Modelle auf einem DGX Spark: 79 und 112 Token/s

· Mag. Michael Allerstorfer · English version

allsmartllm.com läuft nicht auf gemieteten Cloud-GPUs, sondern auf vier NVIDIA DGX Spark in unser eigenen Infrastruktur. Eine davon, bei uns spark-3, betreibt seit September 2026 zwei Sprachmodelle nebeneinander: Qwen3.6-35B-A3B als Allzweckmodell und Nemotron 3.5 Lightning als schnelles Modell daneben.

Dies ist ein Betriebsbericht. Er beschreibt die Konfiguration, die heute läuft, wie wir dorthin gekommen sind und wo wir unterwegs falsch lagen. Die Irrwege haben wir bewusst dringelassen. Wer selbst einen DGX Spark betreibt, findet sie vermutlich nützlicher als die Teile, die funktioniert haben.


Ergebnisse

Qwen3.6-35B-A3B Nemotron 3.5 Lightning
Gewichte NVFP4 für die MoE-Experten, FP8 für die übrigen Schichten NVFP4, dazu DSpark-Draft-Modell
Einzelstrom, Decode, anderes Modell ohne Last 78,6 Token/s 111,7 Token/s
8 gleichzeitige Anfragen, gesamt, anderes Modell ohne Last 263,7 Token/s 293,5 Token/s
8 gleichzeitige Anfragen, gesamt, anderes Modell unter gleicher Last 107,7 Token/s 149,6 Token/s
Kontext 262.144 Token 262.144 Token
Anteil am GPU-Speicher 0,45 0,28
Abstürze mit der aktuellen Konfiguration keine seit 27.09.2026 keine seit 06.09.2026

Die Durchsatzzeilen sind am 19.10.2026 mit kurzen Prompts gemessen (siehe unten). Beide Modelle teilen sich einen Speicherbus. Sind beide gleichzeitig ausgelastet, behält Qwen etwa 40 % und Nemotron etwa die Hälfte dessen, was es allein schafft.

Zum Vergleich: Am 05.09.2026 erreichte Qwen3.6-35B in FP8 mit vLLM 0.18 im Einzelstrom 52,6 Token/s. Das war keine Messung auf einer sonst leeren Maschine. Damals war daneben noch ein drittes Modell geladen, LFM2.5-8B.


Wie wir messen

Alle Durchsatzwerte in diesem Artikel stammen aus demselben eigenen Skript. Es läuft direkt auf dem DGX Spark gegen den OpenAI-kompatiblen Endpunkt von vLLM, nicht über unser Gateway. Das Skript ist hier veröffentlicht: bench.py.

  • Die Anfragen werden gestreamt (SSE). Reasoning-Token und Antwort-Token werden gemeinsam gezählt.
  • Ausgabe: bis zu 512 Token pro Anfrage, temperature 0, Denkphase aktiv. Nach 512 Token sind die Modelle noch in der Denkphase, die Werte beschreiben also Reasoning-Ausgabe.
  • Eingabe: zehn kurze deutsche Prompts aus unserem echten Betrieb (Code, SQL, Fehlerdiagnose, Erklärungen) mit je 39 bis 72 Eingabe-Token. Ein Einzelstrom-Lauf nutzt den ersten Prompt (48 Token), ein Lauf mit 8 gleichzeitigen Anfragen die ersten acht.
  • Einzelstrom: reine Decode-Rate, berechnet als (erzeugte Token − 1) / (Zeit des letzten Chunks − Zeit des ersten Chunks). Prefill und Zeit bis zum ersten Token sind nicht enthalten.
  • Gleichzeitige Anfragen: Gesamtdurchsatz, alle erzeugten Token geteilt durch die Gesamtdauer, Prefill also eingeschlossen.
  • Jede Konfiguration läuft dreimal, angegeben ist der Median.
  • „Anderes Modell ohne Last“ heißt: keine Benchmark-Last auf dem zweiten Modell. Dessen Anfragezähler haben wir vor und nach jedem Lauf gelesen: Während der Qwen-Läufe bekam Nemotron keine anderen Anfragen, während der Nemotron-Läufe bekam Qwen 2. Für die älteren Messungen vom 06.09. und 27.09. haben wir nachträglich die Zugriffslogs geprüft: Das andere Modell bekam in den Messfenstern keine Anfragen.

Kurze Prompts sind der günstige Fall. Echte Anfragen bringen oft Tausende Token Kontext mit, und dann bestimmt der Prefill die Wartezeit. Lange Eingaben haben wir deshalb getrennt gemessen, siehe „Lange Eingaben“ weiter unten.

Eine Lehre zum Messen über ein Gateway: Unser nächtlicher Benchmark läuft über den gesamten Anfrageweg. Im Oktober zeigte er eine Woche lang für beide Modelle auf spark-3 nur den halben Durchsatz. Direkt auf der Maschine gemessen hatte sich nichts verändert. Im nächtlichen Messfenster liefen andere Anfragen parallel. Ein Benchmark über ein gemeinsam genutztes Gateway misst die Gesamtlast, nicht die Maschine.


Die Maschine, und warum sie sich anders verhält

Ein DGX Spark hat einen GB10-Chip mit 128 GB Speicher, den sich CPU und GPU teilen. CUDA sieht davon 121,7 GiB. Die Speicherbandbreite liegt bei etwa 273 GB/s. Gemessene Speicherwerte stehen in diesem Artikel in GiB, so wie free und nvidia-smi sie liefern (umgerechnet aus MiB). Herstellerangaben und Dateigrößen bleiben in GB. Zwei Eigenschaften bestimmen alles Weitere:

  1. Es gibt keinen getrennten GPU-Speicher. Was vLLM für Gewichte und KV-Cache reserviert, fehlt dem Betriebssystem. Läuft der Speicher voll, kommt keine Fehlermeldung. Die Maschine friert ein und muss hart neu gestartet werden. Das ist uns auf einer anderen Spark passiert, bevor wir einen Watchdog hatten.
  2. Die Bandbreite ist der Engpass. Für jedes erzeugte Token müssen die aktiven Gewichte einmal durch den Speicher. Deshalb sind Mixture-of-Experts-Modelle mit wenigen aktiven Parametern auf der Spark viel schneller als dichte Modelle ähnlicher Gesamtgröße. Und deshalb bremsen sich zwei Modelle auf einer Maschine gegenseitig, sobald beide arbeiten.

Wohin der Speicher geht

Die beiden Werte für --gpu-memory-utilization ergeben zusammen 0,73 von 121,7 GiB, also 88,8 GiB. free zeigt mehr als belegt an. Ein Schnappschuss vom 19.10.2026, beide Modelle ohne Last:

GiB
Belegt laut free 104,5
Beide vLLM-Prozesse laut nvidia-smi (Qwen 52,9, Nemotron 36,7) 89,7
Normaler Prozessspeicher (AnonPages: Python, Docker, Systemdienste) 10,3
Kernel (Slab, PageTables, KernelStack) 1,5
Nicht zugeordnet 3,1
Verfügbar laut free 17,1

Die vLLM-Prozesse belegen etwas mehr als ihren eingestellten Anteil (89,7 statt 88,8 GiB), weil CUDA-Kontext und kompilierte Graphen dazukommen. Der Rest ist normaler Prozessspeicher, den jeder vLLM-Prozess auch auf der CPU-Seite braucht. Der Seitencache zählt nicht als belegt. Der Kernel kann ihn freigeben, deshalb ist „verfügbar“ größer als „frei“. Weitere 4,5 GiB Prozessspeicher lagen zum Zeitpunkt des Schnappschusses im Swap.

Betriebsregeln

  • Mindestens 12 GiB verfügbar halten, besser 15. Auf spark-3 sind es heute 17,1 GiB.
  • Jede neue Konfiguration beim ersten Start unter einem Watchdog laufen lassen (unten beschrieben).
  • Nur free und nvidia-smi trauen. Am 19.10. zeigte docker stats für den Qwen-Container 7,7 GiB, nvidia-smi für denselben Prozess 52,9 GiB. CUDA-Speicher im Unified Memory taucht in der cgroup-Abrechnung des Containers nicht auf.
  • Proaktive Speicherverdichtung abschalten: vm.compaction_proactiveness = 0. Auf unseren beiden anderen Sparks, die ein Modell mit Tensor-Parallelität über beide Maschinen betreiben, zerfiel die Decode-Geschwindigkeit nach 28 Tagen Laufzeit erstmals in schnelle und langsame Läufe und fiel nach 43 Tagen von 48 auf 34 Token/s. Die Ursache fanden wir, indem wir bei jedem Benchmark-Lauf die CPU-Zeit des Kernel-Threads kcompactd0 mitschrieben: Jeder langsame Lauf fiel damit zusammen, dass kcompactd0 auf einer der beiden Maschinen fast 100 % lief. Er versuchte endlos und meist erfolglos, Speicherseiten zu verschieben, die von der GPU festgehalten werden. Mit dem Wert 0 lagen alle zehn Testläufe wieder bei 47 bis 48 Token/s. spark-3 zeigte in seinen Zählern dieselbe Vorgeschichte und bekam dieselbe Einstellung.

Der Watchdog

Der Watchdog ist ein kleines Shell-Skript, das neben dem ersten Start einer neuen Konfiguration läuft. Jede Sekunde liest es MemAvailable und die Swap-Belegung aus /proc/meminfo und schreibt beides in ein Log. Es beendet den Container mit docker kill, ohne Schonfrist, sobald eines von zwei Dingen passiert:

  • MemAvailable fällt unter 12.000 MiB, oder
  • die Swap-Belegung wächst um mehr als 8.000 MiB gegenüber dem Wert beim Start.

Die zweite Bedingung ist wichtig, weil sich Speicherdruck auf dieser Maschine oft zuerst im Swap zeigt, während MemAvailable noch gut aussieht. Standardmäßig läuft er 90 Minuten, lang genug für Laden, Kompilieren und einen ersten Benchmark. Skript: vllm-start-watchdog.sh.

Unsere erste Version prüfte alle fünf Sekunden und stoppte den Container geordnet. Das war einmal zu langsam, deshalb prüft die heutige Version jede Sekunde und beendet hart.


Etappe 1: von 52 auf 78 Token/s, nach einem Fehlschluss

Unsere erste Recherche zu NVFP4 (4-Bit-Gewichte statt 8 Bit) kam zu dem Ergebnis, dass es auf der Spark langsamer sei als FP8. Ein fremder Benchmark zeigte −32 %, und die technische Erklärung klang plausibel: eine fehlende Instruktion im Kompilierziel und ein Umweg über BF16.

Für unseren Fall war das falsch. Einen Tag später probierten wir es trotzdem, mit zwei Unterschieden: NVIDIAs Checkpoint mit gemischter Genauigkeit (NVFP4 für die Experten, FP8 für die übrigen Schichten) und vLLM 0.28 statt 0.18. Gemessen am 05.09.2026:

FP8, vLLM 0.18 NVFP4, vLLM 0.28
Einzelstrom 52,6 Token/s 77,9 Token/s (+48 %)
8 gleichzeitig 150,9 Token/s 261,7 Token/s (+74 %)
Speicher belegt auf der Maschine etwa 96 GiB etwa 81 GiB

Beide Spalten sind gemessen, während LFM2.5-8B (0,16) daneben geladen war und keine Last hatte. Die Speicherzeile gilt für die ganze Maschine zu diesem Zeitpunkt: Qwen mit 0,55 (FP8) bzw. 0,45 (NVFP4), dazu LFM. LFM wurde einen Tag später abgeschaltet, um Platz für Nemotron zu machen.

Keine der beiden Änderungen reichte allein. vLLM 0.28 mit dem FP8-Checkpoint war langsamer als 0.18 (−5 % im Einzelstrom, −17 % bei 16 gleichzeitigen Anfragen). Die fremde Quelle, die 97 Token/s für NVFP4 versprochen hatte, nannte für FP8 28 bis 30 Token/s, wir hatten 52,6 gemessen. Ihre Basis passte nicht zu unserer. Erst die eigene Basis messen, dann mit fremden Zahlen vergleichen.

Eine verwandte Falle: Die Logzeile, die das gewählte Kernel-Backend nennt (TRITON, DEEPGEMM, MARLIN), sagte auf dieser Hardware fast nichts über die Geschwindigkeit. Wir haben daraus zweimal den falschen Schluss gezogen, einmal zu optimistisch, einmal zu pessimistisch.


Etappe 2: Der größte Qualitätshebel hat mit Geschwindigkeit nichts zu tun

Qwen3.6 denkt nach, bevor es antwortet. Ohne Grenze denkt es so lange, dass die meisten Antworten nie ankommen: Das ganze Token-Budget geht in die Denkphase.

vLLM kann die Denkphase pro Anfrage begrenzen (thinking_token_budget). Gemessen haben wir das am 19.10.2026 auf der Konfiguration, die heute läuft (NVFP4, vLLM 0.28): dieselben zehn deutschen Prompts, max_tokens 2048, temperature 0, ohne Streaming. „Vollständig“ heißt, das Modell hat die Antwort selbst beendet, statt an max_tokens zu stoßen. Die Sprache haben wir mit einer einfachen Funktionswort-Heuristik auf den ersten 300 Zeichen jeder Antwort bestimmt.

Budget gar keine Antwort vollständige Antwort Antwort in falscher Sprache
ohne 7/10 1/10 entfällt
512 0/10 7/10 6/10
768 0/10 7/10 2/10
1536 0/10 1/10 0/10

Die Zeile 1536 sieht in der Sprachspalte gut aus, aber 9 von 10 Antworten wurden abgeschnitten: Nach 1536 Token Denkphase bleiben nur etwa 500 für die Antwort. 512 ist am schnellsten, aber in 6 von 10 Fällen wechselte das Modell ins Englische. 768 ist der Kompromiss, den wir nutzen, auch wenn bei max_tokens 2048 noch 3 von 10 Antworten abgeschnitten wurden. Im Betrieb setzt unser Gateway für diese beiden Modelle kein max_tokens; das entscheidet der Aufrufer, und ohne Wert lässt vLLM Ausgaben bis zum Rest des Kontexts zu. Unsere erste Messreihe, am 05.09. mit FP8 und vLLM 0.18, wies in dieselbe Richtung: 5 von 10 Antworten in falscher Sprache bei 512, 2 von 10 bei 768.

Die Heuristik ist grob. Auf den FP8-Daten vom 05.09. reproduziert sie alle dokumentierten Werte bis auf einen (1536: 4 statt 3). Die Sprachspalte ist als Tendenz zu lesen, nicht als genaue Quote.

Zum Vergleich: NVIDIAs Modellkarte zum NVFP4-Checkpoint nennt für MMLU Pro 85,0 gegenüber 85,6 bei BF16. Das Denk-Budget hat den Anteil der Prompts ohne Antwort von 7 von 10 auf null gebracht. Wie das Modell denken darf, war für uns weit wichtiger als das Zahlenformat.

Unser Gateway schickt Nemotron dasselbe Budget, 768. Eine Budget-Messreihe für Nemotron haben wir nicht gemacht.

Zwei praktische Hinweise. Einen serverseitigen Standardwert für diesen Wert haben wir in den geprüften vLLM-Versionen nicht gefunden: thinking_token_budget muss mit jeder Anfrage mitgeschickt werden, das erledigt unser Gateway. Und der Server muss mit --reasoning-config gestartet sein, sonst lehnt er das Feld ab.


Etappe 3: ein zweites Modell auf derselben Maschine

Mit NVFP4 brauchte Qwen nur noch 0,45 des Speichers. Damit war Platz für ein zweites Modell.

Wir haben Nemotron 3.5 Lightning gewählt (30B gesamt, 3B aktiv, ein Hybrid aus Mamba-2 und MoE), weil NVIDIA dafür ein DSpark-Draft-Modell liefert, das eigens für die DGX Spark gebaut ist. Das Draft-Modell schlägt drei Token im Voraus vor, das Hauptmodell prüft sie in einem Durchgang. Token, die das Hauptmodell nicht erzeugt hätte, werden verworfen, die Ausgabe folgt also der Verteilung des Hauptmodells. Ob sie bitgenau gleich ist wie ohne Draft-Modell, haben wir nicht geprüft. Gemessen am 06.09.2026, Qwen ohne Last, temperature 0, Reasoning-Ausgabe:

ohne Draft-Modell mit DSpark
Einzelstrom 79,0 Token/s 111,7 Token/s (+41 %)
4 gleichzeitig 173,6 Token/s 206,7 Token/s (+19 %)
8 gleichzeitig 237,5 Token/s 295,0 Token/s (+24 %)
16 gleichzeitig 327,3 Token/s 435,2 Token/s (+33 %)

Wie viel ein Draft-Modell bringt, hängt davon ab, wie vorhersagbar der Text ist. vLLM zählt vorgeschlagene und angenommene Token. Bei unseren Benchmark-Läufen am 19.10. (kurze Prompts, Reasoning-Ausgabe, temperature 0, Qwen ohne Last) nahm das Hauptmodell 55,4 % der vorgeschlagenen Token an, im Schnitt 2,66 Token pro Prüfschritt über 7.694 Schritte. Einen sauberen Wert für den echten Betrieb haben wir nicht: Der Zähler von vLLM läuft seit dem Start am 06.09. und enthält auch unsere eigenen Benchmark-Läufe.

Das Draft-Modell ist 1,3 GB groß. Die übrigen Flags aus NVIDIAs Rezept (--mamba-backend flashinfer, --moe-backend marlin) brachten keinen messbaren Unterschied (78,4 gegenüber 79,0 Token/s). Wir haben sie behalten, weil der Hersteller sie empfiehlt.

Ohne deutschen Systemprompt antwortete Nemotron auf 6 von 10 deutschen Prompts auf Englisch. Mit „Antworte immer auf Deutsch.“ war es 1 von 10. Unser Gateway setzt diesen Systemprompt.

Nemotron ist schneller, aber nicht klüger. Im Artificial Analysis Intelligence Index (Version 4.3.2, abgerufen am 19.10.2026) kommt Qwen3.6-35B-A3B auf 18, Nemotron 3.5 Lightning auf 13. Deshalb laufen beide.


Beide Modelle unter Last

Die Werte oben sind gemessen, während das andere Modell keine Last hatte. Im Betrieb bekommen beide gleichzeitig Anfragen. Am 19.10. haben wir deshalb jedes Modell gemessen, während das andere in einer Schleife mit gleich vielen gleichzeitigen Anfragen beschäftigt war:

anderes Modell ohne Last anderes Modell unter gleicher Last
Qwen, 4 gleichzeitig 185,2 Token/s 75,8 Token/s
Qwen, 8 gleichzeitig 263,7 Token/s 107,7 Token/s
Nemotron, 4 gleichzeitig 214,1 Token/s 108,1 Token/s
Nemotron, 8 gleichzeitig 293,5 Token/s 149,6 Token/s

Qwen behält etwa 41 % seines Durchsatzes, wenn Nemotron gleichzeitig arbeitet, Nemotron etwa 51 %. Die Zeit bis zum ersten Token verdoppelt sich ungefähr (Qwen bei 8 gleichzeitigen Anfragen: 419 statt 212 ms; Nemotron: 370 statt 215 ms). Einzelströme unter Last haben wir nicht gemessen. Zusammen erreichen beide Modelle unter Last 257 Token/s (107,7 + 149,6, aus zwei Läufen mit vertauschten Rollen), weniger als jedes Modell allein bei 8 gleichzeitigen Anfragen. Zwei Modelle auf einer Spark bringen Flexibilität, keinen zusätzlichen Durchsatz. Für die Planung ist die rechte Spalte die ehrlichere Zahl.


Lange Eingaben

Bei 48 Eingabe-Token spielt der Prefill kaum eine Rolle. Bei langen Dokumenten oder langen Gesprächen entscheidet er, wie lange jemand auf das erste Token wartet. Wir haben den Anfang eines gemeinfreien deutschen Romans geschickt (Goethe, „Die Leiden des jungen Werther“, aus Project Gutenberg), gekürzt auf etwa 8.000 und 32.000 Token, eine Zusammenfassung in drei Sätzen verlangt (Denk-Budget 64, bis zu 256 Ausgabe-Token) und die Zeit bis zum ersten Token sowie die Decode-Rate gemessen. Jede Anfrage begann mit einem anderen zufälligen Anfang, damit kein Prefix-Cache helfen konnte. Drei Läufe für Qwen, sechs für Nemotron, anderes Modell ohne Last, 19.10.2026:

Eingabe-Token Zeit bis zum ersten Token Decode
Qwen 8.047 1,12 s 77,1 Token/s
Qwen 32.045 5,43 s 71,0 Token/s
Nemotron 8.051 1,03 s 86,8 Token/s (79,5 bis 100,2)
Nemotron 32.052 4,72 s 83,1 Token/s (79,6 bis 103,0)

Der Prefill schafft etwa 5.900 bis 7.800 Token pro Sekunde. Bei 32.000 Eingabe-Token wartet man also rund fünf Sekunden auf das erste Token, bei den kurzen Prompts 0,06 (Qwen) und 0,09 Sekunden (Nemotron). Qwens Decode-Rate sinkt mit langem Kontext leicht: 78,6 Token/s mit dem kurzen Prompt, 77,1 bei 8.000 und 71,0 bei 32.000 Token. Nemotron verliert deutlich mehr: von 111,7 Token/s mit dem kurzen Prompt auf 83 bis 87 Token/s (Median aus sechs Läufen, einzelne Läufe zwischen 80 und 103), kaum mehr als sein Wert mit kurzem Prompt ohne Draft-Modell (79,0). Den Grund zeigen die Zähler von vLLM: Das Hauptmodell nahm hier 37 bis 40 % der vorgeschlagenen Token an, bei den kurzen Prompts 55 %. Die Antworten waren kurz (138 bis 256 Token), das macht die Werte unruhig. Ein erster Versuch mit einem synthetischen Text, ein einziger deutscher Satz vielfach wiederholt, hatte Nemotron 117 bis 157 Token/s gebracht. Bei sich wiederholender Eingabe rät das Draft-Modell fast alles richtig. Ein guter Hinweis darauf, wie sehr Werte mit spekulativer Dekodierung vom Text abhängen.


Was wir verworfen haben, mit Zahlen

  • MTP (eingebaute spekulative Dekodierung) bei Qwen3.6-35B. Erster Versuch: Speicher voll, Swap 15 von 15 GiB, SSH zeitweise nicht erreichbar. Der Grund: --gpu-memory-utilization deckt das MTP-Modul nicht ab. Bei diesem Modell bringt die MTP-Schicht 256 eigene Experten mit, nach unserer Schätzung etwa 15 GB zusätzlich. Zweiter, sauberer Versuch: 0 bis 5 % mehr Geschwindigkeit für 44 % weniger Parallelität. Zurückgebaut.
  • Atlas Inference Engine 0.4.0 (avarok/atlas-gb10). Im Einzelstrom schneller (95,6 Token/s mit NVFP4), bei 16 gleichzeitigen Anfragen 44 % langsamer (208,9 gegenüber 373,1 Token/s). Die Herstellerkonfiguration (gpu-memory-utilization 0.88) drückte den verfügbaren Speicher binnen fünf Sekunden auf 8,4 GiB (8.552 MiB); unser Watchdog hat sie gestoppt. Der Installer hat außerdem einen Verwaltungsagenten (atlasctl-agent.service) eingerichtet und gestartet, der auf 0.0.0.0:34334 lauschte, erreichbar aus dem ganzen LAN. Wir haben ihn gestoppt und deaktiviert.
  • Ein dichtes 27B-Modell: Qwen3.8-27B in Q4_K_M unter Ollama kam auf einer anderen Spark auf 13,2 Token/s (06.09.2026). Das ist kein gleichwertiger Vergleich mit vLLM und NVFP4. Zwei Community-Messungen desselben Modells in NVFP4 mit vLLM und MTP auf einer Spark nennen 23 bis 26 Token/s im Chat-Betrieb; eine davon erreicht 33,7 Token/s bei gierigen Completions ohne Chat-Vorlage. Das liegt immer noch deutlich unter den MoE-Modellen oben.
  • Ein drittes Modell auf spark-3. Dafür ist kein Platz, und wir haben aufgehört, es zu versuchen.

Abstürze: drei Erklärungen bisher

Am 06.09.2026 starb der Qwen-Dienst während einer einzigen Anfrage und blieb aus, bis ihn jemand von Hand neu startete. Warum er aus blieb, war schnell klar: Nach einem internen Fehler (EngineDeadError) fährt vLLM sauber herunter und endet mit Code 0. systemd sieht darin keinen Fehler, Restart=on-failure greift also nie. Beide Dienste laufen jetzt mit Restart=always. Seitdem kostet ein Absturz ein paar Minuten Ladezeit statt Stunden.

Warum er abstürzte, dauerte länger. Der zeitliche Ablauf war aber eindeutig. Vor der Umstellung auf NVFP4 am 05.09. war der Dienst in 72 Tagen zweimal abgestürzt (27.06. und 17.08.). In den sechs Tagen nach der Umstellung fünfmal. Insgesamt sind nach der Umstellung neun Abstürze belegt.

Erste Erklärung (12.09.): Prefix-Caching. Ein offenes vLLM-Issue (#54173) beschrieb unsere Fehlerkette, und mit abgeschaltetem Prefix-Caching wurde es ruhig. Wir haben das Problem als gelöst dokumentiert. Eine Falle unterwegs: In vLLM 0.28 ist Prefix-Caching standardmäßig an. Das Flag wegzulassen ändert nichts, man braucht --no-enable-prefix-caching. Aufgefallen ist uns das nur, weil ein wiederholter Prompt schneller zurückkam, als es ohne Cache möglich wäre.

Gelöst war es nicht. Zwischen dem 19. und 26.09. folgten vier weitere Abstürze.

Zweite Erklärung (27.09.): FlashInfer-Issue #3566. vLLM ruft die FP8-Matrixmultiplikation mit backend="auto" auf. Auf dem GB10 wählt „auto“ cuDNN, und dessen FP8-Kernel scheitert dort gelegentlich bei kleinen Batches, also genau dann, wenn nur ein bis drei Anfragen laufen. Prefix-Caching hatte das nur häufiger gemacht. Ein kleiner Patch zwingt das Backend auf cuBLAS (siehe unten). Der gemessene Durchsatz sank nicht (79,2 gegenüber 77,4 Token/s im Einzelstrom vor dem Patch). Am 27.09. haben wir geschrieben, dass das alle Abstürze erklärt.

Dritte Erklärung, eigentlich eine Korrektur der zweiten: Als wir alle neun Tracebacks einzeln durchgingen, passten sieben zu #3566 und zwei nicht. Einer war ein cuBLAS-Fehler im Vision-Encoder während einer Anfrage mit Bild. Der andere ein CUDA-Fehler „operation not permitted“, dessen Herkunft das Log nicht verrät. Vorher hatten wir nicht jeden Traceback gelesen. Diese zwei Abstürze sind weiter ungeklärt.

Stand heute: Seit dem Patch läuft Qwen 22 Tage ohne Absturz, Nemotron seit der Einrichtung 43 Tage. Das ist vielversprechend, nach unserem eigenen Maßstab aber noch kein Beleg. Vor NVFP4 stürzte der Dienst etwa alle 36 Tage ab, und zwei der neun Abstürze sind ungeklärt. Als gelöst betrachten wir das Problem nach 72 Tagen ohne Absturz, dem doppelten früheren Abstand, also am 08.12.2026. Bis dahin fängt unser Gateway einen Ausfall ab, indem es auf ein Ersatzmodell umleitet.


Unsere Konfiguration

Beide Dienste laufen als Docker-Container aus dem Image ghcr.io/timothystewart6/vllm-gb10:v0.28.0-gb10.2 (vLLM 0.28.1, gebaut für sm_121a). Das offizielle vLLM-Image funktionierte damals bei uns nicht, weil sein PyTorch nur bis sm_120 übersetzt war. systemd startet die Container.

vLLM selbst hat keine Authentifizierung. Nie direkt ins Internet stellen. Bei uns sitzt vor beiden Diensten ein Inferenz-Gateway mit API-Schlüsseln, und die vLLM-Ports sind nur aus dem internen Netz erreichbar. Die Beispiele unten binden an 127.0.0.1. Das passt, wenn das Gateway auf derselben Maschine läuft. Läuft es auf einem anderen Rechner, kommt die Adresse der internen Schnittstelle dazu (-p <interne IP>:8001:8001), und eine Firewall lässt nur das Gateway durch. So machen wir es.

Qwen3.6-35B-A3B (NVFP4)

docker run --name vllm-mid --gpus all --ipc=host --ulimit memlock=-1 \
  -v /srv/models:/models \
  -v /srv/models/.vllm-compile-cache-mid:/root/.cache/vllm \
  -v /srv/vllm-patches/flashinfer_fp8_cublas.py:/usr/local/lib/python3.12/dist-packages/vllm/utils/flashinfer.py:ro \
  -p 127.0.0.1:8001:8001 \
  ghcr.io/timothystewart6/vllm-gb10:v0.28.0-gb10.2 \
  vllm serve /models/Qwen3.6-35B-A3B-NVFP4 \
    --served-model-name qwen3.6-35b \
    --host 0.0.0.0 --port 8001 \
    --gpu-memory-utilization 0.45 \
    --max-model-len 262144 \
    --max-num-seqs 16 \
    --max-num-batched-tokens 8192 \
    --kv-cache-dtype fp8 \
    --reasoning-parser qwen3 \
    --enable-auto-tool-choice --tool-call-parser qwen3_coder \
    --no-enable-prefix-caching \
    --reasoning-config '{"reasoning_start_str":"<think>","reasoning_end_str":"</think>"}'

--host 0.0.0.0 bezieht sich auf das Innere des Containers. Was von außen erreichbar ist, entscheidet -p.

Nemotron 3.5 Lightning (NVFP4) mit DSpark

docker run --name vllm-nemotron --gpus all --ipc=host --ulimit memlock=-1 \
  -v /srv/models:/models \
  -v /srv/models/.vllm-compile-cache-nemotron:/root/.cache/vllm \
  -p 127.0.0.1:8002:8002 \
  ghcr.io/timothystewart6/vllm-gb10:v0.28.0-gb10.2 \
  vllm serve /models/Nemotron-3.5-Lightning-30B-A3B-NVFP4 \
    --served-model-name nemotron-3.5-lightning \
    --host 0.0.0.0 --port 8002 \
    --gpu-memory-utilization 0.28 \
    --max-model-len 262144 \
    --max-num-seqs 16 \
    --max-num-batched-tokens 8192 \
    --kv-cache-dtype fp8 \
    --mamba-cache-mode align \
    --mamba-backend flashinfer \
    --moe-backend marlin \
    --reasoning-parser nemotron_v3 \
    --reasoning-config '{"reasoning_start_str":"<think>","reasoning_end_str":"</think>"}' \
    --tool-call-parser qwen3_coder --enable-auto-tool-choice \
    --enable-prefix-caching \
    --speculative_config.model /models/Nemotron-3.5-Lightning-DSpark \
    --speculative_config.num_speculative_tokens 3

Anmerkungen zu diesen Entscheidungen:

  • Speicher: 0,45 + 0,28 = 0,73 von 121,7 GiB, also 88,8 GiB. NVIDIAs Rezept für Nemotron nutzt 0,85 und geht damit von einer Maschine aus, auf der nur dieses eine Modell läuft.
  • Prefix-Caching ist bei Qwen aus (siehe Abschnitt zu den Abstürzen) und bei Nemotron an. Nemotron hat eine andere Architektur und hatte das Problem nie.
  • Beide nutzen den Tool-Call-Parser qwen3_coder. Für Nemotron empfiehlt NVIDIAs Modellkarte genau diesen Parser. Für Qwen empfiehlt NVIDIAs Modellkarte qwen3_xml. In vLLM 0.28.1 zeigen beide Namen auf dieselbe Parser-Klasse (Qwen3EngineToolParser), die Wahl macht dort also keinen Unterschied. In älteren vLLM-Versionen waren es getrennte Implementierungen.
  • Kein --rm. Scheitert ein Start, bleiben Container und Log für die Analyse erhalten.
  • Clients nutzen den Namen aus --served-model-name, nie den Modellpfad. Wir haben den Checkpoint hinter diesem Namen zweimal getauscht, ohne dass ein Client es gemerkt hat.

Der cuBLAS-Patch

Der Patch kopiert vllm/utils/flashinfer.py aus dem Image und ersetzt in den beiden FP8-Aufrufen "auto" durch "cublas". "cublas" ist FlashInfers eigener Standardwert für diese Funktion. Das Skript prüft zuerst, ob die Datei genau die zwei erwarteten Zeilen enthält, und patcht sonst nicht. Seinen temporären Container räumt es auch dann weg, wenn ein Schritt scheitert. Skript zum Herunterladen: make-flashinfer-cublas-patch.sh.

#!/usr/bin/env bash
set -euo pipefail

IMG="ghcr.io/timothystewart6/vllm-gb10:v0.28.0-gb10.2"
SRC="/usr/local/lib/python3.12/dist-packages/vllm/utils/flashinfer.py"
TARGET_DIR="${1:-/srv/vllm-patches}"
OUT="$TARGET_DIR/flashinfer_fp8_cublas.py"

WORK="$(mktemp -d)"
CID=""
cleanup() {
  [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true
  rm -rf "$WORK"
}
trap cleanup EXIT

CID="$(docker create "$IMG")"
docker cp "$CID:$SRC" "$WORK/flashinfer.py"
cp "$WORK/flashinfer.py" "$WORK/flashinfer_fp8_cublas.py"

count="$(grep -c '^        "auto",$' "$WORK/flashinfer_fp8_cublas.py" || true)"
if [ "$count" != "2" ]; then
  echo "Expected 2 matching lines, found $count. Not patching." >&2
  exit 1
fi
sed -i 's/^        "auto",$/        "cublas",/' "$WORK/flashinfer_fp8_cublas.py"

echo "Changes against the original:"
diff "$WORK/flashinfer.py" "$WORK/flashinfer_fp8_cublas.py" || true

mkdir -p "$TARGET_DIR"
install -m 0644 "$WORK/flashinfer_fp8_cublas.py" "$OUT"
echo "Written: $OUT"

/srv/vllm-patches lässt sich meist nur mit Root-Rechten anlegen. Einmal anlegen und dem Benutzer übergeben, der das Skript ausführt, dann das Skript starten:

sudo install -d -o "$USER" -m 0755 /srv/vllm-patches
./make-flashinfer-cublas-patch.sh

Die diff-Ausgabe sollte genau zwei geänderte Zeilen zeigen. Danach die Datei schreibgeschützt in den Container einbinden, wie im docker run-Beispiel oben.

Der Patch gehört genau zu diesem Image. Bei jedem Image-Update neu aus der neuen Datei erzeugen und vorher prüfen, ob FlashInfer #3566 dort behoben ist. Eine alte Kopie über eine neuere vLLM-Version gelegt, bricht im besten Fall den Start und läuft im schlimmsten Fall mit nicht zusammenpassendem Code.

systemd und sysctl

Für beide Dienste gibt es dieselben zwei Drop-ins, unter vllm-mid.service.d/ und vllm-nemotron.service.d/:

# /etc/systemd/system/vllm-mid.service.d/restart-always.conf
# /etc/systemd/system/vllm-nemotron.service.d/restart-always.conf
# vLLM exits with code 0 after EngineDeadError, so on-failure never triggers.
[Service]
Restart=always
RestartSec=30
# /etc/systemd/system/vllm-mid.service.d/startlimit.conf
# /etc/systemd/system/vllm-nemotron.service.d/startlimit.conf
# At most 5 starts in 10 minutes, so a start that fails at once does not loop forever.
[Unit]
StartLimitIntervalSec=600
StartLimitBurst=5
# /etc/sysctl.d/99-no-proactive-compaction.conf
vm.compaction_proactiveness = 0

Was wir anders machen würden

  1. Erst die eigene Basis messen, dann fremde Zahlen lesen. Das hätte uns den falschen NVFP4-Schluss erspart.
  2. Speicher rechnen statt schätzen und Zusätze wie MTP-Köpfe getrennt einplanen. --gpu-memory-utilization deckt sie nicht ab.
  3. Jede neue Konfiguration unter einem Watchdog starten, der jede Sekunde prüft und den Container sofort beendet.
  4. Zwei Modelle auf einer Maschine gemeinsam messen, nicht nur einzeln. Die Einzelwerte beschreiben den günstigsten Fall.
  5. Eine Korrektur erst nach einer Beobachtungszeit als bestätigt ansehen, die deutlich länger ist als der übliche Abstand zwischen zwei Ausfällen.
  6. Alles aufschreiben, auch die Fehler. Dieser Artikel ist eine bereinigte Fassung unseres Betriebshandbuchs. Weil die Fehlannahmen dort als falsch markiert sind, haben wir sie mehr als einmal kein zweites Mal gemacht.

Quellen


Beide Modelle gibt es auf allsmartllm.com unter den Aliasen mid (Qwen3.6-35B) und swift (Nemotron 3.5 Lightning). Neue Konten enthalten 10 Millionen Token zum kostenlosen Testen.

← Alle Artikel