RDMA, RoCE and NVIDIA networking
RDMA, Remote Direct Memory Access, lets a program on one machine read or write memory on another machine with no kernel on the data path and, for a one-sided write, no remote CPU involved at all.
A GPU cluster training a large model depends on this. Thousands of GPUs exchange data constantly, and a slow network leaves expensive silicon idle. This article covers how RDMA works, how RoCE carries it over Ethernet, why that is hard, and where NVIDIA’s hardware fits.
What TCP costs
To see what RDMA removes, follow a send() over TCP:
- The application makes a system call, and the CPU enters the kernel.
- The kernel copies the data from the application into socket buffers.
- The TCP/IP stack builds the packets.
- The NIC reads them by DMA and transmits.
- On the far side, an interrupt fires, and the kernel processes the packets.
- The kernel copies the data into the application’s buffer when it calls
recv().
Each step is CPU time. Offloads such as segmentation and checksumming help, but at hundreds of gigabits per second the copies and the protocol processing can occupy whole cores.
RDMA removes them with three ideas:
- Kernel bypass. After setup, the application talks to the NIC directly from user space.
- Zero copy. The NIC reads and writes application memory directly.
- Transport offload. Reliability, ordering, segmentation and retransmission all run on the NIC.
Who does what
The kernel is still needed, but only for the control path.
Control path (slow, through the kernel) Data path (fast, no kernel)
--------------------------------------- ---------------------------
open device post a work request
allocate protection domain ring the doorbell
register memory NIC moves the data
create queues poll for completion
connect queue pairs
The setup work is slow, so applications do it once and reuse the result.
The objects
The programming interface is called verbs, and on Linux it comes from libibverbs in rdma-core.
| Object | Purpose |
|---|---|
| Protection domain (PD) | Groups resources, so a queue pair can only use memory in its own domain |
| Memory region (MR) | A registered buffer the NIC may touch |
| Queue pair (QP) | A send queue and a receive queue, the RDMA version of a socket |
| Completion queue (CQ) | Where the NIC reports finished work |
| Work request (WR) | One operation, posted to a queue |
Memory registration
The NIC cannot use memory unless it is registered first. ibv_reg_mr does three things:
- It pins the pages, so the OS cannot swap or move them while the NIC uses them.
- It gives the NIC the virtual-to-physical translation, since the application will use virtual addresses.
- It returns two keys. The lkey authorises local use. The rkey is handed to a remote peer, and lets it access this region.
Registration is slow and involves the kernel, so real systems register large pools once. A leaked rkey is a real security concern, since it acts as a capability for that memory.
A work request’s journey
To send, the application writes a descriptor into the send queue, which lives in ordinary memory. It then writes to a doorbell, a NIC register mapped into its address space. That write is a plain store instruction with no system call.
Application NIC
----------- ---
write WQE to the send queue
store to doorbell ----------> fetch WQE by DMA
read payload by DMA
build and transmit packets
receive the ACK
write CQE to completion queue
poll CQ <--------------------- (no interrupt needed)
Completion is normally found by polling, which means spinning on a core, at the cost of that core’s time. An interrupt-driven mode exists for when saving CPU matters more than latency.
The operations
Two-sided: SEND and RECEIVE. The receiver posts a buffer first, and an incoming SEND lands in it. Both CPUs are involved in the sense that both post work. If a SEND arrives before a receive is posted, the sender gets an RNR error, Receiver Not Ready, and retries.
One-sided: WRITE and READ. The initiator names a remote address and rkey. The remote NIC completes the work, and the remote CPU does not know it happened. An RDMA WRITE WITH IMMEDIATE carries a 32-bit value and consumes a receive, as a way to notify the far side.
Atomics. Compare-and-swap and fetch-and-add on a remote 64-bit value. These let a machine build remote locks with no remote software.
Here is the core of a one-sided write:
struct ibv_sge sge = {
.addr = (uintptr_t)buf,
.length = len,
.lkey = mr->lkey,
};
struct ibv_send_wr wr = {
.wr_id = 1,
.sg_list = &sge,
.num_sge = 1,
.opcode = IBV_WR_RDMA_WRITE,
.send_flags = IBV_SEND_SIGNALED,
.wr.rdma = {
.remote_addr = peer_addr, /* learned out of band */
.rkey = peer_rkey, /* learned out of band */
},
};
struct ibv_send_wr *bad;
ibv_post_send(qp, &wr, &bad);
struct ibv_wc wc;
while (ibv_poll_cq(cq, 1, &wc) == 0)
; /* spin */
if (wc.status != IBV_WC_SUCCESS)
fprintf(stderr, "%s\n", ibv_wc_status_str(wc.status));
There is no socket and no write(). The operation names a memory address on another computer.
Connecting a queue pair
A QP moves through a state machine with ibv_modify_qp.
RESET -> INIT -> RTR -> RTS
| |
| Ready to Send: can transmit
Ready to Receive: can accept packets
To reach RTR, each side needs the other’s details: QP number, starting packet sequence number, and address, plus the rkey and buffer address for one-sided operations. Verbs do not provide a way to exchange those. Applications usually swap them over a plain TCP connection, or use the RDMA connection manager, librdmacm, which provides a socket-like flow.
Transport types
| Type | Reliable | Connected | Notes |
|---|---|---|---|
| RC, Reliable Connected | Yes | Yes | The usual choice, and supports all operations |
| UC, Unreliable Connected | No | Yes | No ACKs, and no READ or atomics |
| UD, Unreliable Datagram | No | No | One QP to many peers, SEND only, one MTU per message |
RC needs a QP per peer, and the NIC caches QP state. With huge numbers of connections, cache misses go to host memory and performance falls. Shared receive queues reduce the buffer cost. NVIDIA’s Dynamically Connected Transport, DCT, attaches to peers on demand to reduce QP state.
Three ways to carry RDMA
| InfiniBand | RoCE v2 | iWARP | |
|---|---|---|---|
| Network | Dedicated fabric | Ethernet and IP | Ethernet and IP |
| Below the transport | IB link and network layers | UDP/IP | TCP/IP |
| Lossless by | Credit-based flow control | PFC, usually | TCP retransmission |
| Routable | Between subnets, via routers | Yes | Yes |
InfiniBand was designed for this. A link sends only when the receiver has advertised buffer credits, so a congested link pauses rather than drops. A subnet manager computes the routes centrally.
RoCE v1 placed the InfiniBand transport directly in an Ethernet frame, using EtherType 0x8915, so it could not leave a layer-2 network. RoCE v2 puts it in UDP, with destination port 4791, so it can be routed.
iWARP runs RDMA over TCP in the NIC. It handles loss natively, but it has been less widely adopted.
A RoCE v2 packet
+----------+--------+-------+------+-------+---------+------+-----+
| Ethernet | IP | UDP | BTH | RETH | payload | ICRC | FCS |
| 14 | 20 | 8 | 12 | 16 | ... | 4 | 4 |
+----------+--------+-------+------+-------+---------+------+-----+
4791
The Base Transport Header, BTH, holds the opcode, the destination QP, and the packet sequence number. The RDMA Extended Transport Header, RETH, appears on the first packet of a write, and holds the remote address, rkey, and length.
This script builds those headers for a real opcode, 0x0A, RC RDMA WRITE Only, then decodes them:
import struct
def bth(opcode, dest_qp, psn, ack_req=True, pkey=0xFFFF):
return struct.pack(">BBHII", opcode, 0, pkey, dest_qp & 0xFFFFFF,
(ack_req << 31) | (psn & 0xFFFFFF))
def reth(vaddr, rkey, length):
return struct.pack(">QII", vaddr, rkey, length)
payload = b"hello from the NIC"
pad = -len(payload) % 4
headers = bth(0x0A, dest_qp=0x11, psn=1000) + reth(0x7f0012345000, 0xABCD, len(payload))
print(headers[:12].hex(" "))
print(headers[12:].hex(" "))
op, flags, pkey, qp, psn = struct.unpack(">BBHII", headers[:12])
va, rkey, dlen = struct.unpack(">QII", headers[12:28])
print(f"opcode=0x{op:02x} dest_qp=0x{qp:06x} ack_req={psn >> 31} psn={psn & 0xFFFFFF}")
print(f"remote_va=0x{va:x} rkey=0x{rkey:x} length={dlen}")
overhead = 14 + 20 + 8 + 12 + 16 + pad + 4 + 4
print(f"bytes on the wire: {overhead + len(payload)} ({overhead} of it headers, padding and checksums)")
Output:
0a 00 ff ff 00 00 00 11 80 00 03 e8
00 00 7f 00 12 34 50 00 00 00 ab cd 00 00 00 12
opcode=0x0a dest_qp=0x000011 ack_req=1 psn=1000
remote_va=0x7f0012345000 rkey=0xabcd length=18
bytes on the wire: 98 (80 of it headers, padding and checksums)
The packet literally carries a memory address and a key. The receiving NIC checks the key and the bounds, then writes the payload to memory by DMA. No software on the host sees it.
The opcode’s top three bits select the transport type, and the rest select the operation.
| Opcode | Meaning |
|---|---|
0x04 | RC SEND Only |
0x06, 0x07, 0x08 | RC RDMA WRITE First, Middle, Last |
0x0A | RC RDMA WRITE Only |
0x0C | RC RDMA READ Request |
0x11 | RC Acknowledge |
0x64 | UD SEND Only |
0x81 | CNP, Congestion Notification Packet |
A message larger than the path MTU is split into First, Middle and Last packets, and the NIC does this itself.
Why RoCE wants a lossless network
Classic RC NICs use go-back-N. Packets must arrive in sequence. If one is missing, the receiver discards everything after it, and the sender restarts from the gap. That keeps the hardware simple, since the NIC needs no reorder buffer, but it means one lost packet wastes a whole window of data.
TCP was built for loss. This transport was built for InfiniBand, where packets are not dropped for congestion. The next simulation shows the difference, with 1,000 packets in flight:
import random
def go_back_n(loss, window, packets):
sent = base = 0
while base < packets:
for i in range(min(window, packets - base)):
sent += 1
if random.random() < loss:
sent += window - i - 1 # already in flight, and all discarded
base += i
break
else:
base += min(window, packets - base)
return sent
def selective(loss, window, packets):
# only the lost packet is resent
sent = 0
for _ in range(packets):
sent += 1
while random.random() < loss:
sent += 1
return sent
random.seed(7)
N, W = 2_000_000, 1000
for loss in (0, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2):
g, s = go_back_n(loss, W, N), selective(loss, W, N)
print(f"loss {loss:<7g} go-back-N goodput {100 * N / g:6.1f}% selective {100 * N / s:6.1f}%")
| Packet loss | Go-back-N goodput | Selective retransmit |
|---|---|---|
| 0.0001% | 100.0% | 100.0% |
| 0.001% | 99.6% | 100.0% |
| 0.01% | 95.0% | 100.0% |
| 0.1% | 63.4% | 99.9% |
| 1% | 10.1% | 99.0% |
The model is deliberately simple, and it ignores timeouts, which make real losses worse. With 0.1% loss, over a third of the capacity goes to resending data that arrived safely. A bigger bandwidth-delay product means a bigger window, so faster networks suffer more.
That result drove a decade of work on making Ethernet behave as if it never drops anything.
PFC: stopping the sender
Ethernet drops a packet when a switch buffer fills up. Priority Flow Control, IEEE 802.1Qbb, prevents this. When a queue passes a threshold, the switch sends a pause frame to the upstream device for that one priority out of eight. RoCE takes one priority class, and ordinary traffic stays on another.
Sender --> Switch A --> Switch B --> Receiver
<---- PAUSE (priority 3)
<---- PAUSE (priority 3)
PFC is effective, but blunt. Microsoft’s 2016 paper on running RDMA at scale documents the problems:
- Head-of-line blocking. A pause stops all traffic of that priority on the link, including flows heading to uncongested destinations.
- Congestion spreading. Pauses travel upstream, hop by hop, across the fabric.
- Deadlock. A cycle of buffers can form in which each switch waits on the next, and nothing moves.
- Pause storms. A malfunctioning NIC that cannot receive can send pauses continuously, and the effect can spread widely. Switches and NICs now have watchdogs for this.
So PFC should be the last resort, not the main control.
ECN and DCQCN: slowing the sender
ECN lets a switch mark a packet instead of dropping it, using two bits in the IP header. DCQCN, from a 2015 SIGCOMM paper by Microsoft and Mellanox researchers, uses those marks for congestion control. It has three roles:
Sender NIC Switch Receiver NIC
(reaction point) (congestion point) (notification point)
| ---- data -----> marks ECN ---- data ----> |
| |
| <----------------- CNP --------------------- |
|
cuts rate, then recovers gradually
A switch marks packets as a queue grows. The receiver’s NIC sees the mark and returns a CNP. The sender’s NIC cuts its rate, then raises it gradually if no further CNPs arrive. Everything runs in NIC hardware.
The aim is that ECN slows traffic before queues reach the PFC threshold. Tuning those two thresholds relative to each other is much of the work of operating RoCE. DCQCN is not part of the RoCE v2 specification itself. It is an algorithm that NICs implement on the specification’s ECN and CNP building blocks.
Load balancing: the AI traffic problem
Ethernet fabrics usually use ECMP: hash a flow’s headers, and send every packet of the flow along one of the equal-cost paths. RoCE v2 uses the UDP source port for this entropy, and it varies by QP.
ECMP works when there are many small flows. AI training has the opposite: a small number of huge, synchronised flows. Two big flows can hash onto one link while another link sits empty. A collective operation such as all-reduce finishes when its slowest flow finishes, so one collision slows the whole job.
The fix is to spray a flow’s packets across all paths. But sprayed packets arrive out of order, and go-back-N treats that as loss. Modern designs therefore need a NIC that tolerates reordering. Direct data placement helps here: since every write packet can identify where its payload belongs, a NIC can place packets in memory on arrival, whatever the order, if it tracks which ones have arrived.
Research reached a similar conclusion from another angle. IRN, a 2018 SIGCOMM paper, argued that with selective retransmission in the NIC, RDMA can work well without PFC.
NVIDIA’s hardware
NVIDIA acquired Mellanox in 2020, and with it most of the RDMA hardware used in practice.
| Product | Role |
|---|---|
| ConnectX | RDMA NICs, for Ethernet and InfiniBand |
| BlueField | A DPU: a NIC with its own Arm cores |
| Quantum | InfiniBand switches |
| Spectrum | Ethernet switches |
| NVLink | GPU-to-GPU links inside a node or rack, separate from RDMA |
On Linux, ConnectX is driven by mlx5. Software usually reaches it through UCX, or through NCCL for GPU collectives, rather than through raw verbs.
Spectrum-X
Spectrum-X is NVIDIA’s Ethernet platform for AI, and pairs Spectrum-4 switches with BlueField-3 or ConnectX-8 SuperNICs. As NVIDIA describes it:
- The switch spreads a flow’s packets across paths, avoiding the hot spots of static hashing.
- The SuperNIC handles the reordering, so the transport sees consistent data.
- The switch sends telemetry to the SuperNICs, which adjust flow rates in a fast hardware loop.
- PFC and direct data placement together avoid drops and retransmissions.
ConnectX-8 provides up to 800 Gb/s.
Ultra Ethernet
The Ultra Ethernet Consortium, a Linux Foundation project, published its 1.0 specification on 11 June 2025. Its transport, UET, is designed for packet spraying and out-of-order delivery from the start, rather than inheriting InfiniBand’s in-order model. The direction across the industry is the same: multipath, out-of-order delivery, and less dependence on a lossless fabric.
GPUDirect RDMA
Without GPUDirect, sending from a GPU means copying to host memory first:
Without: GPU memory -> host memory -> NIC -> network
With: GPU memory ---------------> NIC -> network
With GPUDirect RDMA, the NIC reads and writes GPU memory directly across PCIe, as a peer. GPU memory is registered much as host memory is, and the rest of the verbs flow is the same.
There are two kernel paths to this:
- DMA-BUF uses a standard Linux mechanism for sharing buffers between devices. NVIDIA recommends it where supported. It needs the open GPU kernel modules, and the application has to ask for it.
nvidia-peermemis the older kernel module, and remains available.
NCCL uses DMA-BUF by default when the kernel and drivers support it.
Hardware placement matters. The GPU and the NIC should sit under the same PCIe switch, so traffic need not cross the CPU’s root complex. This command prints the topology:
nvidia-smi topo -m
Look for PIX or PXB between the GPU and the NIC, rather than SYS.
Hands on: RDMA with no RDMA hardware
Soft-RoCE, the rdma_rxe kernel module, implements RoCE v2 in software over any Ethernet interface. It is slower than an ordinary socket, and exists for learning and development, but the API is real. You need two Linux machines or VMs that can reach each other.
sudo apt install rdma-core ibverbs-utils rdmacm-utils perftest iproute2
sudo modprobe rdma_rxe
sudo rdma link add rxe0 type rxe netdev eth0
rdma link # shows rxe0 on eth0
ibv_devices # lists rxe0
ibv_devinfo -v # ports, MTU, GID table
The GID table is the list of addresses a port can use. Pick the index that holds the machine’s IP address and is marked RoCE v2. With hardware from NVIDIA’s driver stack, show_gids prints this directly.
Run the classic ping-pong, which uses an RC queue pair:
# On machine A
ibv_rc_pingpong -d rxe0 -g 1
# On machine B
ibv_rc_pingpong -d rxe0 -g 1 <machine-A-ip>
Then a bandwidth test with RDMA writes:
# On machine A
ib_write_bw -d rxe0 -x 1
# On machine B
ib_write_bw -d rxe0 -x 1 <machine-A-ip>
Replace 1 with your GID index. Latency variants, such as ib_write_lat and ib_read_lat, take the same arguments.
Soft-RoCE traffic goes through the kernel’s network stack, so you can capture it, and Wireshark decodes it as RoCE v2:
sudo tcpdump -i eth0 -w roce.pcap udp port 4791
You will see the opcodes from the table above, with their sequence numbers and ACKs. On a real RoCE NIC, this capture normally shows nothing, because the traffic never enters the kernel. That is the point of RDMA, and it also makes RDMA harder to debug.
Watching a real RoCE NIC
Since the kernel does not see the data path, you rely on the NIC’s counters.
rdma statistic show
ls /sys/class/infiniband/mlx5_0/ports/1/hw_counters/
ethtool -S eth0 | grep -E 'prio3|pause'
These mlx5 counters are the ones to watch:
| Counter | Meaning |
|---|---|
np_ecn_marked_roce_packets | ECN-marked packets received |
np_cnp_sent | CNPs sent as receiver |
rp_cnp_handled | CNPs acted on as sender |
out_of_sequence | Packets that arrived out of order |
packet_seq_err | NAKs received for sequence errors |
local_ack_timeout_err | Timeouts waiting for an ACK, a bad sign |
ECN marks show the network working as designed. Sustained pause frames show congestion control losing the race. Sequence errors and timeouts on a fabric meant to be lossless show that something is dropping packets.
NCCL has its own controls for these settings. NCCL_IB_HCA picks the NICs, NCCL_IB_GID_INDEX picks the GID, and NCCL_IB_TC sets the traffic class, so that traffic lands in the lossless priority.
What to take away
- RDMA moves the transport into the NIC, and takes the kernel off the data path.
- A queue pair replaces a socket, and registration replaces copying.
- Verbs provide speed, at the cost of pinned memory, QP state, and harder debugging.
- RoCE inherited a transport that assumes no loss, and Ethernet had to be engineered to match.
- PFC prevents drops, and DCQCN tries to keep PFC from being needed.
- AI traffic breaks flow hashing, which pushes the industry towards packet spraying and out-of-order delivery.