Der Standard wird lokal
Seit ein paar Tagen läuft meine tägliche Arbeit auf einem Modell, das im Keller steht. Das große kommerzielle Modell, das vorher alles Anspruchsvolle getragen hat, ist noch eingebunden, aber nur für den Notfall.
Das hätte ich vor drei Monaten nicht geglaubt. Nicht weil lokale Modelle schlecht wären, sondern weil sie immer an einer von drei Stellen scheiterten: zu hohe Latenz für ein Gespräch, zu kleiner Kontext für echte Aufgaben, oder sie konnten keine Tool-Calls und keine Bilder. Ein Modell, das nur Prosa schreibt, ersetzt keinen Arbeitsplatz.
Diese drei Stellen halten inzwischen. Die Latenz liegt unter dem, was ich lesen kann. Der Kontext reicht für ein halbes Buchregal pro Gespräch, also für Aufgaben, die über einen Vormittag laufen. Und es macht Tool-Calls und liest Bilder, was den Unterschied macht zwischen einem Chatfenster und etwas, dem man Arbeit übergibt.
Der Weg dahin waren vier Wochen Messen, und die Antwort war selten dort, wo ich gesucht habe. Zwei Wochen lang stand in meinen Notizen, ein bestimmter KV-Cache-Datentyp sei auf dieser Hardware unmöglich. Gut belegt, mehrfach nachgestellt, die Engine stürzte zuverlässig ab. Er war möglich. Ich hatte an der falschen Stelle nachgesehen, und die falsche Stelle hatte plausibel geantwortet.
Beim Aufräumen der Notizen habe ich gezählt, wie oft mir das passiert ist: zehnmal in vier Wochen. Immer dasselbe Muster, immer eine Quelle, die antwortet, statt der, die entscheidet. Das ist der rote Faden dieses Posts.
Darunter liegt die Technik: drei Modelle hinter einem Namen, jede Tuning-Position einzeln vermessen, und ein Modell, das so groß ist, dass es auf zwei Rechner verteilt werden muss. Alle Zahlen sind selbst gemessen, die Fehlmessungen eingeschlossen.
Was die Begriffe heißen
Fünf Begriffe, danach ist jede Tabelle lesbar. Wer sie kennt, springt zum nächsten Abschnitt.
Einzelrequest (tok/s): wie schnell eine Antwort für einen Nutzer entsteht. Das ist, was man im Chat spürt. 205 tok/s heißt: eine 600-Wort-Antwort in gut drei Sekunden.
Aggregat (tok/s): wie viele Token der Server insgesamt liefert, wenn mehrere Anfragen gleichzeitig laufen. Steigt mit der Parallelität, weil die GPU zwischen Sequenzen umschaltet, statt zu warten. Relevant, wenn Crons, Watchdogs und Chat zusammen anfragen. Für den eigenen Einzelrequest egal.
MTP / spekulatives Decoding: normalerweise kostet jedes Token einen vollen Modell-Durchlauf, also 27 Mrd. Parameter aus dem VRAM lesen. Das ist bandbreitenbegrenzt und die harte Grenze. MTP dreht das um: ein kleiner Drafter rät mehrere Token voraus, das große Modell prüft sie in einem Durchlauf. Was drin bleibt, ist gewonnen. Das Ergebnis ist bitgenau dasselbe wie ohne MTP, weil das große Modell jedes Token verifiziert. Kein Qualitätsverlust, konstruktionsbedingt.
Akzeptanzlänge: wie viele der vorgeschlagenen Token pro Durchlauf durchkommen. Die Akzeptanzrate allein ist irreführend: bei tieferen Draftern sinkt die Rate, aber die mittlere Länge steigt trotzdem. Auf die Länge schauen.
KV-Cache: der Speicher, in dem der Kontext eines laufenden Dialogs liegt. Größerer KV-Pool heißt mehr gleichzeitige lange Dialoge, nicht schnellere Tokens.
Der 27B und der TP4-Weg
Das Modell: ein 27B-Dense im NVFP4-Quantisierungsformat (4-bit Gewichte, ein Community-Build, nicht von mir gebaut), Tensor-Parallel 4 auf vier RTX PRO 4000 mit je 24 GB und 554 GB/s VRAM-Bandbreite pro Karte. Die Gewichte teilen sich auf vier Ranks à etwa 5,5 GiB, der Rest der Karte wird KV-Cache. Das ist der produktive Aufruf, so wie er im Cluster steht:
exec vllm serve /model/models/dense-27b-nvfp4 \
--served-model-name=dense-27b-xnode \
--host=0.0.0.0 --port=8500 \
--tensor-parallel-size=4 \
--distributed-executor-backend=ray \
--trust-remote-code \
--gpu-memory-utilization=0.85 \
--max-model-len=524288 --max-num-seqs=8 \
--attention-backend=TRITON_ATTN \
--kv-cache-dtype=fp8_e4m3 \
--hf-overrides='{"text_config":{"rope_parameters":{"mrope_interleaved":true,"mrope_section":[11,11,10],"rope_type":"yarn","rope_theta":10000000,"partial_rotary_factor":0.25,"factor":2.0,"original_max_position_embeddings":262144}}}' \
--enable-chunked-prefill --enable-prefix-caching \
--enable-auto-tool-choice --tool-call-parser=<xml-parser> \
--reasoning-parser=<reasoning-parser> \
--speculative-config='{"method":"<mtp-method>","num_speculative_tokens":4,"attention_backend":"TRITON_ATTN"}'
Vier Dinge an der Zeile sind die eigentlichen Entscheidungen, alles andere Füll:
--max-model-len=524288+--hf-overridesmit YaRNfactor 2.0. Das Modell deklariert 262k; der Override bringt es auf 512k.VLLM_ALLOW_LONG_MAX_MODEL_LEN=1steht daneben als Env, weil vLLM längere Werte sonst ablehnt.--speculative-configmitnum_speculative_tokens=4. Die Tiefe 4 ist der Produktivwert, sie kommt aus dem Sweep weiter unten.--enable-prefix-caching. Der Cache, um den sich die ganze Load-Balancing-Hälfte dreht.--kv-cache-dtype=fp8_e4m3zusammen mit zwei Angaben vonTRITON_ATTN. Das ist der Satz aus meinen Notizen, der zwei Wochen lang als unmöglich galt, und er braucht seinen eigenen Abschnitt.
Die Flags sind die aus der vLLM-CLI-Dokumentation (https://docs.vllm.ai), nichts davon ist eine Erfindung.
Die zwei Wochen, in denen fp8 unmöglich war
Der KV-Cache liegt live bei 1.519.365 Token, Concurrency 2,90× bei 524k pro Anfrage, gemessen am laufenden Prozess. Gegen den bf16-Stand derselben Instanz (648.858 bis 665.573 Token, je nach Prefill-Budget) ist das gut das Doppelte. Denselben Wert hatte ich zwei Wochen früher als unerreichbar abgeschrieben.
Die Beobachtung war korrekt: mit --kv-cache-dtype=fp8_e4m3 starb die Engine
reproduzierbar mit illegal memory access, dreimal nachgestellt. Meine
Erklärung dafür war „die Karte kann kein fp8-KV". Das klang plausibel, weil sm120
eine junge Architektur ist und Kernel-Lücken dort normal sind.
Der Weg, der die Frage tatsächlich beantwortet, ist nicht die Fähigkeitsliste,
sondern jedes Backend einzeln mit der echten Config zu fragen. vLLM hat dafür
validate_configuration. Das Ergebnis, über alle 35 Backends:
fp8_e4m3 AKZEPTIERT von: TRITON_ATTN, FLASHINFER, ROCM_ATTN,
ROCM_AITER_UNIFIED_ATTN, CPU_ATTN
fp8_e4m3 ABGELEHNT von: FLASH_ATTN ['FP8 KV cache requires FA3 on SM90
or FA4 on SM100'], FLEX_ATTENTION
Zwei von 35 sagen nein, und eines davon war das, das ich gelesen hatte. Die Karte hat fp8-Tensorcores. Was fehlt, ist eine sm120-Kernel-Variante in FlashAttention. Das ist eine Lücke in einer Bibliothek, keine Eigenschaft der Hardware, und die Unterscheidung ist der ganze Unterschied zwischen „geht nicht" und „geht, nur nicht so".
Bleibt die Frage, warum es dann trotzdem krachte, denn FLASH_ATTN war nicht
gesetzt. vLLM hatte selbst gewählt, und zwar FlashInfer: Using FLASHINFER out of potential ['FLASHINFER','TRITON_ATTN']. FlashInfer hat kein statisches
CUDA-Graph-Support, degradiert mit spekulativem Decoding auf
UNIFORM_SINGLE_TOKEN_DECODE und stirbt dann. TRITON_ATTN trägt
_cudagraph_support = ALWAYS fest im Code. Der Crash kam also nicht von fp8,
sondern von der Kombination aus fp8 und einer Backend-Wahl, die ich nie
getroffen hatte.
Und dann kam derselbe Fehler ein zweites Mal, an einer Stelle, die ich für
erledigt hielt. --attention-backend=TRITON_ATTN gesetzt, Crash weg, alles gut,
bis Batch-Breite 32: illegal memory access in Kerneln mit eagle_ im Namen.
Das ist die MTP-Draft-Schicht, und die hatte weiter FlashInfer gewählt, weil
--attention-backend nur auf der Hauptschicht greift.
Ich habe damals envs.py nach einer passenden Umgebungsvariable durchsucht,
keine gefunden und geschlossen, es gäbe kein Flag dafür. Also habe ich statt der
Ursache das Symptom behandelt und die Batch-Breite gekappt. Es gibt ein Flag. Es
ist keine Umgebungsvariable, sondern ein Feld innerhalb von
--speculative-config:
"attention_backend":"TRITON_ATTN"
Zwei Zeilen im Manifest, zwei Wochen Verzögerung, und beide Male war die Ursache dieselbe: ich hatte an einer Stelle nachgesehen, die zu meiner Frage passte, aber nicht zur Antwort.
--max-num-seqs ist die interessanteste Zahl der Zeile: sie steht bei 8.
Die isolierte Durchsatz-Messung sagt, 32 wäre besser: von 8 auf 32 bringt den
Aggregat-Durchsatz von 613 auf 972 tok/s (+46,8 %), mit dem mamba-float16-
Tuning obendrauf 984. Der Einzelrequest bleibt stehen. Prod steht trotzdem bei
8, und die Begründung dafür habe ich zweimal falsch aufgeschrieben, bevor sie
stimmte.
Erster Anlauf: mit MTP und fp8-KV crashte Batch-Breite 32 reproduzierbar mit
illegal memory access in den eagle-Kernels, ein Differential-Lauf mit MTP aus
lief sauber durch. Schluss damals: harte Scheduler-Grenze, MTP ist 1,9× am
Einzelrequest wert, also wird die Breite gekappt. Das war Symptom-Doktern. Die
Ursache war das Attention-Backend der Draft-Schicht, nicht die Breite.
vllm#54225 zeigt denselben
Crash bei einem Request mit 16 Tokens. Mit TRITON_ATTN auf beiden Schichten
fällt die Crash-Schwelle weg.
Die 8 bleiben trotzdem, jetzt aber aus dem richtigen Grund: bei Cron plus ein bis zwei Chat-Sessions liegt Breite 32 nie an. Das Cap ist keine Krücke mehr, sondern eine Zusicherung: vLLM lässt nie mehr als acht Sequenzen gleichzeitig laufen, und genau das macht den KV-Pool pro Sequenz vorhersagbar.
Und hier die Zahl, die mich zuerst wütend gemacht hat: 35 tok/s Einzelrequest, gemessen an derselben Engine, an der andere Tag 205 herauskamen.
Das war kein Systemrückstand. Der „Einzelrequest" war keiner. Vor dem Lauf liefen fremde Requests, der Prefix-Cache-Zähler stieg in zwölf Sekunden Ruhe um 600.000. Ein Wert, der in allen Bedingungen identisch ist, ist zuerst ein Verdacht gegen die Messung. Behoben: Ruheprobe als Pflicht, Fremdlast bricht den Lauf ab statt ihn zu vergiften. 205 tok/s ohne Thinking sind die ehrliche Zahl. Mit Thinking 55, und das ist ebenfalls keine Messung des Modells, son- dern des Denk-Anteils.
512k Kontext, und was YaRN kostet
Das Modell deklariert 262k. Auf 524.288 geht es mit YaRN-Rope, Faktor 2.0, und der Preis war die spannendste Zahl der ganzen Runde: null. Vorher 8/8 korrekt beim Retrieval, nachher 8/8, keine Regression in einer einzigen Aufgabe. Die Warnung aus der Modell-Karte („kann bei kürzeren Texten die Performance beeinträchtigen") galt nicht für unsere Aufgaben.
KV-Pool 732.892 Token, Concurrency 1,40x bei 512k – meine Architektur-Vorhersage war 1,37x, die Rechnung stimmt. Diese beiden Zahlen sind eine Aussage über den bf16-Betriebspunkt, an dem YaRN gemessen wurde, nicht über den heutigen Stand: mit fp8-KV liegt derselbe Pool bei 1.519.365 Token und 2,90x. Die YaRN-Rechnung wird davon nicht falsch, sie gilt nur pro Betriebspunkt. Durchsatz bei 512k: 600,2 tok/s bei c=8, +13 % gegenüber dem 262k-Betrieb, TTFT 84 ms (262k: 90 ms). Das erste Request nach Pod-Start war 39 Sekunden lang, im zweiten Lauf 3,78: Kaltstart, kein YaRN-Effekt. Summen über Einzelmessungen verdecken dies: ohne die Aufschlüsselung hätte ich YaRN eine Verlangsamung angehängt, die es nicht verursacht hat.
MTP: es gibt keine globale Tiefe
Der Drafter ist aktiv, und die Tiefe ist ein Hebel, der prompt-abhängig ist. Gemessen auf derselben Testinstanz, Tiefe 4 gegen 6 gegen 8:
| Tiefe | Prosa | Code | JSON/Struktur | mittl. Länge |
|---|---|---|---|---|
| 4 | 135,3 | 152,9 | 195,9 | 2,02 |
| 6 | 119,5 | 173,8 | 197,1 | – |
| 8 | 108,0 | 169,7 | 203,2 | 3,46 |
Prosa will 4, Code will 6, Struktur will 8. Die Akzeptanzrate sinkt von 0,505
auf 0,433, während die mittlere Länge von 2,02 auf 3,46 steigt. Auf die Länge
schauen. Und: reasoning_effort=xhigh (der Default des Modells) kostet 28 %
Akzeptanz. Wer ohne effort=low anfragt, verliert real Tempo.
MTP komplett aus ist in jedem Fall langsamer (kurz −21 %, 97k −11 %), auch wenn ein Forumthread das Gegenteil riet. Eigene Messung schlägt den Thread.
Was in Prod gelandet ist, und was ich weggeworfen habe
Zwei Hebel sind drin, jeder einzeln belegt, weil zwei gleichzeitig geschaltete Hebel eine nicht zuordenbare Differenz produzieren:
| Änderung | Wirkung auf das Aggregat |
|---|---|
--max-num-seqs 8 auf 32 | +46,8 % |
--mamba-ssm-cache-dtype float16 | +9,4 % obendrauf |
| zusammen | 613 auf 984 tok/s, also +60,6 % |
Der Einzelrequest bleibt davon unberührt, und der Beweis, dass der Engpass echt
war und nicht gerechnet: nach der Umstellung stand Running: 32 reqs, Waiting: 21 im Log. Mit den alten 8 Slots hätten 45 Anfragen gewartet.
Interessanter ist die Liste der Dinge, die ich probiert und wieder rausgeworfen habe. Sie ist länger als die der Gewinner, und jeder Eintrag hat einen Beleg aus dem Quellcode oder dem Startlog, keine Vermutung:
| Kandidat | Warum nichts |
|---|---|
--gdn-prefill-backend flashinfer | Verlangt SM90 oder SM10.x, unsere Karte ist sm120. Der 5,78-fache Gewinn im Hersteller-Blog kam von acht B200. Log sagt requested=flashinfer, läuft dann auf Triton |
cutedsl als dritte GDN-Option | Dieselbe Bedingung, dieselbe Absage |
--mamba-cache-mode all | Log: falling back to 'align' mode. Die Architektur kann es nicht, und align ist bei aktivem Prefix-Caching sowieso Default |
--async-scheduling | Greift laut Log, liefert 964 gegen 984 tok/s. Nichts |
| custom-all-reduce | Zwei Sperren im Code, should_custom_ar() verweigert bei vier Ranks ohne NVLink. Die Begründung stimmt: P2P 53 GB/s gegen 554 GB/s VRAM |
| Engine-Version hochziehen | 148,9 gegen 151,9 Median, das ist Streuung. Gefahrlos und wertlos |
| int8 für den MTP-Kopf | Der Kopf ist bf16, 849 MB, kostet aber nur 12,7 % der Schrittzeit. Maximal 6,8 % zu holen |
| Embeddings requantisieren | 2,6 GB auf einer Karte, bei vier Ranks nur 0,65 GB pro Rank |
Zwei davon sind die eigentliche Lehre. mamba-cache-mode all und der
FlashInfer-Prefill-Backend werden still ignoriert: vLLM nimmt das Flag an,
protokolliert im Startlog den Fallback und läuft weiter. Am Durchsatz sieht man
nichts, weil sich nichts geändert hat. Wer nur die Zahlen vergleicht, hat zwei
identische Konfigurationen gegeneinander gemessen und nennt das Ergebnis dann
Streuung.
Der Prefill hängt nicht am Chunk-Budget
--max-num-batched-tokens stand auf dem Default, und der ist 2048. Das steht
nicht in der Doku, das steht im Quellcode: DEFAULT_MAX_NUM_BATCHED_TOKENS in
vllm/config/scheduler.py. vLLM warnt beim Start selbst, man solle den Wert
erhöhen.
Meine Hypothese war: 2048er-Chunks sind zu klein für effiziente Matrixmultiplika- tionen, also muss der Einzelrequest-Prefill mit größerem Budget schneller werden. Vorher festgelegt, damit die Messung sie widerlegen kann: über 3.200 tok/s bei einer einzelnen Anfrage.
Gemessen, aggregierter Prefill in tok/s:
| parallele Anfragen | Budget 2048 | Budget 8192 | Delta |
|---|---|---|---|
| 1 | 2.782 | 2.872 | +3,2 % |
| 8 | 2.677 | 2.907 | +8,6 % |
| 16 | 2.526 | 2.926 | +15,8 % |
| 32 | 2.543 | 2.924 | +15,0 % |
Die Hypothese ist tot: 2.872 statt der geforderten 3.200, der Einzelrequest gewinnt nichts außer Streuung. Der Gewinn liegt in der Kurvenform. Mit 2048 fiel der Durchsatz unter Last ab, von 2.782 auf 2.543. Mit 8192 bleibt er flach. Das ist das Verhalten eines geteilten Budgets: 32 Anfragen im Prefill bekamen bei 2048 jeweils rund 64 Token pro Scheduler-Durchlauf. Ein Skalierungsgewinn also, kein Spitzengewinn, und die Wand bei etwa 2.900 tok/s weicht auch bei vierfachem Budget nicht.
Der Preis war der Teil, an dem ich falsch lag. Ich hatte lineares Wachstum der Aktivierungsspitze angenommen und daraus abgeleitet, dass der Kontext von 512k auf 262k runter muss. Der Startlog sagt: Spitze 2,9 GiB bei 2048, 2,9 GiB bei 8192, unverändert. Der KV-Pool schrumpft um 16.715 Token, das ist alles. Die geplante Kontextsenkung ist damit gestorben, bevor sie ausgerollt wurde.
Die unerklärte Hälfte
Und dann ist da die Zahl, die ich nicht auflösen konnte. 46,65 ms pro Decode-Durchlauf gemessen, 10,57 ms gerechnet aus Gewichten und Bandbreite. Faktor 4,4, und er ist kontextunabhängig, was ihn erst richtig unangenehm macht.
Ausgeschlossen habe ich, jeweils mit Messung statt mit Argument: PCIe (55,5 GB/s Host auf GPU sind 88 % von Gen5, die Verbindung ist es nicht), Kernel-Launch- Overhead (CUDA-Graphs sind aktiv, im Startlog nachgewiesen), den MTP-Kopf (12,7 % der Schrittzeit, deckt Faktor 4,4 nicht). Bleibt als wahrscheinlichster Kandidat die 48 GDN-Layer, die auf dem Triton-Referenzkernel laufen, weil der fusionierte Kernel SM90 oder SM10.x verlangt. Neuere Builds haben ihn, setzen ihn bei aktivem MTP aber wegen der Kopfratio 48:16 aus, verlangt wird 8:1. Upstream offen.
Das ist die Wand, an der dieser Tuning-Bogen endet, und sie ist nicht mit Flags zu bewegen. Alles bis hierhin waren Konfigurationsentscheidungen. Was hier fehlt, ist ein Profiler-Lauf und wahrscheinlich ein Kernel, den ich nicht schreibe. Ich schreibe es hin, weil ein Post, der nur die gelösten Teile zeigt, ein falsches Bild von der Arbeit gibt.
Die vier Messfallen, die alle vier zugeschlagen haben
- Fremdlast. 279 tok/s auf dem Chat-Node gegen 984 auf dem leeren
Batch-Node, identische Config. Ein
require-idle-Check, der abbricht statt zu messen. - CUDA-Graph-Capture. Der erste Batch einer neuen Breite kostet 40 bis 85 Sekunden und liefert 6,5 tok/s. Kein Messwert. Warmup wird verworfen.
- Streuung. Standardabweichung 13 bis 17 auf einem Mittel von 145. Mein „A ist besser als B" war damit nie ein Befund. Jetzt Median über vier Wiederholungen.
- Still ignorierte Flags.
mamba-cache-mode allfällt aufalignzurück,gdn-prefill-backend flashinferauf Triton. Beide nur im Startlog sichtbar, nie am Durchsatz. Ein A/B damit vergleicht zwei identische Konfigurationen.
Und eine fünfte, die das Prüfwerkzeug selbst betraf: der Tiefen-Sweep meldete „Flag nicht gefunden", weil vLLM das Flag in Anführungszeichen loggt. Die Tiefe hat gegriffen. Gegenprobe gehört auf die Wirk-Ebene, nicht auf die Log-Textebene.
Nebenbei, weil es drei Wochen gedauert hat, es zu glauben: nvidia-smi,
lspci und sysfs melden konstant PCIe Gen1/2,5 auf diesen Boards, auch unter
Volllast. Das ist falsch, ein Melde-Artefakt bei AMD-Root-Complexen mit ASPM.
Nur der gemessene Host-auf-GPU-Durchsatz ist der Beweis.
Ein Name, zwei Instanzen
Der Chat und die Batch-Jobs laufen hinter einem einzigen Modellnamen. Zwei baugleiche Instanzen, eine auf jedem der beiden GPU-Nodes, und der Proxy verteilt nach least-busy. Gemessen: 16 parallele Anfragen, 8 auf dem einen, 8 auf dem anderen Node, null Fehler.
Warum das überhaupt ein Thema war: von 127 Cron-Jobs fragen 124 den Modellnamen an, ohne eine Instanz zu wählen. Beide Instanzen sahen von außen identisch ausgelastet aus, aber die Cache-Anfragen lagen bei 125 Millionen auf der einen und 12,6 Millionen auf der anderen. Ein Name, der beide trägt, macht die Schieflage zum Routing-Problem statt zur Architektur-Frage.
Eine Instanz gezielt anzufragen ist inzwischen auch nicht mehr möglich, und das ist Absicht: die alten Einzelnamen zeigen als Alias alle auf dieselbe Gruppe. Wer den früheren Batch-Namen schreibt, landet über den Alias wieder im least-busy-Verfahren. Das nimmt einem die Möglichkeit, den Cache per Hand zu sortieren, und genau deshalb muss das Routing es können.
Failover ist die günstigere Hälfte: zwei Retries und 60 Sekunden Cooldown innerhalb der Gruppe. Fällt eine Instanz aus, trägt die andere. Die Timeout- Kette muss nach außen monoton wachsen, sonst bricht die oberste Schicht ab, bevor der Fallback überhaupt startet. Und die eigentliche Falle: der aktive Timeout ist nicht unbedingt der, den man gesetzt hat. In der Route stand 900 Sekunden, der Abbruch kam exakt bei 300,0: ein clusterweiter Idle-Timeout am Gateway, der hinter allen Diensten greift. Ein Abbruch auf einer runden Zahl ist immer eine Config, nie das Modell.
Session-Sticky ist die Hälfte, bei der ich zweimal die Meinung geändert habe. Die Logik klingt naheliegend: eine Session bleibt auf ihrer Instanz, damit der Prefix-Cache warm bleibt. Gemessen ist der Preis: derselbe Kontext auf der anderen Instanz ist vollständig kalt. 1,23 Sekunden First-Token werden zu 4,65, die Hit-Rate 93 % auf 0, Faktor 4,8. Ein Load-Balancer, der zwischen zwei Instanzen wechselt, bezahlt diesen Preis bei jeder zweiten Anfrage.
Lange stand in meinen Notizen, der Proxy könne das nicht. Das war eine Aussage über die Version, die ich fuhr, keine über das Werkzeug: die aktuelle kann es, und seit dem 28.8. läuft es. Ein gemeinsamer Redis hinter beiden Proxy-Replicas, eine Session-ID pro Konversation, TTL 600 Sekunden. Abnahme am selben Tag: sechs Requests mit derselben Session-ID, sechs Mal dieselbe Instanz, und der Pin lag im Redis statt nur im Speicher eines einzelnen Replicas. Das ist der Teil, auf den es ankommt, denn nur so gilt er auch für den zweiten Proxy.
Offen ist der Aufrufer-Vertrag: die Clients müssen die Session-ID im Request mitschicken, und meine tun das heute nicht. Solange sie es nicht tun, läuft least-busy weiter und die 4,8-Faktor-Cache-Abgabe ist der Preis. Ob der Pin ihn wirklich trägt, ist der nächste A/B, nicht das nächste Argument.
Der Proxy selbst läuft hochverfügbar: zwei Replicas auf zwei verschiedenen Nodes mit Anti-Affinity, PodDisruptionBudget erlaubt eine Störung, Postgres hinterher ebenfalls zweifach.
Unscensored, ohne die intimen Teile
Gleiches 27B, ein dritter Checkpoint: die entalignierte NVFP4-Variante, eigener Slot auf dem Chat-Node, weil beide auf dieselben vier Karten zugreifen und nicht parallel laufen können. Der Aufruf, so wie er im Cluster steht:
exec vllm serve /model/models/dense-27b-uncensored-nvfp4 \
--served-model-name=dense-27b-uncens \
--host=0.0.0.0 --port=8500 \
--tensor-parallel-size=4 \
--trust-remote-code \
--gpu-memory-utilization=0.85 \
--max-model-len=524288 --max-num-seqs=8 \
--max-num-batched-tokens=8192 \
--attention-backend=TRITON_ATTN \
--kv-cache-dtype=fp8_e4m3 \
--mamba-ssm-cache-dtype float16 \
--enable-chunked-prefill --enable-prefix-caching \
--enable-prompt-tokens-details \
--enable-auto-tool-choice --tool-call-parser=<xml-parser> \
--reasoning-parser=<reasoning-parser> \
--speculative-config='{"method":"<mtp-method>","num_speculative_tokens":4,"attention_backend":"TRITON_ATTN"}'
Diese Instanz ist inzwischen auf denselben Betriebspunkt gezogen wie die
Standard-Variante: 512k, max-num-seqs 8, fp8-KV auf TRITON_ATTN, MTP-Tiefe 4.
Der einzige verbleibende Unterschied ist der, auf den es ankommt: das
--served-model-name ohne Alias. Das ist kein Äquivalenz-Trick: der
Name dense-27b auf dem Standard-Endpoint zeigt hier nicht auf. Wer ihn
anfragt, bekommt die standardisierte Variante.
Der Sicherheitsaspekt ist der Punkt, den ich zuerst nennen will. Das Modell steht nicht auf dem Standard-Endpoint. Es hat einen eigenen Served-Model-Name, ohne Alias, einen eigenen Service und einen eigenen Selector. Wer den Chat-Modellnamen anfragt, bekommt die standardisierte Variante, Punkt. Crons, Distiller, Watchdogs und der öffentliche Traffic können nicht versehentlich darauf landen, und es gibt keinen Switch, der sie umleitet. Das Unscensored-Modell ist ein separater Endpoint mit eigenem Zugriffsweg, keine Schalterstellung am Standard.
Die drei Fallen aus der Modell-Karte, alle drei upstream gemessen und bei uns übernommen:
reasoning_effortmuss aufmediumstehen. Das ist kein Tuning, sondern die Betriebsbedingung. Beim Default verbrennt das Modell sein komplettes Token-Budget im Denkblock: der wächst über 18.000 Zeichen, degeneriert teils in eine Endlosschleife, und es kommt keine Antwort. Upstream belegt das mit einem 9-Fälle-Gate in drei Konfigurationen, 27 Läufe. Beimediumsind es 1.000 bis 3.000 Zeichen und alle Läufe liefern.- Die 15 MTP-Module stehen in der Quantisierungs-Ausnahmeliste und müssen dort bleiben. Behandelt die Engine den bf16-MTP-Kopf als NVFP4, bricht der Drafter still: 0 % Akzeptanz, langsamer als ganz ohne.
- W4A4 ist der einzige Pfad. W4A16 lehnt die Engine ab, weil die Tile-Größe nicht aufgeht. Kein Quant-Flag setzen, die Engine erkennt es aus der Config.
Dafür ist der Checkpoint der schlankste der drei verglichenen: 4,79 GiB Gewichte pro Rank gegen 5,45 beim Standard. Der KV-Pool folgt derselben fp8-Entscheidung wie der Standard-Endpoint. Die Modell-Karte serviert ebenfalls mit fp8, und mit TRITON_ATTN auf Haupt- und Draft-Schicht ist das auch bei uns der stabile Pfad. Neue Checkpoints starten sonst auf ihrer deklarierten Kontextlänge; hier läuft dieselbe YaRN-Extrapolation wie am Standard, weil beide Instanzen eine Modellgruppe bilden und ein gemeinsames Fenster brauchen.
Das 125B auf zwei 72-GB-Karten
Das zweite Modell in diesem Post ist eine andere Gewichtsklasse: ein 125B-MoE mit 512 Experten pro Layer plus eine 51B-Ngram-Tabelle (PLE), zusammen 126 GiB, 419 Dateien. Die zwei RTX PRO 5000 72GB, die im Cluster liegen, stecken in zwei verschiedenen Hosts. Ein Node-TP2 ist physisch unmöglich, also Cross-Node-TP2 über Ray und RoCE, exakt der Rahmen, auf dem die 27B- Instanzen seit Wochen laufen. Der Aufruf auf dem Leader, Worker-Seite identisch bis auf die Ray-Adresse:
exec vllm serve /model/models/flash-next-uncens-nvfp4 \
--served-model-name flash-next \
--host 0.0.0.0 --port 8502 \
--tensor-parallel-size 2 \
--distributed-executor-backend ray \
--trust-remote-code \
--load-format safetensors --safetensors-load-strategy lazy \
--gpu-memory-utilization 0.98 \
--max-model-len 262144 \
--max-num-seqs 16 \
--max-num-batched-tokens 8192 \
--no-enable-flashinfer-autotune \
--compilation-config '{"mode":0,"cudagraph_mode":"FULL_DECODE_ONLY"}' \
--mamba-ssm-cache-dtype float16 \
--enable-chunked-prefill --enable-prefix-caching \
--enable-prompt-tokens-details \
--enable-auto-tool-choice --tool-call-parser <xml-parser> \
--reasoning-parser <reasoning-parser>
Drei Werte sind hier die eigentliche Geschichte:
--gpu-memory-utilization 0.98. Gewichte 125,96 GiB gegen 131,9 addressierbar. Mit 0.95 wären 2,3 GiB Reserve übrig, zu wenig für den NVFP4-Swizzling-Overhead. 0.99 bringt keine nutzbare KV-Reserve, widerlegt mit einer byte-identischen Signatur. Die Rechnung steht in einem eigenen Skript im Repo, nicht im Kopf.PLE_FORCE_FP8=1im Env, kein Flag: der einzeilige Patch, der die 51B-Ngram-Tabelle als FP8 statt bfloat16 lädt. Ohne ihn 95,37 GiB, mit ihm 47,68. Der Zwischenschritt war selektives CPU-Offload (--cpu-offload-gb 13), das den OOM nur vom VRAM in den Node-RAM verschoben hat. Der Patch ersetzt es, und das Offload ist aus der Zeile draußen.--no-enable-flashinfer-autotune+--compilation-configFULL_DECODE_ONLY. Beides kommt aus dem offiziellen Recipe für die RTX-Variante; das Autotune würde auf diesen Karten den Decode-Pfad degradieren.
Staging: 126 GiB von einem Hugging-Face-Host. Der erste Lauf hing nach 27 GiB, der zweite wurde nach dem Abbruch auf 0,1 MB/s gedrosselt, der Mirror dieselben Datei mit 24,1 MB/s im selben Zeitfenster. Faktor 240. Die Standard- Lib kommt mit dem Redirect plus Xet-Storage nicht klar, und der empfohlene Transfer-Beschleuniger ist für Xet der falsche. Also ein eigenes Skript mit Range-Resume, lokal verifiziert: 21,9 MB/s, und eine absichtlich halbierte Datei wurde korrekt aufgefüllt.
Das OOM, das kein Kontext-Problem war. Der Erststart mit voller 262k Kontextlänge starb am VRAM. Die naheliegende Theorie (Kontext zu groß) fiel an einem A/B: 32k und 262k lieferten byte-identisches OOM, 70,95 gegen 71,12 GiB, 400 MiB Allokschritt. Byte-identisch nach einer Parameteränderung spricht den Parameter frei. Die Ursache saß im Gewichts-Load: die Engine baute die Ngram-Tabelle als bfloat16, also 95,37 GiB statt 47,68 (FP8), weil das Quantisierungs-Config des Checkpoints nicht auf den erwarteten Typ passte und eine isinstance-Prüfung durchfiel. Die korrekte FP8-Klasse existierte im Image und wurde nie gewählt. Der Fix ist ein einzeiliger, env-gated Patch vor dem Typ-Check. Die 51B-Tabelle ist der Grund, warum dieses Modell überhaupt ein Cross-Node-Modell ist: pro Rank bräuchte sie als BF16 allein mehr als eine Karte.
Offload war der Weg, der nicht ging. Das offizielle Recipe offloaded die
Tabelle in den Host-RAM. Cross-node ist das strukturell gesperrt: der
Offload-Hook existiert nur im Single-Prozess-Executor, und die Konfig-Prüfung
verlangt nnodes == 1. Und meine eigene Regel: keinen Host-RAM nehmen, alles
auf die GPUs. Der generische selektive Offload (--cpu-offload-gb 13 --cpu-offload-params ngram_embedding) trägt die VRAM-Rechnung trotzdem, und
die Zahl 13 hat einen gemessenen Grund: mit 26 wanderte der Fehler nur. Statt
GPU-OOM killte der Ray-Monitor den Worker, weil er den ganzen Node zählt,
inklusive der 27B-Instanz mit ihren 23 GiB, und bei 95 % zuschlägt. 13 GiB
Offload lassen 49,7 GiB auf der GPU und den Node bei 72 %.
GPU-Pinning per UUID. Der Container sieht trotz DRA-Claim alle fünf Karten
des Nodes, und der nvidia-smi-Index der 72er ist 1, der Torch-Index 0. Die
Reihenfolge kippt zwischen den beiden Sichten. Ein Index-Pin landet still auf
der falschen Karte. CUDA_VISIBLE_DEVICES mit der UUID blendet die anderen
vier aus, und der Rank sieht genau eine Karte.
Probes, weil der Start 270 Sekunden dauert. Ohne startupProbe gilt der Container sofort als gestartet, und das Group-Restart-Policy baut die gesamte zwei-Node-Gruppe neu, wenn der Pod beim Laden zuckt. Gemessen: Pod alle drei Minuten ersetzt, die Ladezeit kam nie durch. Also eine startupProbe mit 20 s × 60 = 1200 s Budget, länger als der Start, sonst dreht das Restart-Policy die Gruppe endlos. Die readinessProbe ist dagegen raus: 60 Sekunden Budget gegen 270 Sekunden Laden, und bei Group-Rebuild genügt ein einzelnes not-ready, um die ganze Gruppe endlos neu aufzubauen.
Der MTP-gegen-Kontext-Tradeoff. Der MTP-Kopf kostet 2,24 GiB (Model Loading 64,36 statt 62,12). Danach bleiben 2,07 GiB für den KV-Cache, und 262k Token brauchen 5,68. Maximaler Kontext mit MTP: etwa 95.500 Token. Gemessen, n=12 je Variante, gleiche Prompts und Seeds:
| Konfiguration | tok/s | Anmerkung |
|---|---|---|
| 262k, ohne MTP | 74,6 | Produktivstand |
| 96k, mit MTP | 111,6 | +52,2 % durch MTP |
| 96k, ohne MTP | 73,3 | Kontext-Effekt nur −1,7 % |
MTP bringt also +52 %, lässt sich aber auf diesem Checkpoint nicht mit 262k kombinieren. Eine ältere Notiz („MTP kostet 0 GiB, bringt +2,9 %") war falsch gemessen: die Zahl kam aus einem Pod, der in einer Restart-Schleife lief, und gehört zu einem anderen Lauf. Erkennbar nur an der Prozess-ID.
Dass es an diesem Checkpoint hängt und nicht am Modell, ist der eigentliche Punkt an der Bilanz. Es fehlen 3,61 GiB, ein Checkpoint müsste also rund 6 % kleiner sein, ohne Qualität zu verlieren. Das klingt nach einer Suchaufgabe, und genau die habe ich als nächstes gemacht.
123 Kandidaten prüfen, ohne 126 GiB zu laden
Weil 262k und MTP auf diesem Checkpoint nicht zusammenpassen, war die naheliegende Idee: ein anderer Checkpoint, mit kleinerem MTP-Kopf oder besser gepacktem PLE. Also suchen.
Wie teuer Raten dabei ist, hatte der Vorlauf gezeigt. Ein Kandidat wurde
vollständig geladen, 102 GiB, und scheiterte dann am Loader mit
ValueError: There is no module or parameter named 'ngram_embedding.shard_0'.
Ein Nachmittag für eine Information, die im Datei-Header stand.
Der Header eines safetensors-Files liegt am Dateianfang, und HTTP kann Teilbereiche lesen. Ein Range-Read von wenigen Kilobyte pro Shard genügt also für Tensornamen, dtypes und Shapes:
u = f"https://huggingface.co/{repo}/resolve/main/{shard}"
n = int.from_bytes(get(u, rng="0-7"), "little") # Header-Länge
hdr = json.loads(get(u, rng=f"8-{8+n-1}")) # Namen, dtype, shape
Damit lässt sich vor jedem Download beantworten: Größe pro dtype-Gruppe, ist ein MTP-Kopf drin, liegt das PLE in FP8 oder BF16, wie viele Experten pro Layer. Die Filterkette über 123 Repos:
| Stufe | Kriterium | übrig |
|---|---|---|
| Suche | acht Suchbegriffe über die API | 123 |
| Format | MXFP4, GGUF, AWQ, W4A16 raus | 30 |
| Struktur | Config und Index lesbar | 30 |
| MTP und PLE | Tensornamen geprüft | 8 |
| Größe | kalibrierte VRAM-Rechnung | 1 |
Die Ausschlussgründe sind interessanter als das Ergebnis. MXFP4 sieht auf dem
Papier passend aus, der Scheme-Gate meldet get_min_capability() = 80, also
“läuft ab Ampere”. Der relevante Gate ist ein anderer:
from vllm.model_executor.layers.quantization.utils.mxfp4_utils import mxfp4_experts_quant_supported
mxfp4_experts_quant_supported(120) # False
Die MoE-Kernel brauchen sm100. Auf sm120 fällt das Ganze auf BF16-Dequant zurück, also mehr VRAM und langsamer. Im Container verifiziert, nicht aus der Modellkarte geglaubt.
Ein anderer Kandidat trug unsere Kartenklasse im Namen und war 11 GiB kleiner.
Die Modellkarte erklärt, woher: 512 auf 448 Experten pro Layer, 64 Experten aus
jedem der 48 MoE-Layer entfernt, kalibrierungsfrei nach mean|W| / RMS(W). Das
sind 12,5 % weniger Modell, kein besserer Quant, und dazu kein einziger Benchmark.
Darunter stand Requires a patched SGLang, stock builds emit GARBLED OUTPUT.
Zwei Repos, ein Checkpoint
Bei der ungefilterten Variante gab es zwei Kandidaten desselben Autors, einmal als UNCENSORED und einmal als ABLITERATED. Die Frage war, welcher besser ist. Die Frage hat keine Antwort:
207 Dateien in beiden Repos
identischer sha256 : 207
unterschiedlich : 0
Derselbe Checkpoint, zweimal veröffentlicht. Geprüft über die Blob-Hashes der API, ohne Download. Ein A/B über zwei identische Dateibäume hätte Rauschen produziert, das wie ein Ergebnis aussieht, und ich hätte es interpretiert.
Eine Formel, die 23 GiB neben der Wahrheit lag
Für die Größenabschätzung brauchte ich VRAM pro GPU aus der Plattengröße. Meine erste Formel: Gewichte ohne PLE, geteilt durch zwei Karten. Damit kamen für alle Kandidaten Werte heraus, die um 23 GiB falsch lagen.
Aufgefallen ist das nur, weil ich sie an einem Fall mit bekanntem Ergebnis geprüft habe. Der laufende Checkpoint meldet 62,12 GiB pro GPU, und die 206 Shards auf der Platte lassen sich nachrechnen:
TOTAL 125,87 GiB MTP 4,86 | PLE 47,75 | MoE 63,72 | Rest 9,55
Formel Ergebnis Delta zu 62,12
total/2 62,94 0,82 ← trifft
(MoE+Rest)/2 + PLE/2 60,51 1,61
total − PLE 78,13 16,01
(total − PLE)/2 39,06 23,06 ← meine erste Formel
Alles wird über beide Karten geshardet, das PLE eingeschlossen. Und der Punkt, in dem ein Fremd-Checkpoint besser schien, war gar kein Unterschied: unser eigener hat das PLE längst in FP8. Ohne diese Kalibrierung hätte ich einen 115-GiB- Download auf einer nie geprüften Annahme empfohlen.
Der Download, der zweimal scheiterte
126 GiB von einem Hugging-Face-Host, und die Standard-Bibliothek war der falsche
Weg. snapshot_download hing bei 27 von 126 GiB, Prozess lebte, TCP-Verbindung in
CLOSE_WAIT. Nach dem Abbruch drosselte der Host anonyme Zugriffe auf 0,1 MB/s,
während ein Mirror dieselbe Datei im selben Zeitfenster mit 24,1 MB/s
lieferte. Faktor 240. Mit dem Mirror als Endpunkt stirbt die Bibliothek dann am
HEAD-Redirect.
Also ein eigenes Skript, direkter HTTP-Download mit Range-Resume gegen den Mirror. Endstand 126,0 von 126,0 GiB, danach verifiziert: 206 von 206 Shards, 0 kaputte Header, 296.475 Tensoren. Und eine absichtlich halbierte Datei wurde beim nächsten Lauf korrekt aufgefüllt, damit der Resume nicht nur behauptet ist.
Das Bittere daran: beide Fehler standen als Kommentar im vorhandenen Stage-Job im Repo. Ich habe sie nachgebaut, statt vorher zu lesen.
Was am Ende der Kette steht: der neue Checkpoint ist strukturell identisch zum alten, dieselbe Architektur, dieselben 13 Ausschluss-Muster in der Quant-Config, 512 Experten, PLE ohne gepackte Tensoren. Genau das war die Bedingung dafür, dass unser FP8-Patch und die 262k-Konfiguration unverändert greifen. Laut Modellkarte kostet die Filterentfernung 0,18 Prozentpunkte MMLU, gemessen über 2.280 Fragen mit derselben Harness. Das sind Angaben des Autors, nicht von mir nachgemessen, und so stehen sie hier auch da.
Damit ist die Arbeitsteilung fertig: das 125B ist der Kontext-Betrieb, 262k nativ, ohne YaRN, ohne Extrapolation, ohne MTP. Der 27B bleibt der Schnellere mit 205 tok/s im Einzelrequest, 984 im Aggregat und 512k über YaRN. Zwei verschiedene Jobs, kein Ersatzverhältnis.
Der Fehler, der ein Fehler bleiben darf
Zum Schluss der Teil, wo die richtige Entscheidung war, nichts zu tun. Bei JSON-erzwungener Ausgabe wirft die Engine reproduzierbar:
ERROR [backend_xgrammar.py:162] Failed to advance FSM for request
chatcmpl-... for tokens 271. Please file an issue.
ERROR-Level, bis zu drei Wellen pro Anfrage. Token 271 dekodiert zu '\n\n'.
Bevor man sowas jagt, gehört die Frage geklärt, ob überhaupt etwas kaputt ist. Zehn
aufeinanderfolgende Anfragen mit JSON-Modus und aktivem Reasoning: zehn valide
Antworten, null kaputt, alle mit finish_reason=stop. Der Request erholt sich
selbst, xgrammar verwirft nur den Draft-Vorschlag und lässt die Position neu
würfeln.
Der Auslöser ist trennscharf messbar:
FSM-Fehler vorher : 3
nach 3x JSON-Modus MIT reasoning : 6 (+3, also einer pro Anfrage)
nach 3x JSON-Modus OHNE reasoning : 6 (+0)
Meine Fix-Hypothese war ein Config-Feld namens enable_in_reasoning, das auf
False stand. Name und Kontext passten perfekt. Die Docstring sagt: “Whether to
use structured input for reasoning.” Das legt die Grammatik zusätzlich auf den
Denktext, also mehr Zwang statt weniger. Auf True hätte ich es wahrscheinlich
verschlimmert. Ein Config-Feld, dessen Name zur eigenen Hypothese passt, ist kein
Beweis.
Upstream ist es exakt derselbe Fall, offen, mit einem anderen Drafter:
vllm#53777 beschreibt
dieselbe Token-ID, dieselbe Grammatik, dieselben Retry-Wellen und nennt es
“a draft-vs-grammar interaction bug rather than corruption”. Der zugehörige PR
#44392 filtert
Reasoning-Boundary-Tokens vor dem FSM-Advance und erklärt damit auch, warum es
gerade '\n\n' ist: Whitespace am Übergang aus der Denkphase landet mit im
akzeptierten Batch.
Also bleibt es liegen. Der naheliegende Workaround kostet Reasoning, der andere kostet 52 % Tempo, und der richtige Fix gehört upstream. Einen Alert dafür zu bauen wäre der schlechteste Zug: er würde im Normalbetrieb dauerfeuern und genau den Fall verdecken, auf den es ankommt, nämlich einen FSM-Fehler mit kaputter Antwort.
Was das 35B-MoE nicht konnte
Das 35B-MoE war der Arbeitstier-Platz: Chat, Delegation, Approval. Baseline 2026-07-26: 155,7 tok/s Einzelrequest, 806 bei c=8, alles auf einem Node mit kurzem Kontext. Zwei Probleme machten es zum Problem.
Erstens Stabilität. Mit max-num-seqs über 3 stirbt das Modell reproduzierbar
ab c=2: HTTP 503, Engine-Neustart. Das Symptom ist dasselbe, das ich beim 27B
zunächst der Batch-Breite zugeschrieben hatte. Dort war die Ursache das
Attention-Backend der MTP-Draft-Schicht, nicht die Breite. Beim MoE habe ich
diesen Nachweis nie geführt: es lief noch auf der alten Erklärung, und
max-num-seqs 3 beendete jede nennenswerte Parallelität. Ob TRITON_ATTN auf
beiden Schichten auch hier die Schwelle aufgehoben hätte, ist offen. Die Frage
hat sich mit der Ausmusterung erledigt, aber sie gehört ehrlich hierhin.
Zweitens: Cross-Node war die schwächere Topologie für dieses Modell. Die Zahlen dazu stehen in der Tabelle unten.
Was der 27B daraufhin erledigte, was das MoE nicht konnte:
| Workload | MoE-35B | 27B-Dense |
|---|---|---|
| c=8 Aggregat | 356,7 tok/s | 496,5 (+39,2 %) |
| TTFT p95 bei c=8 | 3,025 s | 205 ms |
Slots (max-num-seqs) | 3 (Stabilitätsgrenze) | 8 |
| Vision | keine Abnahme in dieser Ära | 5/5 gemessen |
| Unscensored-Variante | nicht verfügbar | eigener Endpoint |
Beide Zahlen oben kommen aus demselben Messwerkzeug am selben Abend
(2026-08-12, 200 Ausgabe-Token pro Request, c=8, beide Instanzen Cross-Node-TP4).
Die Instanzen waren nicht identisch konfiguriert: der 27B lief bei
262.144 mit max-num-seqs 8, das MoE bei 524.288 mit max-num-seqs 3. Bei
200 Ausgabe-Token auf kurzen Prompts berühren die Kontextgrößen die Messung
nicht, aber die Slots-Grenze des MoE ist Teil der Zahl: 356,7 tok/s ist der
Durchsatz unter Stabilitätsgrenze, und genau das ist der Befund.
Und weil das der Punkt ist, an dem ich mich selbst erwischt habe: in meinem Ausmusterungs-Commit steht „+45,7 % Durchsatz bei c=8 (600 vs 412 tok/s)". Die 600,2 sind belegt, das ist der 512k-Lauf des Dense. Die 412 sind es nicht. Ich habe sie in keinem Rohergebnis wiedergefunden, in keinem Messprotokoll und in keiner Ergebnisdatei: der Wert steht ausschließlich in meinem eigenen Commit-Kommentar. Rückgerechnet ist er aus der Prozentzahl entstanden (600,2 / 1,457 = 411,9), und die Prozentzahl aus einem früheren „+30 % vorn" gegen einen MoE-Stand, den ich nie mit dem 512k-Lauf zusammen gemessen habe. Eine Zahl, die nur als Rückrechnung ihrer eigenen Schlussfolgerung existiert, ist keine Messung. Sie ist hier ersetzt durch das Paar, das wirklich gegeneinander gelaufen ist.
Die Vision-Abnahme war der Trigger, nicht nur eine nette Zahl: ein selbst erzeugtes Bild mit bekanntem Inhalt plus einem Zufallstoken, der nirgends im Trainingsmaterial stehen kann. Formen, Farben, OCR exakt mit Ziffern, räumliche Beziehung, und die Negativkontrolle: „Katze im Bild?" auf ein Bild ohne Katze, korrekt verneint. Ohne die Negativkontrolle beweisen vier Treffer nichts.
Die ehrliche Einzelzahl: sequenziell ist das MoE weiterhin schneller, 140,7 gegen 108,0 tok/s im selben Lauf wie die Tabelle, weil es 3B aktive Parameter gegen 27B Dense bewegt. Was ich kaufe, ist die Parallelität, der Kontext und die Stabilität, nicht der Einzelrequest.
Die Ausmusterung, 2026-08-16: sieben Konsumenten umgebogen, und ich hatte anfangs zwei gezählt. Die Volltextsuche über Apps und Scripts fand sieben, zwei davon NetworkPolicies, die still gebrochen wären: die Policies nannten den alten Port (das MoE lauschte auf 8300, der 27B auf 8500) und einen Label-Schlüssel, den der neue Pod gar nicht trägt. Eine Policy war seit 13 Tagen wirkungslos, der Traffic kam nur durch, weil ohne greifende Ingress- Policy nichts blockt. replicas 0 statt gelöscht: Manifest, PVC und Service bleiben, und wer es zurückwill, zahlt nicht 17,5 GiB Staging. Das Frontier- Modell vom großen US-Anbieter läuft seither nur noch im Notfall, wenn lokal steckt.
Was als Nächstes kommt
Zwei Wände, beide offen.
Die erste sitzt auf dem 125B: der MTP-Head. Der aktuelle Checkpoint listet ihn in der Quant-Ausnahmeliste, liegt also komplett in bfloat16 und kostet 4,856 GiB VRAM, die der KV-Cache sonst bräuchte. Es gibt einen alternativen Community-Checkpoint im reinen NVFP4, dessen MTP-Head 1,475 GiB kostet: mit ihm passen MTP und 262k zusammen auf die zwei Karten, mit Reserve. Gestaged, aber unprobt, und das ist der Stand, an dem er bleibt, bis der Langkontext- Beweis (Nadel im Stroh, 120.000 Token) gegen den bekannten Stand läuft. Community-Quants kauft man nicht, man misst sie.
Die zweite Wand sitzt auf dem 27B: Faktor 4,4, 48 GDN-Layer auf dem Triton-Referenz-Kernel, upstream offen. Sobald der fusionierte Kernel auf sm120 mit aktivem MTP funktioniert (er verlangt eine 8:1-Relation, wir haben 48:16), bekommt der 27B einen zweiten Schub, und die Tuning-Zahlen dieses Posts sind ein Betriebspunkt. Nächster A/B entscheidet, wie viel.
Was ich aus den vier Wochen tatsächlich mitnehme, ist keine der Zahlen oben. Es ist die Liste der zehn Fälle, in denen ich die falsche Quelle gefragt habe:
| gefragt | entscheidet aber |
|---|---|
| Fähigkeitsliste des Backends | validate_configuration mit der echten Config |
get_min_capability() | der Kernel-Gate für dieses Feature |
| die Env-Variablen der Engine | ein Feld innerhalb von --speculative-config |
| der Name eines Config-Felds | dessen Docstring |
| meine eigene VRAM-Formel | ein Fall, dessen Ergebnis ich schon kenne |
| meine Rückrechnung aus einer Prozentzahl | das Messprotokoll |
nvidia-smi, lspci, sysfs | der gemessene Durchsatz |
| eine Logzeile | die Prozess-ID darunter |
| der Durchsatz nach einer Flag-Änderung | die Fallback-Zeile im Startlog |
| zwei Repo-Namen | die sha256 der Dateien darunter |
Keine dieser Quellen hat gelogen. Jede hat korrekt beantwortet, was ich gefragt habe. Der Fehler war jedes Mal derselbe: ich habe die Quelle genommen, die antwortet, statt der, die entscheidet. Und die antwortende ist immer die bequemere, sonst wäre es kein Muster.
Praktisch heißt das: die Frage vor der Messung ist nicht „was sagt X", sondern „ist X hier überhaupt zuständig". Das kostet zwei Minuten und hat mir in vier Wochen zehnmal Stunden gespart, die ich nicht gespart habe.