Lossless RoCE auf einem UniFi-Enterprise-Switch
Wer die früheren Posts gelesen hat, kennt mich schon ein bisschen: ich betreibe ein GPU-Cluster unten im Keller, und ich stecke lieber ein Wochenende da rein als zur Kreditkarte zu greifen. Man hört die Lüfter durch die Decke hochdrehen, wenn was richtig arbeitet. Das hier ist die Geschichte von so einem Wochenende. Dem, an dem ich einem Switch was beigebracht hab, das sein Datenblatt kategorisch ausschließt.
Der Cluster macht seit Monaten Inference, leise, jedes Modell auf seiner eigenen Karte. Das lief, bis das erste Modell ankam, das nicht mehr auf eine einzelne GPU passte. Ein größeres Modell auf eine Kiste zu quetschen bringt dich nur so weit. Tensor-Parallelismus innerhalb einer Maschine, TP=8 über die GPUs in dem Chassis, ist unkompliziert: die teilen sich NVLink oder PCIe, der Interconnect ist schnell und lokal. Aber sobald du was richtig Großes fahren willst, und in den anderen Hosts liegt VRAM ungenutzt herum, musst du breiter gehen als eine Kiste. TP=16 über zwei Maschinen, oder mehr. Sobald Tensor-Parallelismus die Host-Grenze überschreitet, ist das Netz kein Rohr mehr, sondern das, was entscheidet ob es überhaupt funktioniert.
Das Problem ist also, ein Modell über Nodes aufzuteilen, ohne dass das Netz zum Flaschenhals wird. Tensor-Parallelismus ist der fordernde Fall, weil jeder Layer ein all-reduce über alle beteiligten GPUs auslöst. Innerhalb einer Kiste schluckt NVLink das. Über Hosts hinweg landet es auf dem Ethernet, und normales Ethernet ist bei weitem nicht gut genug: die GPUs müssen ununterbrochen reden, ohne dass der Prozessor sich dazwischendrängt. Das Werkzeug dafür ist RDMA, über Ethernet heißt das RoCEv2, und RoCEv2 braucht genau das eine, wonach der Rest deines Netzes nie fragt: ein lossless Fabric. In der Praxis: einen Switch, der Priority Flow Control kann. Die Lehrbuchantwort ist, einen zu kaufen. Ein gebrauchter Mellanox/NVIDIA Spectrum SN2700 liegt auf dem deutschen Markt gerade bei 2.500 bis 3.700€, und die billigen US-Listings landen im selben Bereich, sobald 19% Einfuhrumsatzsteuer, Zoll und Versand drauf sind. Neu ist locker fünfstellig. Oder du ringst einen MikroTik nieder.
Ich wollte keinen weiteren Switch kaufen. Es stand schon einer im Rack und surrte vor sich hin: ein UniFi-Aggregation-Switch. Alles was ich las sagte, der kann kein RoCE, kein PFC, kein DCB, nichts. Also die naheliegende Frage, die anscheinend niemand stellte: warum eigentlich nicht? Was hält ihn wirklich davon ab?
Also bin ich per SSH drauf. Der UniFi Enterprise Campus Aggregation Switch läuft unter der Haube auf SONiC. Unter der abgeriegelten Weboberfläche liegt dasselbe offene Netzwerk-OS, das die Hyperscaler benutzen, auf einem Marvell-ASIC, der Priority Flow Control voll beherrscht. Die Fähigkeit war die ganze Zeit da. Ubiquiti zeigt sie dir nur nicht.
Von da an war es kein Kaufproblem mehr, sondern ein Verstehensproblem. Und bevor ich dir zeige, wie ich es gelöst hab (und wie ich den Switch dabei einmal komplett abgeschossen hab): kurz, wofür der ganze Aufwand überhaupt gut ist. Denn das entscheidet, ob dich das hier betrifft.
Die Last, und warum das Netz die Wand ist
Das Ziel ist ein dediziertes Inference- und Training-Fabric für einen Multi-Node-GPU-Cluster: Kubernetes-nativ, NVIDIA-GPUs, für große Modelle die nicht auf eine einzelne Kiste passen. Wenn ein Modell über mehrere Nodes läuft, müssen die GPUs ununterbrochen miteinander reden. All-reduce, all-gather, KV-Cache-Transfer. Genau dieser Node-übergreifende Traffic ist der Flaschenhals, und schnell machst du ihn mit RDMA: die Netzwerkkarte schreibt direkt in den GPU-Speicher (GPUDirect RDMA), komplett am Prozessor vorbei.
RDMA über Ethernet heißt RoCEv2. Und RoCEv2 hat eine harte Bedingung. Das Netz muss lossless sein. Ein einziges verworfenes Paket killt den RDMA-Durchsatz, weil das ganze Modell davon ausgeht, dass das Fabric nie droppt. Lossless kriegst du mit Priority Flow Control (PFC): statt bei vollem Buffer ein Paket wegzuwerfen, schickt der Switch ein PAUSE-Frame stromaufwärts und sagt “halt diese Priorität kurz an”. Genau das macht aus normalem Ethernet ein RDMA-fähiges Fabric, und genau das ist das Feature, das UniFi nicht freigibt.
RoCE in einfach
Falls dich die letzten zwei Absätze mit Abkürzungen erschlagen haben, hier das Erdgeschoss. Überspring es, wenn du schon fließend RDMA sprichst.
Normalerweise sitzt bei einer Datenübertragung von einer Maschine zur anderen die CPU mitten in jedem Schritt: die Netzwerkkarte reicht das Paket ans Betriebssystem, das OS kopiert es in den Anwendungsspeicher, und erst dann sieht dein Programm es. Für einen Web-Request völlig okay. Ein Desaster, wenn acht GPUs tausende Male pro Sekunde gigabyteweise Tensor-Daten austauschen müssen, weil dann die CPU und diese Speicherkopien der Flaschenhals werden.
RDMA (Remote Direct Memory Access) wirft den Mittelsmann raus. Die Netzwerkkarte der einen Maschine schreibt direkt in den Speicher der anderen, ohne dass eine der beiden CPUs beteiligt ist. GPUDirect RDMA geht noch einen Schritt weiter: die Karte schreibt direkt in den GPU-Speicher. Die Daten landen genau dort, wo sie gebraucht werden, niemand kopiert sie zweimal, und die CPUs bleiben außen vor. Das ist es, was Node-übergreifende GPU-Arbeit schnell genug macht, dass sie sich lohnt.
RoCE (RDMA over Converged Ethernet, gesprochen “rocky”) ist der Weg, RDMA über ein normales Ethernet-Netz zu fahren statt über teure InfiniBand-Hardware. Version 2, RoCEv2, ist die, die heute alle nutzen. Sie gibt dir den Direkt-in-den-Speicher-Zauber auf denselben Switches und Kabeln, die du eh schon hast.
Es gibt einen Haken, und der ganze Rest des Posts hängt daran. RDMA setzt voraus, dass das Netz nie ein Paket verwirft. Normales Ethernet verspricht das nicht: kriegt ein Switch mehr Traffic als er verarbeiten kann, wirft er einfach Pakete weg und überlässt es dem Sender, das zu merken und neu zu schicken. TCP ist gebaut, um das wegzustecken. RDMA nicht. Ein einziges verworfenes Paket kostet nicht nur dieses eine Paket, es würgt die ganze Übertragung ab und der Durchsatz fällt von der Klippe.
Also muss das Netz lossless sein, und genau das kauft dir Priority Flow Control (PFC). Statt bei vollem Buffer ein Paket wegzuwerfen, schickt der Switch ein kleines PAUSE-Signal zurück an den Sender: “halt diese Traffic-Klasse kurz an, ich muss aufholen”. Der Sender wartet einen Moment, der Buffer läuft leer, der Traffic geht weiter, nichts geht verloren. PFC ist das eine Feature, das aus normalem Ethernet ein Fabric macht, dem RDMA vertrauen kann. Und genau das ist das Feature, das UniFi im Silizium mitliefert, aber nicht freigibt.
Zwei Dinge, die ich klar sagen will, bevor wir unter die Haube gehen:
- Ja, das trägt auch Training, nicht nur Inference. Meta fährt verteiltes AI-Training at scale über RoCE. Die Anforderung ist identisch, lossless plus PFC plus ECN, egal ob du ein großes Modell über Nodes servierst oder eins trainierst. Inference ist die sanftere Last. Wenn das Fabric die all-reduce-Gewitter des Trainings aushält, ist Inference leicht.
- Das ist nicht NVIDIA-only, mit einer ehrlichen Einschränkung. Die Switch-Arbeit hier ist komplett herstellerneutral: sie macht das Fabric lossless, und jede RoCEv2-fähige NIC profitiert. Die Intel E810 (irdma-Treiber) fährt RoCEv2 einwandfrei, die neue E835 (bis 200GbE) listet es explizit. Aber: RoCE können heißt noch nicht GPUDirect können. Der klassische GPUDirect-Pfad (
nvidia_peermem) existiert nur für Mellanox/ConnectX; der modernere DMA-BUF-Pfad ist zwar treiberneutral gedacht und irdma hat die Kernel-Unterstützung dafür inzwischen, aber NVIDIA validiert GPUDirect offiziell nur auf ConnectX, auf einer E810 landen RDMA-Transfers praktisch im Host-RAM und werden von dort in den VRAM kopiert. Für Storage, CPU-Workloads und auch fürs Modell-Serving ist die E810 damit völlig brauchbar. Wer aber das letzte Drittel Durchsatz bei GPU-zu-GPU-Traffic will, braucht auf der Kartenseite ConnectX.
Was der Switch wirklich ist
UniFi-Switches fühlen sich wie Appliances an. Weboberfläche, ein paar Schalter, kein Weg rein. Also bin ich per SSH drauf.
Die Device-SSH-Zugangsdaten bei UniFi sind nicht das Controller-Login. Die liegen in den Management-Settings des Controllers und werden aufs Gerät gepusht. Einmal drin:
$ show version
SONiC Software Version: SONiC.3.0.8
Distribution: Debian 10
Kernel: 5.10.0
ASIC: marvell
HwSKU: ECSAGG-48x25G6x100G
Platform: x86_64-marvell_db98cx8514_10cc-r0
Das ist keine UniFi-Firmware. Das ist SONiC, das Open-Source-Netzwerk-OS aus dem Open Compute Project, dasselbe NOS das in Microsoft Azure und in gutem Teil der weltweiten Hyperscale-Fabrics läuft, von Ubiquiti gebaut auf einem Marvell Prestera db98cx8514 ASIC. Das komplette Werkzeug ist da: sonic-cli, vtysh, redis-cli, sonic-cfggen, config, show. Die Config liegt in einer Redis-Datenbank, exakt wie bei jeder anderen SONiC-Kiste.
Heißt: die Hardware kann prinzipiell alles was SONiC kann. Inklusive dem vollen DCB-Stack: PFC, ECN, WRED, lossless Buffer-Pools. Die Fähigkeit steckt im Silizium. Die einzige Frage ist, ob Ubiquiti die Tür offen gelassen hat.
Der Fehler, der den Switch gecrasht hat
Hatte sie. Und ich bin trotzdem als Erstes mit dem Kopf gegen die Wand gerannt.
SONiC hat ein One-Shot-Kommando um QoS- und Buffer-Config zu laden: config qos reload. Von einer normalen SONiC-Kiste kommend ist der Reflex klar. Einfach QoS neu laden und die RoCE-Config generieren lassen. Also hab ich es ausgeführt.
Es hat den ganzen Switch abgeschossen. Der orchagent, der Prozess der den ASIC programmiert, ist mit einem std::invalid_argument aus stoul gecrasht, hat SIGABRT geworfen und den ganzen swss-Container mitgerissen. Der Switch war weg vom Netz. Rettung: Reboot.
Die Kette dahin ist kurz und lohnt sich zu verstehen, denn es ist eine Falle spezifisch für diese Ubiquiti-Kisten. Ubiquiti liefert die Hardware ohne die Dateien, mit denen ein normaler SONiC-Switch seine lossless Buffer dimensioniert (pg_profile_lookup.ini und die pg_lossless_*-Profile fehlen schlicht). Also stolpert config qos reload sofort über ein fehlendes Template. Der naheliegende “Fix” ist, DEVICE_METADATA.type zu setzen damit das Template auflöst, und genau das ist der Fehler: jetzt versucht der Reload fröhlich, Buffer-Config für alle 54 Ports gleichzeitig zu generieren, nur gibt es keine Kabel-Längen-Tabelle, aus der er den lossless-Headroom berechnen könnte. Er wirft malformte Buffer-Keys raus, orchagent verschluckt sich am ersten, den er nicht parsen kann, und reißt die Control-Plane mit. Bekannter SONiC-Fehler (sonic-buildimage Issue #16186), ich bin nur auf einer Kiste, die dafür prädestiniert war, geradewegs reingelaufen.
Die Regeln, die ich auf die harte Tour gelernt hab:
- Nie
config qos reloadauf diesem Switch. Es ist global und crasht eine Produktiv-Kiste. - Nie
DEVICE_METADATA.typean einem Live-Switch anfassen. Das schreibt das ganze Buffer-Modell um. config checkpointundrollbackgehen hier nicht. Ubiquitis Custom-Config-Felder sind nicht YANG-konform, also verweigert der Checkpoint-Validator. Dein einziger echter Fallback istconfig reload(lädt die On-Disk-Config) oder im schlimmsten Fall ein Reboot.
Falls du ihn crashst: ein simpler Reboot bringt ihn direkt zurück. Die On-Disk-config_db.json ist Ubiquitis saubere Baseline und enthält dein Experiment nicht, also kehrt der Switch in seinen funktionierenden Produktivzustand zurück. Nichts an der RoCE-Arbeit unten wird auf Disk persistiert, außer du machst es explizit (dazu später), heißt: ein Reboot ist ein garantierter Reset-Knopf. Trotzdem vorher sichern:
sudo sonic-cfggen -d --print-data > /tmp/config_backup_$(date +%s).json
Warum das Feature nicht freigegeben ist, obwohl die Hardware es kann
Erst der Buffer-Mythos. Ich hab anfangs angenommen “24 MB ASIC-Buffer sind zu klein für RoCE”. Falsch. Der MMU ist 24 MB total, der ingress_lossless_pool ist bereits mit ~20 MB angelegt, und der Marvell-ASIC steht explizit auf SONiCs Liste PFC-fähiger Chips. Buffer war nie der Blocker.
Der echte Blocker ist reine Software-Verpackung. UbiOS, die UniFi-Schicht, exponiert PFC/DCB/QoS gar nicht im Web-UI. Und das SONiC-Image kommt ohne die lossless-Profil-Definitionen, die buffermgrd braucht um PFC-Buffer zu generieren. Der Chip kann es. Das Werkzeug ist da. Ubiquiti hat nur die letzte Meile nicht verdrahtet und zeigt es nicht.
Das ist eine Produktentscheidung, kein Hardware-Limit. Ein verschenkter Verkaufsvorteil, keine fehlende Fähigkeit, und darauf komme ich am Ende zurück.
Die Architektur
Drei Bilder. Erstens, wo der Switch sitzt. Er steht zwischen dem normalen Cluster-Ethernet und dem dedizierten RoCE-Fabric:
Zweitens, der SONiC-Control-Pfad im Switch. Die Kette, die deine Config durchläuft, und der letzte Sprung ist der einzige Beweis der zählt:
Drittens, der echte RoCE-Datenpfad mit PFC bei der Arbeit. Ein PAUSE-Frame statt eines Drops:
Der Fix: RoCE Port für Port aktivieren, sicher
Der sichere Weg ist das Gegenteil von config qos reload. Ein Port nach dem anderen, direkt in die Redis-config_db schreiben, das lossless Buffer-Profil selbst bauen, und nach jedem Schritt die Switch-Gesundheit prüfen. Kein globaler Reload, niemals.
Ein paar Dinge, die dir Schmerz ersparen:
- Redis-Keys enthalten
|. Quote sie, sonst frisst die Shell die Pipe. Führ alles aus einem hochgeladenen Script aus, nicht aus verschachtelten SSH-Heredocs. - Referenzen zwischen config_db-Tabellen müssen das
[TABLE|key]-Format nutzen, sonst loggtorchagentmalformed reference ... must be surrounded by [ ]und überspringt sie stillschweigend. - Verifiziere am ASIC, nicht in der config_db. Das einzige was zählt ist das SAI-Attribut auf dem Port.
Die Buffer-Werte unten sind für 100G auf kurzem DAC, konservativ aus einer bewährten Centec-SONiC-Referenz abgeleitet. size, xon, xoff für längere Kabel anpassen.
Schritt 1, lossless Buffer-Profil (das Stück, das Ubiquiti weggelassen hat):
redis-cli -n 4 hset 'BUFFER_PROFILE|pg_lossless_100000_5m_profile' \
pool '[BUFFER_POOL|ingress_lossless_pool]' \
size '36864' xon '18432' xoff '18432' dynamic_th '0'
Schritt 2, QoS-Maps (DSCP 24 wird Traffic Class 3, die klassische RoCE-Priorität):
redis-cli -n 4 hset 'DSCP_TO_TC_MAP|AZURE' 0 1 24 3 32 4
redis-cli -n 4 hset 'TC_TO_PRIORITY_GROUP_MAP|AZURE' 0 0 3 3 4 4
redis-cli -n 4 hset 'TC_TO_QUEUE_MAP|AZURE' 0 0 3 3 4 4
redis-cli -n 4 hset 'MAP_PFC_PRIORITY_TO_QUEUE|AZURE' 3 3 4 4
redis-cli -n 4 hset 'PFC_PRIORITY_TO_PRIORITY_GROUP_MAP|AZURE' 3 3 4 4
Schritt 3, lossless PG und QoS-Map an den Port binden (hier Ethernet48; beachte die [TABLE|key]-Referenzen und pfc_enable):
redis-cli -n 4 hset 'BUFFER_PG|Ethernet48|3-4' \
profile '[BUFFER_PROFILE|pg_lossless_100000_5m_profile]'
redis-cli -n 4 hset 'PORT_QOS_MAP|Ethernet48' \
dscp_to_tc_map '[DSCP_TO_TC_MAP|AZURE]' \
tc_to_queue_map '[TC_TO_QUEUE_MAP|AZURE]' \
tc_to_pg_map '[TC_TO_PRIORITY_GROUP_MAP|AZURE]' \
pfc_to_queue_map '[MAP_PFC_PRIORITY_TO_QUEUE|AZURE]' \
pfc_to_pg_map '[PFC_PRIORITY_TO_PRIORITY_GROUP_MAP|AZURE]' \
pfc_enable '3,4'
Nach jedem Schritt prüfen, dass die Control-Plane noch lebt, bevor du weitermachst:
docker ps --filter name=swss --format '{{.Status}}' # muss "Up ..." sagen
Der Beweis
config_db ist eine Wunschliste. Der ASIC ist die Wahrheit. Zieh das SAI-Attribut für den Port und prüf, dass PFC wirklich ins Silizium programmiert ist:
OID=$(redis-cli -n 2 hget 'COUNTERS_PORT_NAME_MAP' 'Ethernet48')
redis-cli -n 1 hget "ASIC_STATE:SAI_OBJECT_TYPE_PORT:$OID" \
SAI_PORT_ATTR_PRIORITY_FLOW_CONTROL
# liefert 24 (binär 11000 = Prioritäten 3 und 4 aktiv)
24 heißt: Prioritäten 3 und 4 haben PFC auf Hardware-Ebene. Der Switch schickt jetzt PAUSE-Frames statt zu droppen auf diesen Prioritäten. Das ist ein lossless Fabric, auf Hardware deren Datenblatt das Feature nicht listet.
Dann der End-to-End-Test mit echtem RDMA-Traffic über den Switch:
# Server auf einem Port, Client auf dem anderen, RDMA-Write über das Fabric
ib_write_bw -d <hca> -F --report_gbits -R # Server
ib_write_bw -d <hca> -F --report_gbits -R <server> # Client
Update, ein paar Tage später: die vollen Zahlen sind da. Inzwischen hängen drei Nodes mit Dual-Port ConnectX-5 Ex an der Fabric, alle Firmware angeglichen, und ich hab die komplette Matrix mit einem deterministischen Benchmark-Harness gemessen: jede Richtung, jede Rail, einzeln und alles gleichzeitig, 8 Sekunden pro Messpunkt, reproduzierbar von Lauf zu Lauf.
Das Gesamtbild als Leiter, von gewöhnlichem Ethernet bis alles auf einmal:
Und dieselben Daten als Tabellen, gruppiert nach der Frage, die jede Schicht beantwortet.
Schicht 1: rohe Fabric (RDMA, RAM zu RAM). Liefert das Netz?
| Test | Ergebnis | Was es beweist |
|---|---|---|
| Eine Rail, jede Richtung | 93,7–95,4 Gbit/s | ~96 % Wire-Effizienz, das praktische Maximum |
| Full Duplex, ein Kabelpaar | 187,9–188,3 Gbit/s | beide Richtungen echt gleichzeitig |
| Dual-Rail, eine Richtung | 114,4–114,8 Gbit/s | der PCIe-x8-Deckel, dazu unten mehr |
| Dual-Rail, bidirektional | 214–220 Gbit/s | Maximallast auf einem Node-Paar |
| 3-Node-Ring, aggregiert | 280 Gbit/s | der Switch ist non-blocking |
| Latenz, 2-Byte-Write, idle | 2,3 µs (p99 2,4) | |
| Latenz unter Volllast | 2,4 µs (p99 2,5) | PFC/ECN tun ihren Job |
| TCP auf demselben Kabel, zur Einordnung | 51 G (1 Stream) / 98,9 G (8 Streams) | zu hohen CPU-Kosten; RDMA macht 94 G bei ~0 % |
Zwei Zeilen sind der eigentliche Beweis. Die Latenz unter Last: dass ein 2-Byte-Write nur ein Zehntel Mikrosekunde langsamer wird, während derselbe Switch Volllast trägt, das ist PFC, das die lossless Queue nie in einen vollen Puffer laufen lässt. Und die TCP-Zeile: selbst acht getunte Streams erreichen Wire-Speed nur unter Verheizen ganzer Cores, RDMA macht es nebenbei.
Schicht 2: GPU-Traffic (NCCL, VRAM zu VRAM). Überlebt es den vollen Software-Stack?
| Kollektiv (256 MB) | 2 Nodes | 3 Nodes |
|---|---|---|
| Punkt-zu-Punkt uni / bidi | 94,8–95,2 G / 188 G | |
| broadcast | 95,2–95,6 G | 94,6 G |
| alltoall | 93,5–93,8 G | siehe Alignment-Geschichte unten |
| allreduce | 89,9–91,7 G | 91,6 G |
| reducescatter | ~88 G | 88,6 G |
| allgather | 86,8–88,3 G | 18,5 G ⚠️, mit NCCL_PROTO=LL128: 65,6 G |
| sendrecv-Ring | 93,3 G | |
| Latenz, 4-Byte-allreduce | 14–16 µs |
Punkt-zu-Punkt bei 94,8 heißt: der komplette Stack aus Container, torchrun und NCCL kostet auf dem Pattern nichts, Wire-Speed, GPU-Speicher zu GPU-Speicher. Jede Zeile dieser Tabelle grün zu bekommen, und die zwei zu verstehen, die sich gewehrt haben, brauchte die Detektivgeschichten unten.
Wer die 9,68-Gbit/s-Anekdote dazu will: ein Node-Pfad hing tagelang bei exakt diesem Wert fest. Root Cause war totes MAC-Learning im ASIC, der Switch hat unknown unicast geflutet statt geswitcht, und die Flood-Pipeline des Marvell cappt genau da. Der eine Befehl, der es aufdeckte: show mac -v 8 während eines langsamen Transfers, Ziel-MAC nicht in der Tabelle, Fall gelöst. Der Fix eskalierte in drei Stufen: Schreibzugriffe auf die config_db wurden ignoriert, auf die APP_DB auch, und die einzige Tür, die in diesem Build bis zum ASIC reicht, ist swssconfig mit einem FDB_TABLE-JSON im swss-Container. Ein statischer Eintrag brachte den Pfad sofort auf 93,89; ein Switch-Reboot später war auch das dynamische Learning geheilt. Lehre: eine verdächtig konstante schlechte Zahl bedeutet Mechanismus, kein Rauschen.
Zwei 100G-Ports pro Node: was Dual-Rail wirklich bringt
Jeder Node trägt eine Dual-Port-100G-NIC, und die naive Erwartung ist “zwei Ports = 200 G”. Die gemessene Wahrheit ist interessanter, und sie ist eine PCIe-Lektion, keine Netzwerk-Lektion. Hier die ganze Fabric mit Blick auf Karten-Ebene, PCIe-Geometrie inklusive:
RTX PRO 4500 (Gen5 x16)
CX-5 Dual-Port (Gen3 x8)
GPU und NIC an getrennten Root Ports"] NB["Node B - Arrow Lake, VM
RTX PRO 4500 (Passthrough)
2x CX-5 Single-Port
Host-Root-Complex im Pfad"] NC["Node C - Threadripper PRO
RTX PRO 4000 (Gen4)
CX-5 Dual-Port (x8)
volles GPUDirect in beide Richtungen"] SW["UniFi ECS-Agg (SONiC)
lossless PFC
Rail A + Rail B (getrennte VLANs/Subnetze)"] NA ---|"2x 100G"| SW NB ---|"2x 100G"| SW NC ---|"2x 100G"| SW class SW hot; class NC good;
Der grüne Node wird später wichtig: der Threadripper ist die einzige Maschine, auf der NIC und GPU nah genug beieinander sitzen für volles GPUDirect in beide Richtungen. Merk dir das fürs letzte Kapitel.
Was die zwei Ports messbar liefern:
- Eine Richtung, beide Ports: 114 G, nicht 200. Die Karte sitzt auf PCIe Gen3 x8, das sind ~128 G brutto minus Protokoll-Overhead. Beide Ports teilen sich dieses Slot-Budget, der zweite Port bringt unidirektional nur ~20 G obendrauf. Nicht die Kabel sind das Limit; der Slot ist es.
- Beide Richtungen, beide Ports: 214 bis 220 G. PCIe ist full duplex, TX und RX bekommen je ihr eigenes ~114-G-Budget, und plötzlich zahlt sich der zweite Port voll aus. Bidirektionaler Traffic (genau das, was allreduce-lastiges Training erzeugt) ist der Fall, in dem Dual-Rail glänzt.
- Warum kein LACP? Ein Bond nagelt jeden RDMA-Flow auf einen Link (ein Flow hasht auf einen Port, immer). Zwei unabhängige Rails mit getrennten Subnetzen lassen NCCL selbst über beide Ports verteilen, das kann es nativ, und die Fehlerdomänen bleiben sauber. Eine Fußnote aus dem Testen: mit nur einer GPU pro Node war Dual-Rail-NCCL sogar langsamer (32 G); Rails lohnen erst, wenn mehrere GPUs pro Node sie füttern.
- Der ehrliche Fix für den x8-Deckel ist eine Gen5-x16-Karte (ConnectX-7-Klasse). Roadmap, kein Bedauern: die bidirektionalen 220 G pro Node-Paar sind das, was die Workloads real sehen.
Ein Trick für alle, die mit einem einzelnen RDMA-Node testen. Pack die zwei NIC-Ports in getrennte Network Namespaces. Sonst kürzt der Linux-Kernel den Traffic über Loopback ab und er trifft den Switch nie physisch. Mit den Ports in verschiedenen netns wird der Traffic aus einem Port raus, durch den Switch und in den anderen zurück gezwungen. Ein echter Fabric-Pfad aus einer einzigen Kiste.
Reboot-fest machen
Der Haken: alles oben lebt in der laufenden Redis-config_db, im RAM. Es wird nicht in die On-Disk-config_db.json geschrieben. Zur Laufzeit hält es bombenfest. Ich hab Ubiquitis Config-Enforcer-Daemons (ubnt-lnxcfgd, hostcfgd) stundenlang laufen sehen, ohne die QoS-Tabellen zurückzusetzen, was besser ist als bei älterer Firmware, wo Leute berichteten dass die Config nach Minuten weggewischt wurde. Aber ein Reboot löscht es.
Man könnte es mit config save auf Disk schreiben, aber das riskiert eine Kollision mit Ubiquitis eigenem Config-Layer (ubnt_cfg.json), und es ist genau die Art Sache die ein Firmware-Update plattmacht. Der robuste Weg ist ein kleiner idempotenter Boot-Hook. Ein systemd-Oneshot, der die RoCE-config_db-Einträge nach jedem Boot neu setzt, erst auf swss-Bereitschaft wartet und am Ende das ASIC-Attribut verifiziert. Er umgeht Ubiquitis Config-Layer komplett, weil er den Zustand einfach neu behauptet nachdem die Kiste oben ist, statt zu kämpfen ihn durch den Boot zu persistieren.
Das hält RoCE reboot-fest, ohne Ubiquitis Baseline anzufassen, und es scheitert sauber. Falls ein Firmware-Update mal die SONiC-Internals ändert, failt der Hook laut statt still etwas zu korrumpieren.
Die Fabric wird erwachsen: vier Nodes, vier Detektivgeschichten
Der Switch war der Anfang. Vier Nodes auf die Fabric zu bringen, produzierte eigene Lektionen, jede unsichtbar für alle beteiligten Datenblätter.
Der Node, der pingt und Verbindungen verweigert. Nach einer Reboot-Runde nahm ein Node keine RDMA-Verbindungen mehr an, als Server; als Client funktionierte er. Ping gut, FDB gut, GIDs gut, Loopback gut. Der Schuldige saß auf einer anderen Maschine: ein staler ARP-Eintrag mappte die Rail-1-IP des Servers auf dessen Rail-2-MAC. Mit zwei NICs im selben Subnetz beantwortet Linux ARP für beide Ports (ARP-Flux), und eine gecachte falsche Antwort schickte jeden Verbindungsaufbau an den Port, auf dem niemand lauschte. Der Fix, auf jedem Node:
ip neigh flush dev <rail-if>
sysctl net.ipv4.conf.all.arp_ignore=1
sysctl net.ipv4.conf.all.arp_announce=2
Der Reboot, der alles zurückdrehte. Die erste ungeplante Reboot-Runde löschte RoCE-IPs, setzte MTU zurück auf 1500 (was RDMA still auf 83 Gbit/s deckelt) und benannte auf einem Node die RDMA-Devices um. Alles war live konfiguriert, nichts persistiert. Die Kur spiegelt den Boot-Hook des Switches: ein roce-node-apply.service pro Node liest /etc/roce-node.conf und setzt Rail-IPs, MTU, DCB und sysctls bei jedem Boot neu. Seitdem sind Reboots langweilig, und das ist das höchste Kompliment, das Infrastruktur verdienen kann.
Firmware, live angeglichen. Die ConnectX-5 kamen auf drei Firmware-Ständen an, und die Notes des neueren LTS-Builds listen einen RoCE-Retransmission-Fix, exakt die Klasse Bug, die man später nicht als Phantom jagen will. mstflint flasht bei laufendem Node, mstfwreset -l 3 aktiviert in zehn Sekunden ohne Reboot, die Kubernetes-Nodes bleiben Ready. Nur der virtualisierte Node brauchte den Tanz auf dem Host (VM stoppen, vfio lösen, mstfwreset --skip_driver, VM starten), weil ein Function Level Reset im Gast keine Firmware aktivieren kann.
Zwei Rails, kein LACP, mit Absicht. Jede Dual-Port-NIC speist zwei unabhängige Rails: getrennte Subnetze, kein Bonding. LACP würde jeden RDMA-Flow auf einen Link nageln; NCCL verteilt nativ über beide Rails und bekommt das Aggregat, ohne dass zwei Kabel eins spielen müssen.
Von Leitungsrate zu GPU-Geschwindigkeit: NCCL und GPUDirect
Dass die Fabric schnell ist, ist notwendig, nicht hinreichend, die GPUs müssen sie nutzen. Das wurde eine eigene Jagd mit drei Kapiteln.
Der Allgather-Detektiv. Allreduce machte ~90 Gbit/s, während 3-Node-Allgather auf denselben Leitungen bei 18,5 kroch. Ich habe NCCL-Versionen gejagt (2.30 selbst gebaut: nichts) und mich mit busbw-Formeln so lange verwirrt, bis ich einer Messung glaubte, die ein Artefakt war, peinlich, und es bleibt in der Geschichte. Entschieden hat es ein handgebauter isend/irecv-Ring mit exakt dem Allgather-Muster: 137 Gbit/s. Die Fabric konnte das Muster; NCCLs Algorithmuswahl nicht. NCCL_PROTO=LL128 hob 3-Node-Allgather von 18,5 auf 65,6, Faktor 3,5. Zwei ehrliche Fußnoten aus der A/B-Validierung: pro Job pinnen, nicht global, ein globaler LL128-Pin kostet 20 bis 30 % bei fast allem anderen; und die 2-Node-Variante dieses Bugs ist upstream in NCCL ≥ 2.28 gefixt, Workarounds also immer gegen aktuelle Versionen nachtesten. NCCLs Defaults sind für DGX-Klasse-Kisten mit NVLink und GPUDirect getunt; auf einem Consumer-Board wählen sie für bestimmte Patterns pathologisch falsch. Nichts ist kaputt, es ist für eine Maschine getunt, die man nicht hat.
Update: NCCL 2.30.7 (nach diesem Text erschienen) fixt die Algorithmus-Wahl: 3-Node-allgather ging im Re-Test von 18,5 auf 91 Gbit/s, ganz ohne LL128-Pin. Der Workaround oben bleibt dokumentiert — für ältere Versionen, und als Lehrstück darüber, busbw-Tabellen nicht zu selbstsicher zu lesen.
Das Alltoall, das nie kaputt war. Nach allem Tuning kollabierte 3-Node-Alltoall weiter auf 8 bis 17 Gbit/s, in unserem Benchmark-Harness. Die offiziellen nccl-tests machten 79 bis 87 auf denselben Nodes in derselben Minute. Drei Tage Verdacht gegen Fabric und Silizium endeten zwei Zeilen tief im eigenen Python: wir hatten den Tensor mit n -= n % 3 auf Teilbarkeit durch die World-Size gebracht, was Peer-Chunks von 178.956.968 Bytes erzeugt, nicht durch 16 teilbar. Jeder Peer-Chunk lag auf krummen Adressen, NCCL fiel vom vektorisierten 16-Byte-Pfad auf den Element-Pfad, Faktor 5. Die offiziellen Tests alignen still (count & ~(16/eltSize - 1), alltoall.cu Zeile 19), deshalb zeigten sie es nie. Zwei Elemente weniger im Tensor: 46 G. Ein natürlich alignter 768-MB-Tensor: 87,9 G. Die teuerste Lektion des ganzen Projekts, und der Fix kostete nichts: bevor du die Hardware beschuldigst, lass das Referenz-Tool laufen, und lies, wie es seine Inputs konditioniert. Diese Regel ist hier seitdem Gesetz (wir nennen sie Rule Zero).
Quellcode lesen statt raten. GPUDirect verweigerte sich mit der Log-Zeile GPU Direct RDMA Disabled (distance 8 > 5). Zwei Tage Symptom-Stochern (GDR_LEVEL=SYS erzwungen: Vendor-Error 81) brachten nichts; fünfzehn Minuten in NCCLs paths.cc erklärten alles: Distanz 8 ist PATH_PHB, Pfad durch den CPU-Root-Complex, die Default-Schwelle ist PXB = 5, eine gemeinsame PCIe-Bridge. Der offizielle Knopf heißt NCCL_NET_GDR_LEVEL=PHB. Dieses Verhältnis, Minuten Code-Lesen gegen Tage Raten, hielt bei jedem harten Problem dieser Geschichte.
Das Phantom in barrier(). In manchen Containern crashte dist.barrier() mit CUDA unknown error, manchmal, die Signatur von Flakiness, und flakige Benchmarks sind wertlos. Elf Eliminierungen später: torch.zeros(1) nach einem InfiniBand-Kollektiv crasht, derselbe Aufruf davor als Warmup, und alles läuft. NGC-Container defaulten auf CUDA_MODULE_LOADING=LAZY, unsere handgebauten Container mounteten einen unvollständigen Treiber-Bibliothekssatz, und Lazy Loading verschob den Fehler auf den ersten Kernel-Launch nach der NCCL-Initialisierung, barrier() ist intern nur zeros(1) plus Allreduce, also bekam es die Schuld. “Flaky” heißt: es gibt eine Variable, die du nicht siehst.
Der Read, der nie ankommt: ein stiller PCIe-Fehler
Mit endlich aktiviertem GDR zeigten die beiden Intel-Arrow-Lake-Nodes eine saubere Trennung, die der Threadripper-PRO-Node nicht hat: die NIC, die in GPU-Speicher schreibt, funktioniert (59 Gbit/s in den VRAM, auf jedem Node); die NIC, die aus GPU-Speicher liest, scheitert. Jedes Mal. Auf dem Threadripper liest derselbe Software-Stack VRAM mit 84 Gbit/s über eine Rail, 180 Gbit/s über beide.
Eine frühere Runde hatte bei “wahrscheinlich das Silizium” geendet, und das störte mich, Elimination ist kein Mechanismus. Also wurde neu aufgerollt, mit einer Regel: kein Urteil ohne positiven Beweis. Der Reproduzierer schrumpfte auf eine Maschine, NIC-Port 1 liest GPU-Speicher, den Port 0 serviert, raus über den Switch und zurück:
Deterministisch, sieben von sieben Läufen. Ein 64-Byte-Read scheitert exakt wie ein 128-Kilobyte-Read; RC-, DC- und XRC-Transport scheitern identisch; Host-RAM auf demselben Pfad macht 97,6 Gbit/s. Und die Software-Seite ist beweisbar sauber: bpftrace auf dem dmabuf-Pfad des Kernels zeigt, dass die NIC exakt die GPU-BAR1-Bus-Adresse bekommt (sg0: dma=0xa000600000), jeder Mapping-Aufruf meldet Erfolg. Was den Read tötet, passiert nach der Übergabe von Software an Hardware.
Der Teil, der mich immer noch beschäftigt: nirgends steht ein Fehler. Wir haben jedes Fehlerregister auf GPU, NIC und beiden Root Ports gelöscht, den scheiternden Read laufen lassen und alles wieder ausgelesen, kein einziges Bit gesetzt. Kein Unsupported Request, kein Completion Timeout, kein AER-Eintrag:
Writes sind posted, abschicken und vergessen, sie routen sauber. Reads sind non-posted, sie brauchen eine echte Antwort, und zwischen diesen beiden Root Ports ist die Antwort auf eine Art falsch, die kein Fehlerzähler für erwähnenswert hält. Die Ausschlussliste ist lang, jeder Punkt live getestet, eine Variable pro Schritt: IOMMU aus, ACS aus, NoSnoop/RelaxedOrdering umgeschaltet, Treiber-Registry-Schalter mit sauberen Reboots, BAR1 von 32G auf 256M verkleinert (schließt die bekannte Z690/Z790-Resizable-BAR-Workaround-Klasse aus), kalte und warme GPU, zwei perftest-Builds, NIC-Firmware-Config Byte für Byte gegen den funktionierenden Node gedifft. Und ein sprechendes Detail aus dem Treiber-Quellcode: NVIDIAs offene Kernel-Module führen eine Chipsatz-Macken-Tabelle, Z690 hat einen Eintrag, Z790 hat einen Eintrag, Z890 (Arrow Lake) steht gar nicht drin. Eine zweite Arrow-Lake-Maschine, anderer CPU-SKU, anderes NIC-Layout, sogar virtualisiert mit Passthrough, zeigt die identische Fehlersignatur.
Die Beweislage sagt: der Arrow-Lake-Root-Complex behandelt NIC-initiierte Non-Posted-Peer-to-Peer-Transaktionen zwischen Root Ports falsch, und zwar lautlos. Wir haben es als Upstream-Report aufgeschrieben, NVIDIA/open-gpu-kernel-modules#1253, mit Reproduzierer, bpftrace-Beweis und der vollen Ausschluss-Matrix, weil die nächste Person etwas Besseres verdient als Forum-Threads, die mit “ich hab aufgegeben und mir einen größeren Server gekauft” enden. Praktisch leidet der Cluster kaum: NCCL erkennt den kaputten Read-Pfad und fällt auf Host-Staging zurück, und die Zahlen in der Tabelle oben halten. Aber “umgeht es” und “funktioniert” sind verschiedene Wörter.
Update, einen Tag später: NVIDIA hat geantwortet — und zwar nicht irgendwer, sondern einer der GPUDirect-Architekten. Die Bestätigung ist so kurz, wie drei Tage Debugging es verdienen: “Intel client CPUs are known to only support PCIe P2P writes.” Es ist eine fundamentale Hardware-Limitierung des Client-Root-Complex, es gibt keine Treiber-Schalter, und der einzige echte Workaround ist GPU und NIC hinter einen gemeinsamen PCIe-Switch zu hängen. Also: kein Bug in unserem Setup, keine Treiber-Regression — Silizium. Jede Workstation- und Server-Plattform (Xeon W, Threadripper, EPYC) routet P2P-Reads sauber; Intels Client-Dies können es schlicht nicht. Was bleibt: In keinem öffentlichen Intel-Datenblatt steht davon ein Wort. Jetzt steht es wenigstens hier.
Wann man das macht, und wann man einfach den Switch kauft
Ich tu nicht so, als wäre das ein supportetes Produktiv-Setup. Ist es nicht. Also ehrlich:
Mach das, wenn du so einen Switch schon hast, du ein Homelab oder einen kleinen Cluster fährst, und du lieber ein Wochenende in das Verständnis deines Fabrics steckst als ein paar tausend Euro in eine weitere Kiste. Du lernst enorm viel darüber, wie RoCE wirklich funktioniert, und kriegst ein echt lossless Fabric aus Hardware, die dir gehört.
Kauf den echten Switch, wenn das ein geschäftskritisches Fabric ist, du Hersteller-Support brauchst, du über eine Handvoll Nodes hinaus bist, oder du den Reboot-Persistenz-Hack und das komplette Fehlen eines Sicherheitsnetzes nicht tolerieren kannst. Ein richtiger Mellanox/Spectrum-Switch gibt dir natives, supportetes, Plug-and-Play-RoCE mit SHARP und dem ganzen Rest. Im großen Maßstab ist das das Geld wert.
Für 12 GPUs über 4 Nodes, Inference und die gelegentliche Multi-Node-Training-Runde, ist der Switch den ich eh besaß die richtige Antwort. Deine Linie liegt woanders. Zieh sie ehrlich.
Ein offener Appell an Ubiquiti
Ubiquiti, die Hardware kann das schon. Es ist SONiC, es ist ein Marvell-ASIC der auf der PFC-fähigen Liste steht, der lossless-Pool ist in der Buffer-Config bereits angelegt. Das Einzige zwischen eurer Enterprise-Campus-Aggregation-Linie und nativem RoCE ist eine pg_profile_lookup.ini, die lossless-Profile, und eine Handvoll QoS-Schalter im UI. Ihr haltet ein Feature zurück, das das Silizium mitbringt.
Das ist keine Kostenersparnis. Es ist ein verschenkter Verkaufsvorteil. “Lossless RoCE für GPU- und Storage-Fabrics, auf einem Switch der einen Bruchteil eines Mellanox Spectrum kostet” würde sich bei jedem Homelab verkaufen, bei jeder kleinen AI-Bude, bei jeder Uni-Gruppe die einen GPU-Cluster auf Budget hochzieht. Das ist ein schnell wachsender Markt und er sucht genau danach. Exponiert PFC/DCB nativ, meinetwegen als advanced- oder unsupported-Toggle, und ihr besitzt ein Segment, das aktuell entweder überzahlen oder das machen muss, was ich gerade gemacht hab. Bitte baut es ein.
Zum Mitnehmen
- Der UniFi Enterprise Campus Aggregation Switch läuft SONiC auf einem Marvell-ASIC. Er kann lossless RoCE, die Fähigkeit ist nur nicht exponiert.
- Niemals
config qos reloadglobal. Das crasht die Control-Plane. Rettung per Reboot; er lädt die saubere On-Disk-Config. - RoCE Port für Port über die config_db aktivieren, das fehlende lossless Buffer-Profil selbst bauen,
[TABLE|key]-Referenzen nutzen, und am ASIC verifizieren (PRIORITY_FLOW_CONTROL = 24). - Config lebt im RAM. Mach sie reboot-fest mit einem idempotenten systemd-Boot-Hook, nicht mit
config save. - Funktioniert mit ConnectX und Intel E810/E835 auf der NIC-Seite, fürs lossless Fabric. Für echtes GPUDirect (NIC schreibt direkt in VRAM) bleibt ConnectX gesetzt. Trägt Training genauso wie Inference.
- Dual-Port 100G: der Slot entscheidet, nicht das Kabel. Auf Gen3 x8 zahlt sich der zweite Port bidirektional aus (220 G), unidirektional kaum. Unabhängige Rails schlagen LACP für RDMA.
- Eine verdächtig konstante schlechte Zahl bedeutet Mechanismus, kein Rauschen. Und bevor du Hardware beschuldigst: lass das Referenz-Tool laufen und lies, wie es seine Inputs konditioniert. Diese Regel (bei uns Rule Zero) war teuer gelernt, über zwei unaligned Tensor-Elemente.
- Minuten Quellcode-Lesen schlagen Tage Symptom-Raten:
paths.ccfür die GDR-Distanz,alltoall.cufürs Alignment,ProcessGroupNCCL.cppfür barrier(). - Alles live Konfigurierte stirbt beim Reboot. Idempotente Apply-Services überall: Boot-Hook auf dem Switch,
roce-node-applyauf jedem Node. - Wenn eine Plattform etwas still nicht kann (Arrow Lake: NIC liest aus GPU-Speicher), beweise es, schreib es auf, melde es upstream: #1253.
- Wisse, wo deine Linie liegt zwischen “hol raus was mir gehört” und “kauf die supportete Kiste”.
Update, 22. Juli — wir haben die These trotzdem totgemessen. Das NVIDIA-Statement oben stand, aber ein Verdacht stand auch noch: unser langsamer Node meldete am GPU-Root-Port MaxPayload 128 bytes statt 256 — im x8-Bifurcation-Split, während die x16-Boards 256 zeigten. Ein BIOS-Changelog-Eintrag (“Silicon Initialization Code” = Intel-FSP-Bump) roch nach Fix. Also: BIOS komplett remote geflasht, über den BMC, ohne Hand am Gerät — der offizielle Pull-Mechanismus des BMC war übrigens kaputt (lädt das Image schlicht nie), funktioniert hat nur Multipart-Push mit einem undokumentierten OemParameters={"ImageType":"BIOS"} und Host vorher aus. Danach, mit Zwischenfällen (der Flash leert die Secure-Boot-MOK-Liste → NVIDIA-DKMS-Modul wird vom Kernel abgelehnt; und das alte NVRAM-Settings-Profil erzwang wieder 128 — erst ein CMOS-Clear per IPMI machte die 256 stabil): Root Port meldet DevCap 256, GPU verhandelt 256, die ganze Kette sauber. Der PCIe-Init-Bug war real und ist gefixt.
Und die Bandbreite? 5.203 MiB/s. Exakt derselbe Wert wie vorher, auf die Nachkommastelle. Message-Size-Sweep von 2 Bytes bis 8 MiB: harter Deckel ab 1 KiB. Vier QPs statt einer: null Skalierung. Die komplette offizielle Tuning-Checkliste (NVIDIA GPUDirect-Doku + Mellanox/HPCAC): MAX_ACC_OUT_READ stand schon auf dem Empfehlungswert 44, PCI_WR_ORDERING=force_relax per Firmware gesetzt und via mstfwreset live aktiviert — keine Änderung. ACS aus, IOMMU aus (null Gruppen, verifiziert), rBAR 32G, MTU, alles durch. Der Deckel bewegt sich um kein einziges Gbit.
Die Lehre in einem Satz: MaxPayload 128 war ein echter Bug, aber nicht unser Bottleneck — auch der P2P-Write-Pfad durch den Arrow-Lake-Root-Complex ist hart gedeckelt, bei uns 43,6 Gbit/s, egal was in den Config-Registern steht. Korrelation (langsamer Node = einziger mit 128) sah nach Beweis aus und war Koinzidenz. Der A/B-Test, der das entlarvt hat, kostete einen BIOS-Flash. Billiger geht Erkenntnistheorie nicht.
Beifang, der jemandem Tage sparen könnte: die GeForce RTX 5090 im vierten Node meldet CU_DEVICE_ATTRIBUTE_DMA_BUF_SUPPORTED = 0 — gleicher Treiber, gleicher Kernel wie die RTX-PRO-Karten daneben, die 1 melden. Treiber-Update, IOMMU-Toggle, Kernel-Update: bleibt 0. GeForce ist von GPUDirect RDMA schlicht ausgeschlossen, Produktpolitik seit jeher (“available on Tesla and Quadro GPUs”), und das alte nvidia-peermem-Fallback lädt beim Open-Kernel-Module gar nicht mehr. Wer GDR will: RTX PRO aufwärts, und kein Intel-Client-Silizium. Unsere Fabric-Zahlen oben bleiben davon unberührt — Host-Staging trägt weiter 94G Wire-Speed bei unidirektionalen Patterns.