Ein halbes Megatoken Kontext, und was es kostet
Letzte Woche lagen zwei Nodes direkt aneinander, 391 Gb/s über zwei Rails, und zwei Modelle liefen cross-node schneller als auf einer einzelnen Maschine. Die Bandbreite war gelöst. Was ich damals nicht angefasst hatte: beide Modelle liefen mit 65.536 Token Kontext, während sie 262.144 respektive 524.288 könnten.
Ein Viertel und ein Achtel des Möglichen. Das ist jetzt behoben, und der Weg dahin hat zwei Dinge geliefert, die ich vorher nicht wusste.
Die Zahlen zuerst
| vorher | jetzt | real gemessen | |
|---|---|---|---|
| dense-27B | 65.536 | 262.144 | 261.899 Token, Needle gefunden |
| MoE-35B | 65.536 | 524.288 | 521.315 Token, Needle gefunden |
Die rechte Spalte ist der Punkt. Eine Konfiguration behauptet einen Kontext, sie beweist ihn nicht. Ich habe einen Merksatz an den Anfang eines Prompts gesetzt, den schwersten Fall, weil bei stiller Truncation genau der zuerst wegfällt, und danach gefragt. Beide Modelle haben ihn zitiert, bei 99,9 % und 99,4 % ihrer jeweiligen Obergrenze.
Über dem Limit lehnt die API sauber ab (maximum context length is 524288),
0,2 Sekunden, kein Crash, keine stille Kürzung. Eine harte, korrekte Kante.
Beide Modelle sind übrigens nativ auf diese Längen trainiert, kein RoPE-Scaling im Spiel. Das ist der Grund, warum ich beim dense nicht auf 512k gegangen bin, obwohl es technisch ginge: Faktor 2 über dem Training tauscht Retrieval-Qualität gegen eine hübschere Zahl im Manifest. Der Prompt wird akzeptiert, aber was das Modell in der Mitte noch findet, ist ungeprüft und üblicherweise schlechter.
Der Preis steht in einer Zahl
Kontext kostet nicht Geschwindigkeit. Er kostet Parallelität.
Der KV-Cache ist die harte Grenze, und er wird durch die Anzahl gleichzeitiger Sequenzen geteilt. Bei 65k liefen beide Modelle mit acht parallelen Requests. Für den vollen Kontext musste ich runter:
max-num-seqs vorher | jetzt | |
|---|---|---|
| dense | 8 | 2 |
| MoE | 8 | 3 |
Und damit sind die Durchsatzzahlen, die ich letzte Woche als Ergebnis aufgeschrieben habe, nicht mehr erreichbar:
| bei 65k | bei vollem Kontext | |
|---|---|---|
| dense, 8 parallel | 648 tok/s | nicht mehr möglich |
| dense, an der Grenze | 234 tok/s (2 parallel) | |
| MoE, 8 parallel | 950 tok/s | nicht mehr möglich |
| MoE, an der Grenze | 446 tok/s (3 parallel) |
Der Einzelrequest bleibt unberührt: 163 und 238 tok/s, identisch zu vorher. Wer ein Modell für einen Agenten mit riesigem Kontext betreibt, verliert nichts. Wer viele parallele Anfragen bedienen will, verliert die Hälfte des Durchsatzes.
Das ist keine Optimierung, das ist eine Entscheidung. Und ich hatte sie vorher nicht als Entscheidung auf dem Zettel, ich dachte, „mehr Kontext" sei ein Feature, das man einschaltet.
Eine Überraschung nach oben
Meine Vorhersage war 2,23× Concurrency-Reserve beim dense und 3,30× beim MoE. Tatsächlich:
dense: 707.017 Token KV-Cache -> 2,70x für 262.144
MoE: 2.222.981 Token KV-Cache -> 4,24x für 524.288
Der KV-Cache ist bei unverändertem gpu-memory-utilization gewachsen. Weniger
gleichzeitige Sequenzen brauchen weniger CUDA-Graph-Speicher, und das Freiwerdende
landet im Cache. Meine Rechnung war zu konservativ, ein angenehmer Fehler, aber
ein Fehler.
Bemerkenswert ist das Verhältnis: das MoE hält bei praktisch gleichem Speicherverbrauch dreimal so viele Token im Cache wie das dense Modell. MoE-Layer brauchen weniger KV pro Token. Deshalb ist das halbe Megatoken beim MoE fast geschenkt, während der dense für 256k schon auf zwei Sequenzen runter muss.
Das spekulative Decoding, das nur unter Last stirbt
Zweite Baustelle derselben Woche. Beide Modelle fahren Multi-Token-Prediction, und der Parameter dafür stand auf 1, ein einziger angebotener Draft-Token.
Beim MoE war MTP damit sogar negativ: 3,2 % langsamer als ohne. Ich hatte dafür eine Theorie über sparse Forward-Passes und den relativen Draft-Overhead. Die Theorie war falsch. Es war schlicht der Parameter.
| MoE | 1 Token | 4 Tokens | |
|---|---|---|---|
| Akzeptanzlänge | 1,85 | 4,00 | +116 % |
| einzeln | 154 tok/s | 238 tok/s | 1,54× |
| 8 parallel | 867 tok/s | 951 tok/s | 1,10× |
Akzeptanzlänge 4,00 bei vier angebotenen Tokens ist das theoretische Maximum. Voll gesättigt.
Also der naheliegende nächste Schritt: sechs Tokens. Bei einem Request war es besser, 258 statt 238 tok/s, über zwei Runden reproduziert. Ich war einen Satz davon entfernt, das als Verbesserung zu melden.
Dann vier Requests parallel:
torch.AcceleratorError: CUDA error: an illegal memory access was encountered
vllm.v1.engine.exceptions.EngineDeadError
Null von vier. Und null von acht. Pod tot, Neustart, Modell weg.
Das ist die Sorte Bug, die als Optimierung getarnt in Produktion geht. Er ist bei einem Request nicht nur unsichtbar, er sieht dort gut aus. Ein einzelner Request ist keine Abnahme, und ich hätte den Concurrency-Test für unnötig gehalten, wenn die erste Zahl nicht so verlockend gewesen wäre.
Beide zurück auf vier. Dort 4/4 und 8/8, null Restarts.
Eine Zahl, die ich zurücknehmen muss
Beim dense hatte ich nach der MTP-Umstellung 161,91 tok/s gemessen. Die Zahl ist nicht reproduzierbar. Nach einem Rollback und drei Wiederholungen: durchgängig 151 bis 153, bei identischer Konfiguration.
Vier Hypothesen geprüft, alle widerlegt. Die Thermik lag bei 39 bis 62 °C, voller Takt. Andere GPUs durch die Scheduler-Neuzuweisung: dieselben acht. Degradierte CUDA-Graphs: das war ein abgeschnittener grep in meinem eigenen Check, der volle Graph-Modus war die ganze Zeit da. Das zweite Modell zieht Ressourcen: MoE auf null skaliert, dense allein gemessen, 152,09, kein Unterschied.
Gegen einen echten Regress spricht die Akzeptanzlänge. Heute 3,97, damals 3,81, also höher. Die 161,91 waren ein Ausreißer, den ich als Ergebnis notiert hatte. Belastbar ist ~152.
Das ist unangenehm aufzuschreiben, aber eine Zahl, die man einmal gesehen und nie wieder erreicht hat, ist keine Messung. Sie ist ein Zufall mit Dezimalstellen.
Was ich nicht gemessen habe
Zwei Lücken, die ich offen benenne, weil sie sonst wie Ergebnisse aussehen.
Retrieval in der Mitte. Der Needle-Test sitzt am Prompt-Anfang. Das ist der schwerste Einzelfall, aber ein vollständiger Needle-in-a-Haystack prüft mehrere Tiefen. Bei 500k heißt „findet den Anfang" nicht „findet alles".
Die Prefill-Zeiten sind unbrauchbar. Der 500k-Lauf war schneller als der 400k-Lauf, 41,8 gegen 83,3 Sekunden. Das ist Prefix-Caching, die Testprompts überlappen. Als Funktionsbeweis taugt es, als Performance-Messung nicht.
Das eigentliche Fazit
Der Weg von 65k auf ein halbes Megatoken war technisch unspektakulär: zwei Zahlen in einem Manifest, ein Rollout, sechs Minuten.
Interessant war, was dabei umfiel. Ein spekulatives Decoding, das erst unter Last zeigt, dass es kaputt ist. Ein KV-Cache, der wächst, wenn man ihm weniger Sequenzen gibt. Eine eigene Messung, die einer Wiederholung nicht standhielt. Und ein Tradeoff, den ich für ein Feature gehalten hatte.
Drei von vier dieser Erkenntnisse kamen zustande, weil eine Messung wiederholt wurde, die vorher schon ein Ergebnis geliefert hatte. Das ist der langweiligste Teil der Arbeit und offenbar der ergiebigste.