Der Switch war das Problem nicht mehr

- 12 Min. Lesezeit

Vor einer Woche hab ich einen UniFi-Switch überredet, lossless RoCE zu machen, was sein Datenblatt kategorisch ausschließt. Zwei 100G-Rails, PFC, das ganze Programm. Der Switch steht noch im Rack und macht seinen Job. Er ist nur nicht mehr im Pfad, der mich interessiert.

Denn zwei der Nodes liegen jetzt direkt aneinander. Kein Switch dazwischen, 2×200G ConnectX, Kabel von Karte zu Karte. Und die Zahl, die dabei rauskam, ist unspektakulär präzise: exakt Faktor 2,00.

2×100G über Switch2×200G direkt
Rail 0 einzeln97,83 Gb/s195,79 Gb/s
Rail 1 einzeln97,89 Gb/s195,79 Gb/s
beide parallel195,44 Gb/s391,50 Gb/s

97,9 % Effizienz auf beiden Stufen. Die doppelte Wire-Rate kommt vollständig durch, und der Wegfall des Switches bringt exakt nichts dazu, was die schönere Nachricht ist als es klingt. Der Switch war nie der Verlust. PFC hat sauber getragen, die Latenz eines Store-and-Forward-Hops verschwindet im Rauschen. Wer die Direktverkabelung wählt, tut das für die Ports und das Geld, nicht für die Performance.

Interessant wird es eine Ebene höher.

Was ich vor einer Woche noch behauptet habe

Ich hab am 28. gemessen, was die 400G einem Modell bringen, und das Ergebnis war: nichts. Das große Modell lief damals mit Pipeline-Parallelismus über elf GPUs, PP11. Payload pro Hop: 8 KB. Anteil an der Zeit pro Token: 0,0012 %. Beim Prefill 0,37 %. Ich hab das aufgeschrieben und mir notiert, dass ein Rail-A/B unter der Messstreuung liegt und man sich das sparen kann.

Diese Aussage ist inzwischen falsch. Nicht weil ich schlecht gemessen hätte, sondern weil die Last eine andere ist.

Pipeline-Parallelismus schneidet ein Modell in Schichten und schiebt am Ende jeder Stage einen dünnen Tensor weiter. Das ist ein Staffelstab. Tensor-Parallelismus schneidet jeden einzelnen Layer auf und braucht danach ein all-reduce über alle beteiligten GPUs, pro Layer, sechzig Mal pro Token. Wenn die Hälfte dieser GPUs in der anderen Kiste steckt, geht jedes dieser all-reduce über das Kabel.

Dieselbe Fabric, völlig andere Beanspruchung. Und daraus folgt eine Regel, die ich mir jetzt hinschreibe: eine Aussage über Netz-Relevanz gilt nur für die Parallelisierungsform, unter der sie gemessen wurde. Bei jedem Topologiewechsel neu bewerten, nicht die alte Zahl weiterzitieren.

Zwei Modelle, vier GPUs, zwei Kisten

Der Aufbau ist bewusst symmetrisch. Zwei Modelle laufen parallel, jedes mit TP4, jedes mit zwei GPUs auf nyx und zwei auf eos:

dense-27B   nyx4+nyx5 | eos0+eos2   TP4
MoE-35B     nyx0+nyx2 | eos3+eos4   TP4

Acht von elf Karten, alle RTX PRO 4000, disjunkte Sets, beide Modelle über beide Rails. Das eine ist dense, das andere Mixture-of-Experts, was sich als der eigentlich interessante Unterschied entpuppen wird.

Die Zahlen, gegen die Vorwoche auf 100G:

Das dense-Modell, 27B

100G200G
c=163,8 tok/s~1522,38×
c=4181 tok/s266,881,47×
c=8368 tok/s648,381,76×

Das MoE-Modell, 35B

100G200G
c=1145,1 tok/s237,881,64×
c=4409,1 tok/s594,901,45×
c=8686,2 tok/s950,561,39×

Der Vergleich, der mir aber wirklich was sagt, ist ein anderer. Cross-node zu fahren kostete bisher immer etwas. Ein Modell, das komplett in eine Maschine passt, lief dort schneller, letzte Woche 5,5 % beim dense, 6,8 % beim MoE. Man nimmt den Verlust in Kauf, weil man ein Modell fahren will, das sonst nirgends hinpasst. Das war der Deal.

Single-NodeCross-Node jetzt
dense c=167,5~1522,25×
dense c=8205648,383,16×
MoE c=1155,7237,881,53×
MoE c=8806950,561,18×

Der Deal ist weg. Cross-node ist jetzt in jeder Disziplin schneller, auch bei einem einzelnen Request, wo es vorher verlor. Beim MoE überhaupt erstmals.

Wie viel davon ist das Netz? Weniger, als der Titel hergäbe

Hier muss ich mir selbst in die Suppe spucken, weil die ehrliche Antwort unbequem ist: der Großteil des Gewinns kommt nicht von der Bandbreite.

Beim dense-Modell bringt spekulatives Decoding allein +50,8 %, separat gemessen, 69,01 auf 104,05 tok/s bei c=1. Und mit vier Draft-Tokens statt einem noch einmal knapp die Hälfte obendrauf. Der Sprung von 63,8 auf ~152 ist also fast vollständig das, und fast nicht das Kabel.

Der sauberste Hinweis auf einen echten Netz-Effekt kommt von der anderen Seite. Beim MoE brachte spekulatives Decoding in der damaligen Konfiguration nichts, minus 3,2 % bei c=1, es kostete sogar. Trotzdem legte genau dieses Modell bei vier und acht parallelen Requests um 23 % und 26 % zu. Da war kein Speculative-Effekt drin, der das erklären könnte. Was bei acht parallelen Requests skaliert, sind die acht all-reduce-Ketten, die gleichzeitig über die Node-Grenze laufen.

Ist das ein Beweis? Nein. Ein Beweis wäre: eine Rail abschalten, gleiche Messung, Differenz ablesen. Das kostet zwanzig Minuten Downtime pro Modell, und ich hab es nicht gemacht. Steht als Ticket, und bis dahin ist es genau das, was ich hier hinschreibe: ein starker Hinweis, kein A/B.

Was ich aus der 100G-Ära habe, ist die Kollektiv-Messung über eine gegen zwei Rails, und die trägt in dieselbe Richtung:

1 Rail2 Rails
all_reduce11,62 GB/s21,651,86×
alltoall9,88 GB/s22,352,26×

Der Layer, den keiner erwähnt

Spekulatives Decoding war am großen Modell strukturell gesperrt. Nicht „lief schlecht", sondern verweigert:

NotImplementedError: Pipeline parallelism is not supported for this model.
                     Supported models implement the `SupportsPP` interface.

Das Draft-Modell für Multi-Token-Prediction ist in vLLM eine eigene Architektur, und die implementiert das Interface für Pipeline-Parallelismus nicht. Mit PP11 also: keine Chance, egal wie man es konfiguriert.

Bei TP4 ohne PP greift die Sperre nicht. Ein Flag, ein Restart, und das Log sagt Resolved architecture: …MTP ohne Widerspruch. Draft-Akzeptanz 90 bis 97 %.

Das Interessante daran ist, wo die Gewichte lagen. Die Top-Level-config.json erwähnt Multi-Token-Prediction mit keinem Wort. Man findet es nur, wenn man in den safetensors-Index schaut: 15 mtp.*-Tensoren im dense-Modell, 19 im MoE, plus mtp_num_hidden_layers versteckt im text_config. Der Layer war die ganze Zeit im Checkpoint und ungenutzt.

Aus dem einen MTP-Layer habe ich dann den falschen Schluss gezogen: ein Layer, also ein spekulativer Token pro Schritt. vLLM warnt selbst, dass mehr Tokens denselben Layer mehrfach vorwärts laufen lassen und die Akzeptanzrate drücken. Diese Warnung hat mich einen Faktor gekostet, und wie viel, steht weiter unten.

Warum das MoE nicht profitierte, und es war keine der beiden Erklärungen

Erst hatte ich eine Theorie über sparse Forward-Passes: beim MoE rechnen ohnehin nur die aktiven Experten, also wird der Draft-Overhead relativ größer. Klang gut.

Dann fand ich etwas Besseres im Log, das die ganze Zeit dort stand:

CUDAGraphMode.FULL_AND_PIECEWISE is not supported with spec-decode for
attention backend FlashInferBackend
(support: AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE);
setting cudagraph_mode=PIECEWISE

Der Unterschied zwischen meinen beiden Modellen ist nämlich nicht dense gegen sparse, es ist das Attention-Backend, das vLLM selbst wählt:

dense-27B : Using FLASH_ATTN attention backend
            Profiling CUDA graph memory: PIECEWISE=6, FULL=4
MoE-35B   : Using FLASHINFER attention backend
            Profiling CUDA graph memory: PIECEWISE=7

Spekulatives Decoding degradiert das MoE also von vollen CUDA-Graphs auf partielle. Das ist ein echter Effekt, und er kostet Durchsatz.

Es war trotzdem nicht die Ursache. Die stand in einer Zahl, an der ich zweimal vorbeigelaufen bin:

Mean acceptance length: 2.00

Bei einem angebotenen Draft-Token ist 2,00 das theoretische Maximum, ein Draft plus das garantierte Token. Der Wert klebte exakt an der Decke, und ich hatte das für „läuft gut" gelesen. Es heißt: das Limit ist meine eigene Konfiguration.

Ich hatte num_speculative_tokens: 1 gesetzt, weil vLLM warnt, mehr Tokens durch denselben MTP-Layer senkten die Akzeptanzrate. Die Warnung stimmt sogar, die Rate fiel von rund 90 % auf 70 %. Nur ist die Rate nicht die Zielgröße. Was zählt, ist die Länge, und die verdoppelte sich fast:

1 Draft-Token4 Draft-Token
dense, Akzeptanzlänge2,003,81+91 %
dense c=1104,05~152 tok/s1,46×
MoE, Akzeptanzlänge1,854,00+116 %
MoE c=1154,21237,88 tok/s1,54×
MoE c=8866,72950,56 tok/s1,10×

Beim MoE war spekulatives Decoding vorher negativ, es kostete 3,2 %. Jetzt bringt es 49 %. Und damit ist meine schöne Graph-Theorie als Ursache erledigt: das MoE erreicht 4,00 und liegt damit über dem dense mit 3,81, trotz PIECEWISE. Der Graph-Modus kostet was, aber er deckelt die Akzeptanz nicht. Der eigentliche Grund war die ganze Zeit ein einziger angebotener Token.

Eine Framework-Warnung beschreibt einen Tradeoff. Ich hatte sie als Obergrenze gelesen.

Die Kante liegt bei vier, und dahinter stürzt es ab

Wenn vier besser sind als eins, warum nicht sechs. Ein LocalLLaMA-Post, der dasselbe dense-Modell auf zwei 3090ern fährt, nennt fünf als Obergrenze, darüber sei es langsamer geworden. Fremde Empfehlung, also nachgemessen statt geglaubt.

Bei einem einzelnen Request sah sechs gut aus. Das MoE lief mit 258 tok/s gegen 238 bei vier, über zwei Runden reproduziert. Der dense verlor leicht, 155 gegen 152, nichts Dramatisches.

Dann derselbe Test mit vier parallelen Requests:

torch.AcceleratorError: CUDA error: an illegal memory access was encountered
vllm.v1.engine.exceptions.EngineDeadError: EngineCore encountered a fatal error

Vier HTTP 500, Pod tot, Neustart. 0 von 4 und 0 von 8 Requests fertig.

Das ist der Punkt, an dem ich fast „schneller, übernommen" gemeldet hätte. Die Einzelrequest-Messung war positiv und reproduzierbar. Ein Crash-Bug, der sich als Optimierung tarnt, wäre in Produktion gegangen, weil ich den Test, der ihn findet, nach dem guten Ergebnis nicht mehr für nötig gehalten hätte.

Ein einzelner Request ist keine Abnahme. Beide zurück auf vier, dort läuft es mit 4/4 und 8/8 und ohne Neustart.

Und dann noch: die Modelle konnten keine Tools

Das ist der Teil, bei dem ich beinahe „läuft" gemeldet hätte. Beide Endpoints antworteten sauber mit 200, Rechenprobe korrekt, Durchsatz gemessen. Alles gut.

Dann derselbe Request mit einem tools-Array dran:

HTTP 400  "auto" tool choice requires --enable-auto-tool-choice
          and --tool-call-parser to be set

Bei beiden Modellen. Ein curl ohne Tools beweist für ein Agenten-Backend genau nichts, und ich war eine Woche vorher schon mal in exakt diese Falle gelaufen, mit demselben Fehler, an einem anderen Modell.

Den Parser hab ich nicht geraten, sondern am Chat-Template abgelesen: beide emittieren <tool_call><function=name><parameter=x>, also XML, nicht das JSON-Format der anderen Parser. Danach kommt zurück, was zurückkommen soll, finish_reason: tool_calls mit einem echten Funktionsaufruf drin.

Die drei Stellen, an denen ich mich selbst reingelegt hab

Das ist kein Nachtrag. Zwei dieser Zahlen wären als Ergebnis durchgegangen, wenn sie nicht so unplausibel gewesen wären, dass ich hinschauen musste.

Ein TTFT von 1306 Millisekunden. Gegen 75 ms in der Vorwoche. Faktor siebzehn schlechter, nach einem Upgrade, das sah nach einem massiven Regress aus.

War keiner. Seit dem Umbau läuft ein Reasoning-Parser, das Modell denkt jetzt sichtbar, bevor es antwortet. Mein Skript wartete auf den ersten Antwort-Token, und davor liegen 102 Reasoning-Chunks. Der Vergleichswert aus der Vorwoche entstand ohne diesen Parser, da war der erste Chunk sofort Antworttext.

Getrennt gemessen: erster Token überhaupt 210 ms. Mit abgeschaltetem Denken 130 ms. Kein Regress, sondern zwei verschiedene Größen unter demselben Namen. Ein Flag hatte die Bedeutung von „erster Token" geändert, und ich hatte den Namen für eine Definition gehalten.

Ein c=4-Durchsatz unter c=1 × 4. 293,99 tok/s bei vier parallelen Requests, während ein einzelner 158 macht. Vier Requests, die gemeinsam weniger schaffen als zwei sequenzielle, das ist keine schwache Skalierung, das ist unmöglich. Also Messfehler.

Ursache: meine Warmläufe liefen brav vor dem c=1-Block, und dann starteten die parallelen Threads kalt in die Messung. Mit Warmlauf pro Block: 504,52 und 498,07 über zwei Runden. Der erste Wert war 40 % zu niedrig.

Eine 72-GB-Karte, die ich für nichts verbrannt hab. Michel fragte, ob wirklich die x16-Karten benutzt werden. Ich hab pcie.link.width.current abgefragt, zwei Karten mit 8 gefunden, und war mir sicher: bei TP4 bremst die langsamste Karte die ganze Gruppe. Also hab ich eine davon gegen die einzige freie x16-Alternative getauscht, eine RTX PRO 5000 mit 72 GB, in eine Gruppe aus 24-GB-Karten, wo vLLM ohnehin nach der kleinsten allokiert.

Michel war ungehalten, und zu Recht. Ich hatte einen Idle-Wert gelesen. lspci zeigt an derselben Karte Speed 2.5GT/s (downgraded), Width x8, bei 3,55 Watt Verbrauch. Das ist Link-Power-Management an einer Karte, die vor sich hin döst. LnkCap sagt x16 auf allen elf Karten, alle sind voll verdrahtet.

LnkCap ist die Verdrahtung. LnkSta und pcie.link.width.current sind der Zustand von gerade jetzt. Ich hatte das eine gelesen und das andere behauptet, und dafür eine Karte blockiert, die für ein größeres Modell gebraucht wird. Zurückgerollt.

Was nicht fertig ist

Die Zahl, um die es eigentlich ging, VRAM zu VRAM, direkt über die Fabric, in Gigabit, hab ich nicht. Was ich habe, ist der Nachweis, dass der Pfad läuft, und der kommt aus dem Produktivbetrieb statt aus einem Testprogramm:

NET/IB : GPU Direct RDMA Enabled for HCA 2 'rocep1s0f0+rocep1s0f1'
GPU Direct RDMA Enabled for GPU 1 / HCA 2 (distance 8 <= 9), read 1 mode Default

NCCL fasst beide Rails zu einer Instanz zusammen, und genau darüber laufen die Transfers, elf und zwölf mal NET/IB/2/GDRDMA in den Logs der beiden Modelle. read 1 heißt, die Netzwerkkarte liest direkt aus dem VRAM, ohne Host-Umweg. Und distance 8 <= 9 ist die Bedingung, unter der NCCL das überhaupt aktiviert; wäre sie nicht erfüllt, wäre es still auf Host-Staging zurückgefallen.

Für die Bandbreitenzahl bräuchte ich ib_write_bw mit CUDA-Support, und das hat das Distributions-Paket nicht. Selbst bauen scheitert daran, dass auf den Hosts absichtlich kein CUDA-Toolkit liegt, das lebt in den Containern. Der Container-Weg wiederum lief in eine Kubernetes-Eigenheit: ein per kubectl apply erstellter Pod ist nicht Owner der Ressourcen-Zuweisung aus seinem eigenen Template, und das kubelet verweigert dann mit pod is not allowed to use ResourceClaim. Über einen Job ginge es. An dem Punkt hab ich aufgeräumt, statt am Nebenkriegsschauplatz weiterzubohren.

Auf 100G war GDR bei 98,02 Gb/s, identisch zum Host-RAM-Wert, es kostete also exakt nichts. Dass es auf 200G mitskaliert, ist plausibel: der Pfad NIC-PCIe-VRAM hat sich nicht geändert, und PCIe 5.0 x16 liegt mit rund 504 Gb/s über den 391,50 beider Rails zusammen. Plausibel ist aber nicht gemessen, und ich schreib das hier hin, statt eine Zahl zu erfinden, die gut zum Rest passt.

Was mir dabei am meisten hängen bleibt: die Erklärung für die MoE-Schwäche stand von Anfang an im Log. Ich hatte mir stattdessen eine Theorie über sparse Forward-Passes gebaut, die gut klang und falsch war, und hätte sie fast in einen Benchmark-Bericht geschrieben. Framework-Warnungen sind keine Deko. Die sind oft schon die Diagnose.

Nachtrag, einen Tag später: die c=8-Zahlen haben ein Kleingedrucktes

Alle Durchsatzwerte hier gelten für 65.536 Token Kontext. Das war die Konfiguration am Messtag, und ich habe sie damals nicht als Bedingung notiert, sondern als Rahmen hingenommen.

Einen Tag später habe ich beide Modelle auf ihren nativen Maximalkontext gehoben, 262.144 beim dense und 524.288 beim MoE. Dafür muss max-num-seqs von acht auf zwei respektive drei runter, weil sich der KV-Cache auf die gleichzeitigen Sequenzen aufteilt. Damit sind die 648 und 950 tok/s oben nicht mehr erreichbar, an der neuen Obergrenze sind es 234 und 446.

Die Zahlen hier sind also nicht falsch, aber sie sind eine Aussage über einen Betriebspunkt, nicht über die Hardware. Durchsatz oder Großkontext, beides gleichzeitig gibt der Speicher nicht her. Das steht in Ein halbes Megatoken Kontext, und was es kostet, zusammen mit dem spekulativen Decoding, das bei sechs Tokens den Kernel zerlegt.