RoCE v2 + DCB/PFC/ECN: doing RDMA on Ethernet
Why RDMA on ethernet only works when the fabric is lossless — PFC, ECN, DCQCN, DSCP, and the configuration that makes RoCE actually deliver line rate.
help for the full list, or solutions for copy-paste fix recipes.RoCE (RDMA over Converged Ethernet) is what most GPU clusters built after 2022 use, because ethernet vendors finally caught up on price/perf with InfiniBand and customers already had ethernet operations teams. From the NIC's perspective, RoCE looks like InfiniBand — same verbs API, same RC QPs, same ibv_post_send. From the fabric's perspective, it's UDP packets. Making those two views co-exist is the entire job.
This page walks through what RoCE is, why it depends on a lossless fabric, and the configuration knobs (PFC, ECN, DCQCN, DSCP, TC mapping) you need to get right or RoCE works in the lab and falls over the moment a real workload starts.
RoCE v1 vs v2
| Version | Encapsulation | Routable? | Deployed in 2026? |
|---|---|---|---|
| RoCE v1 | IB transport headers directly over Ethernet (EtherType 0x8915) | No — L2 only | Almost never |
| RoCE v2 | IB transport over UDP/IPv4 or UDP/IPv6, dst port 4791 | Yes — full L3 | Standard |
RoCE v2 is what everyone runs. It puts the InfiniBand transport layer (BTH + payload) inside a UDP datagram, which means ECMP at the switch can hash on UDP source port to spread flows across multiple paths. Mellanox HCAs vary the UDP src-port per QP (or per packet on some firmware) precisely to enable that.
Practical implication: every RoCE flow has an identifiable IP src/dst and UDP/4791 destination. You can tcpdump -i ens3np0 udp port 4791 and see the BTH headers. You can ACL it. You can route it across a leaf-spine like any other traffic.
Why RoCE needs a lossless fabric
The killer detail: RC QP retransmit is "go-back-N" with hardware-fixed retry counts. When a packet is dropped, the receiver gets out-of-order PSN and NAKs, the sender retransmits everything from that point onward, and after qp_retry_cnt (default 7) failed retries, the QP transitions to ERROR and your app sees vendor err 81.
In a TCP world, drops are normal. TCP's congestion control is built around drops (cubic, BBR, etc.). In a RoCE world, drops are catastrophic. A single buffer overflow on a switch egress queue can poison enough packets to kill a NCCL allreduce that touches thousands of QPs.
So RoCE deployments use lossless ethernet — meaning the switches are configured to never drop the RoCE traffic class. Instead, when an egress queue fills, the switch sends a PAUSE frame upstream telling the sender to stop. The sender stops. The buffer drains. The PAUSE expires. Traffic resumes. Zero packet loss.
That is PFC.
PFC (Priority Flow Control), IEEE 802.1Qbb
802.1Qbb extends 802.3x PAUSE to be per-priority instead of per-port. Without it, pausing one priority would pause everything (storage, control plane, north-south traffic). With 802.1Qbb you can say "pause only traffic on priority 3" — RoCE on priority 3 gets paused, everything else flows.
The mechanism:
- RoCE traffic is tagged with a priority via DSCP-to-PCP mapping (or PFC-watch on the ingress).
- Switch egress queues are configured per-priority. The "RoCE queue" has an Xoff threshold below the queue size.
- When the RoCE queue hits Xoff, the switch sends a PFC PAUSE frame upstream with that priority bit set.
- The upstream port stops sending RoCE-priority traffic for the pause-quanta encoded in the frame.
- Below Xon threshold, the queue resumes (Xon frame).
You configure PFC in three places, all of which must agree:
- NIC: which DSCP / PCP value RoCE uses (
mlnx_qos -i ens3np0 --trust dscpand DSCP-to-priority mapping). - Switch port: PFC enabled on the priority that matches.
- Switch queue/buffer: Xoff/Xon thresholds, MMU sizing.
If any one of those three is wrong, you either drop packets (RoCE collapses) or you pause too aggressively and saturate the head-of-line.
The dreaded congestion spreading problem
PFC is a victim. A slow receiver causes its ingress queue to fill. PFC pauses the upstream. The upstream's egress queue starts filling because it can't drain to the slow receiver. The upstream pauses its upstream. That cascades back through the fabric. Now traffic destined for other receivers also gets paused, because they share intermediate links with the slow path.
This is "PFC propagation" or "victim flow" or "congestion spreading," depending on which paper you read. It's why ECN+DCQCN exists: to back off the sender before PFC kicks in, so PFC remains a safety net rather than the steady-state mechanism.
A symptom you'll learn to recognize: NCCL allreduce running at 380 Gb/s for 9 iterations and 50 Gb/s for 1 iteration, repeatedly. That's PFC hits — switch is pausing because some flow is starved, the whole job stalls until the queue drains.
ECN + DCQCN
ECN (Explicit Congestion Notification, RFC 3168) is the classic IP-layer "I'm getting congested, slow down" signal. Switches flip the CE bit in the IP header when a queue exceeds a threshold (well below the PFC Xoff). The receiver echoes that back to the sender, and the sender is supposed to reduce its rate.
For RoCE, the protocol that does the rate reduction is DCQCN (Data Center Quantized Congestion Notification), first published by Microsoft and Mellanox. The sender NIC implements an algorithm roughly:
- Start at line rate.
- On receiving a CNP (Congestion Notification Packet — generated by the receiver in response to a CE-marked packet), reduce rate multiplicatively (current_rate *= alpha, alpha typically 0.5).
- While no CNPs arrive, increase rate additively at fixed time intervals.
The whole loop happens on the NIC, in microseconds. Configured via mlxreg, mlnx_qos, or cma_roce_mode:
$ mlnx_qos -i ens3np0
DCBX mode: OS controlled
Priority trust state: dscp
dscp2prio mapping:
prio:0 dscp:07,06,05,04,03,02,01,00,
prio:1 dscp:15,14,13,12,11,10,09,08,
prio:2 dscp:23,22,21,20,19,18,17,16,
prio:3 dscp:31,30,29,28,27,26,25,24, <-- RoCE typically uses prio 3, DSCP 26 (AF31)
prio:4 dscp:39,38,37,36,35,34,33,32,
prio:5 dscp:47,46,45,44,43,42,41,40,
prio:6 dscp:55,54,53,52,51,50,49,48,
prio:7 dscp:63,62,61,60,59,58,57,56,
PFC configuration:
priority 0 1 2 3 4 5 6 7
enabled 0 0 0 1 0 0 0 0
The convention almost everyone uses: DSCP 26 (AF31) → priority 3 → PFC priority 3. ECN-marked thresholds set on switch ingress queue 3 below the PFC Xoff threshold.
Configuring DSCP for RoCE on the host
# Trust DSCP (not PCP, not VLAN priority)
mlnx_qos -i ens3np0 --trust dscp
# Map DSCP 26 to priority 3
mlnx_qos -i ens3np0 --dscp2prio set,26,3
# Enable PFC on priority 3 only
mlnx_qos -i ens3np0 --pfc 0,0,0,1,0,0,0,0
# Make RoCE use TOS 0x68 (DSCP 26 << 2)
cma_roce_tos -d mlx5_0 -t 106
cma_roce_tos writes the byte directly into the IP TOS field; 106 = 0x6A = 26<<2 | ECN_CAPABLE. The shift is because TOS = DSCP (high 6 bits) | ECN (low 2 bits).
For NCCL to actually use these values, set:
export NCCL_IB_TC=106
export NCCL_IB_GID_INDEX=3
NCCL_IB_TC is the IP TOS NCCL stamps on RoCE v2 packets. Should match cma_roce_tos. NCCL_IB_GID_INDEX picks the right GID — see below.
Picking the right GID
When RoCE comes up, the NIC populates a GID table. Each port gets multiple entries representing each L2/L3 binding:
$ show_gids
DEV PORT INDEX GID IPv4 VER DEV
--- ---- ----- --- ---- --- ---
mlx5_0 1 0 fe80:0000:0000:0000:5af3:1100:0011:abcd v1 ens3np0
mlx5_0 1 1 fe80:0000:0000:0000:5af3:1100:0011:abcd v2 ens3np0
mlx5_0 1 2 0000:0000:0000:0000:0000:ffff:0a000a01 10.0.10.1 v1 ens3np0
mlx5_0 1 3 0000:0000:0000:0000:0000:ffff:0a000a01 10.0.10.1 v2 ens3np0
Index 0 = link-local IPv6 RoCE v1, Index 1 = same v2, Index 2 = IPv4 v1, Index 3 = IPv4 v2. You always want v2. The IPv4 v2 entry is the one to point NCCL at.
Inside Kubernetes pods using macvlan, GID indices 0-3 are usually empty (no default interface) and macvlan-bound entries appear at indices 4-7. If you see all-zero GIDs at index 0-3, that doesn't mean RoCE is broken — check higher indices. A real-world layout in a cluster with macvlan-bound RoCE NICs:
GID[0]: 0000:0000:0000:0000:0000:0000:0000:0000 type=
GID[1]: 0000:0000:0000:0000:0000:0000:0000:0000 type=
GID[2]: 0000:0000:0000:0000:0000:0000:0000:0000 type=
GID[3]: 0000:0000:0000:0000:0000:0000:0000:0000 type=
GID[4]: 0000:0000:0000:0000:0000:ffff:0a000a01 type=IB/RoCE v1
GID[5]: 0000:0000:0000:0000:0000:ffff:0a000a01 type=RoCE v2 <-- NCCL_IB_GID_INDEX=5
GID[6]: fe80:0000:0000:0000:6426:b4ff:feXX:XXXX type=IB/RoCE v1
GID[7]: fe80:0000:0000:0000:6426:b4ff:feXX:XXXX type=RoCE v2
In that layout NCCL_IB_GID_INDEX=5 selects the IPv4 RoCE v2 entry and that's the only one that routes correctly.
Validation: is PFC actually working
$ ethtool -S ens3np0 | grep -i pause
rx_pause_ctrl_phy: 0
tx_pause_ctrl_phy: 0
rx_prio0_pause: 0
rx_prio3_pause: 1842
tx_prio3_pause: 0
rx_prio3_pause_duration: 18430
tx_prio3_pause_duration: 0
What you want to see:
rx_prio3_pausenon-zero → switch is sending you PAUSE frames (means the switch hit Xoff for prio 3 — congestion happened, PFC saved you).tx_prio3_pausenon-zero → you sent PAUSE frames (your local NIC is the one congested, e.g., GPU was slow consuming).rx_pause_ctrl_phy(global pause) non-zero → 802.3x global pause is on instead of per-priority. Bad. That pauses everything.rx_prio0..2,4..7_pausenon-zero → wrong priority enabled or DSCP misconfigured.
The numbers should grow slowly under load and stay near zero at idle. If tx_prio3_pause grows fast, your GPU-side path is the bottleneck (slow MR, GDR not active, IOMMU forcing bounce buffer).
mlnx_perf — the swiss army knife
$ mlnx_perf -i ens3np0 -t 1
rx_packets_phy: 1,234,567
tx_packets_phy: 1,234,560
rx_bytes_phy: 542 GB/s
tx_bytes_phy: 540 GB/s
rx_prio3_bytes: 540 GB/s
rx_prio3_pause: 12 (rate)
tx_pause_storm_warning: 0
tx_pause_storm_warning non-zero is a five-alarm fire — you're spending more than half your time paused.
Common failure modes
PFC storms
A misbehaving NIC asserts PFC continuously. The upstream switch port gets paused indefinitely. ALL traffic through that switch port is starved (because PFC propagates). Symptom: one node's slow training pegs the cluster. Mitigation: PFC watchdog on the switch (drop the priority after N seconds of continuous pause) — every modern Mellanox/NVIDIA Spectrum, Arista, Cisco Nexus 9k supports this.
Head-of-line blocking
PFC pauses prio 3 on a port. All flows on prio 3 through that port are blocked, even ones not destined for the actual congested receiver. Mitigation: keep paths short (leaf-spine, no extra hops), use ECN + DCQCN to back off senders before PFC.
DSCP mis-mapped
You set TOS=0x68 on the host. Switch ACL is matching TOS=0x60. RoCE goes into the default queue, gets dropped under load. Symptom: vendor err 81 under load, fine at idle. Validate with tcpdump -i any -nn 'udp port 4791' -e -X and check the IP TOS byte.
Switch QoS not configured
PFC enabled on the host but not on the switch. NIC sends pause frames; switch ignores them. NIC's queue overflows; drops happen. Symptom: same as above, vendor err 81. Validate by counting rx_prio3_pause on the host — if zero under load, switch isn't pausing anything.
Wrong link layer
$ ibv_devinfo -d mlx5_0 | grep link_layer
link_layer: Ethernet
If this says InfiniBand on a card you expected to be RoCE, the port was set to IB mode. Flip with mlxconfig -d <pci> set LINK_TYPE_P1=2 (2 = ETH, 1 = IB), then power-cycle.
MTU mismatch
RoCE pmtu must match end-to-end. NIC MTU 9000, switch MTU 1500 in the middle, drops happen on the first fragment. Verify with ip link show ens3np0 and from the switch show interfaces ethernet 1/1 mtu. Also check NCCL's view: NCCL_DEBUG=INFO will show pmtu per QP.
Reference: typical RoCE config snapshot for a GPU node
| Setting | Value | Where to set |
|---|---|---|
| Trust mode | DSCP | mlnx_qos -i $iface --trust dscp |
| DSCP for RoCE | 26 | NCCL_IB_TC=106 + cma_roce_tos -t 106 |
| Priority for DSCP 26 | 3 | mlnx_qos --dscp2prio set,26,3 |
| PFC enabled priorities | 3 only | mlnx_qos --pfc 0,0,0,1,0,0,0,0 |
| ECN | enabled on prio 3 | switch config |
| DCQCN | enabled | mlxreg + sysfs (default OK) |
| MTU | 9000 (jumbo) | ip link, switch, end-to-end |
| RoCE mode | v2 | enabled by default in modern firmware |
| Link layer | Ethernet | mlxconfig LINK_TYPE_P1=2 |