Ein Wort im vLLM-Fork: Warum unsere Flash-Next-Spur jeden Token dreimal bezahlt hat
Am Samstagabend, dem 13. September, war die Spur weg. Der vLLM-Container auf unserer Maschine mit vier RTX 5090, seit dem 6. September mit FP8-KV-Cache in Produktion, startete jede Sekunde neu und brach jede Sekunde ab. Abgeschaltet hatte ihn kein Mensch, sondern unser Betriebsagent, und der hatte dafür eine Freigabe. Er sollte herausfinden, wie viel mehr gleichzeitige Sitzungen die Karte hergibt, und war mit einer Messung angetreten, die sich im Nachhinein als richtig herausgestellt hat. Falsch war seine Erklärung. Die Suche nach der richtigen hat einen Tag gedauert und endet bei einem einzigen Wort.
Wie die Spur ausgefallen ist
Der Agent hatte ein Startskript für einen Debug-Container geschrieben und darin serve als erstes Argument übergeben. Das Container-Image trägt vllm serve aber bereits als Einstiegspunkt. Ergebnis war vllm serve serve …, ein Kommando, das der Parser mit einem Fehler quittiert, und ein Container mit Neustart-Automatik, der genau das jede Sekunde wiederholte. Die Sitzung des Agenten selbst starb kurz darauf an einem Fehler im Gateway, weil seine eigene Modellspur keinen Ausweichpfad hat. Zurückgerollt hat niemand.
Die Wiederherstellung war Routine: Container weg, Produktionsimage mit dem bekannten Startskript hoch. Zwei Dinge daran waren es nicht. Das Startskript trug noch die Werte vom 7. September, 192.000 Token Kontext und acht Slots, die Produktion lief aber seit dem 12. mit 220.000 Token und vier Slots. Den richtigen Stand lieferte ausgerechnet der Agent selbst, er hatte den Container vor dem Abschuss per docker inspect gesichert. Und die Spuren für Chat und Reasoning blieben während der Ausfälle erreichbar, weil das Gateway sie auf die Ersatzmaschine mit einer einzelnen RTX 5090 umlenkt. Das haben wir vor dem zweiten Neustart mit echten Anfragen geprüft: drei Sekunden Antwortzeit über den Umweg.
| Zeitpunkt | Image | Kontext / Slots | Ergebnis |
|---|---|---|---|
| 13.09., 22:20 | Debug-Container des Agenten | 220.000 / 16 | Crash-Loop, Spur weg |
| 13.09., 22:44 | kvq-fp8 | 192.000 / 8 | läuft, mit den veralteten Skript-Vorgaben |
| 14.09., 10:24 | kvq-fp8, Retention 0 | 220.000 / 8 | läuft, 275 Blöcke, Variable ohne Wirkung |
| 14.09., 11:05 | kvq-fp8-mambafree | 220.000 / 8 | läuft, 119 Blöcke |
Jeder Neustart kostet rund 15 Minuten, davon gut zwölf für das Laden der Gewichte über die Auslagerung der N-Gram-Tabelle in den Hauptspeicher.
Was der Agent richtig gemessen hatte
Der KV-Pool der Spur hat 1.356 Blöcke zu je 800 Token und 5,53 MB je Karte. Das Startlog von vLLM rechnet daraus 1.050.422 Token und 4,77 gleichzeitige Vollsitzungen bei 220.000 Token. Der Agent hatte über die Metrik kv_cache_usage_perc nachgezählt, wie viele Blöcke eine Anfrage tatsächlich belegt: nicht einen je 800 Token, sondern einen je 308. Bei 16.000 Token stimmte das, darüber hatte er nicht gemessen. Wir haben es mit einer Anfrage von 84.495 Token wiederholt: 275 Blöcke, wo die Geometrie 111 vorsieht. Die Zahl des Startlogs war also um den Faktor 2,5 zu optimistisch, und eine echte Vollsitzung mit 220.000 Token hätte nur gut zweimal in den Pool gepasst.
Als Ursache nannte der Agent das Aufrunden der Mamba-Zustandsblöcke auf die Seitengröße der Attention-Blöcke. Das Startlog selbst widerspricht: Padding mamba page size by 0.25%. Ein Viertelprozent erklärt keinen Faktor 2,6, und die aufgerundeten Blöcke sind je Anfrage konstant, sie wachsen nicht mit der Kontextlänge.
Drei weitere Messungen an der laufenden Spur, ohne Neustart, haben den Kreis geschlossen. Dieselbe Anfrage ein zweites Mal, mit 84.000 Token aus dem Prefix-Cache: 237 Blöcke, also fast gleich viel, obwohl kaum noch gerechnet wurde. Eine kurze Anfrage mit 4.000 erzeugten Token: ein Block je 800 Token, genau die Attention und nichts sonst. Und eine kalte Anfrage, zwanzigmal je Sekunde abgetastet: alle 190 Millisekunden, mit jedem Prefill-Schritt von 2.048 Token, kamen 6,5 Blöcke dazu. Die Attention braucht davon 2,56. Die übrigen vier sind je ein Zustandsblock der vier Mamba-Gruppen des Modells, drei für die Gated-DeltaNet-Schichten und eine für die PLE-Faltung, und keiner davon wurde vor dem Ende der Anfrage wieder frei.
Der Fehler steht in einer Zeile
vLLM speichert die Zustände der linearen Schichten im „align"-Modus in derselben Blocktabelle wie die Attention, positionsindiziert. Je Prefill-Schritt wird ein neuer Zustandsblock angehängt, die Positionen dazwischen werden mit Leerblöcken aufgefüllt. Vorgesehen ist, dass nur der aktuelle und der vorige Zustand belegt bleiben; die Kapazitätsrechnung des Startlogs geht genau davon aus.
So sieht der Anfang der Tabelle einer Mamba-Gruppe nach drei Prefill-Schritten aus, wenn der vierte Schritt die Freigabe bis Position 5 anstößt:
| Position | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| Inhalt | leer | leer | Zustand aus Schritt 1 | leer | leer |
Rückwärtslauf mit break | bleibt belegt | Abbruch beim ersten Leerblock | |||
Rückwärtslauf mit continue | übersprungen | übersprungen | wird frei | übersprungen | übersprungen |
Der Zustand aus Schritt 2 liegt auf Position 5, außerhalb des Bereichs, und bleibt in beiden Fällen erhalten. Der aus Schritt 1 wird nur mit continue erreicht.
Freigegeben wird über eine Hilfsfunktion, die den freizugebenden Bereich der Tabelle rückwärts durchläuft. Sie ist für Sliding-Window-Attention geschrieben, wo Leerblöcke nur am Anfang der Tabelle stehen, und bricht deshalb beim ersten Leerblock ab:
for i in range(last_block - 1, first_block - 1, -1):
if blocks[i] == self._null_block:
break # senn 2026-09-14: continue
freed.append(blocks[i])
blocks[i] = self._null_block
Bei einer mit Leerblöcken durchsetzten Mamba-Tabelle trifft der Rückwärtslauf im Prefill praktisch sofort auf einen Leerblock und gibt nichts frei. Im Decode liegt der vorige Zustandsblock direkt unter der Freigabegrenze, wird frei, und erst danach kommt der Abbruch. Das ist genau das Muster aus den Messungen. Die Korrektur besteht darin, Leerblöcke zu überspringen statt abzubrechen. Für Sliding-Window-Attention ändert sich dadurch nichts, der Bereich enthält ohnehin nur freizugebende Blöcke.
Beim Gegenlesen der Quellen für diesen Beitrag kam heraus, dass wir nicht die Ersten waren. Im Hauptzweig von vLLM ist dieselbe Diagnose seit dem 11. September behoben, Pull Request #55450, „Retire Mamba states across null gaps". Die Fassung dort überspringt die Lücken ebenfalls und merkt sich zusätzlich, bis wohin die Tabelle schon abgeräumt ist, damit sie nicht jedes Mal von vorn liest. Unser Build stammt vom 26. August und kennt den Patch nicht; in einer Release-Version von vLLM steckt er bis heute nicht. Drei Tage Vorsprung hätten uns einen Tag Suche erspart, und umgekehrt hätte ohne die Suche niemand gewusst, dass genau dieser Patch für uns den Faktor 2,3 wert ist.
Ein Umweg vorher lohnt die Erwähnung. Der Fork kennt eine Umgebungsvariable VLLM_PREFIX_CACHE_RETENTION_INTERVAL, mit der sich einstellen lässt, wie dicht Mamba-Zustände für spätere Prefix-Treffer aufgehoben werden. Wir haben sie zuerst verdächtigt und mit einem Neustart auf 0 gesetzt: 275 Blöcke, unverändert. Sie steuert nur, welche Zustände einen Hash bekommen, nicht, welche Blöcke eine laufende Anfrage festhält.
Der Fix und der Beweis
Ein Image mit der geänderten Datei, ein Neustart von 15 Minuten, dieselben Messungen:
Die Prüfreihe vom 6. September lief anschließend unverändert durch, bei Temperatur 0 und ohne Reasoning:
| Prüfung | FP8-KV, 6. September | mit Korrektur, 14. September |
|---|---|---|
| Rechenaufgabe 17 × 23 | richtig | richtig |
| Erzwungener Tool-Call | 1 Aufruf | 1 Aufruf |
| Needle in 34.974 Token, Tiefe 0,3 | gefunden | gefunden |
| Needle in 104.835 Token, Tiefe 0,7 | gefunden | gefunden |
| Needle in 180.539 Token, Tiefe 0,5 | gefunden, 17,9 s | gefunden, 19,7 s |
| Vier parallele Needles à 128k | 4 von 4, 50 s | 4 von 4, 49 s |
Dazu eine Needle bei 175.383 Token, die 233 Blöcke belegte, und null Verdrängungen im Scheduler während der ganzen Reihe.
Was das für den Betrieb heißt
Eine Anfrage kostet jetzt einen Block je rund 710 Token. Bei Kontexten um 100.000 Token, dem Alltag unseres Coding-Agenten, passen rund neun Sitzungen gleichzeitig in den Pool, vorher vier. Bei 220.000 Token sind es 4,7, und das ist die Zahl, die das Startlog von Anfang an behauptet hat. Die Rechnung dort war nie falsch, sie setzte nur ein Verhalten voraus, das der Code nicht hatte. Der Slot-Deckel steht seither wieder auf acht.
Unverändert ist, woran das Modell selbst gebunden bleibt: 262.144 Token je Anfrage, die Attention-Schichten kosten je Token, was sie kosten, und die Gewichte sind dieselben. Was der Agent ursprünglich gesucht hatte, ein Trick nach dem Vorbild von DeepSeek-V4.1-Flash mit seinen 890 Byte je Token, ist eine Eigenschaft des Trainings und lässt sich nachträglich nicht anschrauben. Das Doppelte an Kapazität lag stattdessen in einer Freigabefunktion, die für ein anderes Attention-Muster geschrieben war.
Stand jetzt
Die Spur läuft seit dem 14. September, 11:05 Uhr, mit dem korrigierten Image, 220.000 Token Kontext und acht Slots. Der Rückweg ist dasselbe Startkommando mit dem Image vom 6. September, 15 Minuten, dann ist das Leck wieder da und sonst nichts anders. Offen sind zwei Kleinigkeiten in der Konfiguration, ein Startskript mit veralteten Vorgabewerten und eine Kontextgrenze im Chat-Frontend, die über der des Backends liegt. Sobald ein vLLM-Release den Patch aus #55450 und den FP8-KV-Pfad aus #54426 zusammen mitbringt, wechseln wir auf ein offizielles Image und werfen beide Eigenbauten weg.
Weiterführende Quellen
- Flash-Next in Produktion: der FP8-KV-Patch vom 6. September
- Prefix-Caching in vLLM, erklärt am eigenen Betrieb
- vLLM PR #55450: Retire Mamba states across null gaps
- vLLM Engine Arguments:
--mamba-cache-mode,--mamba-block-size - Marconi: Prefix Caching for the Era of Hybrid LLMs, arXiv 2411.19379
- Qwen3.8-Flash-Next auf Hugging Face
- RFC #54426: FP8-KV auf dem QSA-Pfad
Was war der Fehler im vLLM-Fork genau?+
Die Funktion, die nicht mehr benötigte KV-Blöcke einer Anfrage freigibt, läuft rückwärts durch die Blocktabelle und bricht beim ersten leeren Eintrag ab. Bei den Mamba-Schichten von Qwen3.8-Flash-Next ist die Tabelle aber mit leeren Einträgen durchsetzt, weil je Prefill-Schritt nur ein Zustandsblock geschrieben wird. Der Abbruch traf deshalb sofort, und kein Zustandsblock wurde vor dem Ende der Anfrage frei. Die Korrektur ersetzt das Abbrechen durch ein Überspringen.
Wie viel Kapazität bringt die Korrektur?+
Eine Anfrage mit 84.000 Token belegte vorher 275 Blöcke des KV-Pools, nachher 119. Je Token sind das rund 710 statt 310 Token je Block. Bei Kontexten um 100.000 Token passen jetzt rund neun Sitzungen gleichzeitig in den Pool, bei 220.000 Token 4,7. Vorher waren es vier beziehungsweise gut zwei.
Hat der Fix die Antwortqualität verändert?+
Nein. Dieselbe Prüfreihe wie beim Wechsel auf den FP8-KV-Cache lief mit identischen Zeiten durch: Rechenaufgabe, Tool-Call, Needles bei 35.000, 105.000 und 180.000 Token, vier parallele Needles mit je 128.000 Token. Das Modell und seine Gewichte sind unverändert, nur die Buchführung über die Blöcke ist anders.
senn-tech