Ashok G

Draft

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:

  1. The application makes a system call, and the CPU enters the kernel.
  2. The kernel copies the data from the application into socket buffers.
  3. The TCP/IP stack builds the packets.
  4. The NIC reads them by DMA and transmits.
  5. On the far side, an interrupt fires, and the kernel processes the packets.
  6. 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.

ObjectPurpose
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

TypeReliableConnectedNotes
RC, Reliable ConnectedYesYesThe usual choice, and supports all operations
UC, Unreliable ConnectedNoYesNo ACKs, and no READ or atomics
UD, Unreliable DatagramNoNoOne 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

InfiniBandRoCE v2iWARP
NetworkDedicated fabricEthernet and IPEthernet and IP
Below the transportIB link and network layersUDP/IPTCP/IP
Lossless byCredit-based flow controlPFC, usuallyTCP retransmission
RoutableBetween subnets, via routersYesYes

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.

OpcodeMeaning
0x04RC SEND Only
0x06, 0x07, 0x08RC RDMA WRITE First, Middle, Last
0x0ARC RDMA WRITE Only
0x0CRC RDMA READ Request
0x11RC Acknowledge
0x64UD SEND Only
0x81CNP, 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 lossGo-back-N goodputSelective 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.

ProductRole
ConnectXRDMA NICs, for Ethernet and InfiniBand
BlueFieldA DPU: a NIC with its own Arm cores
QuantumInfiniBand switches
SpectrumEthernet switches
NVLinkGPU-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-peermem is 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:

CounterMeaning
np_ecn_marked_roce_packetsECN-marked packets received
np_cnp_sentCNPs sent as receiver
rp_cnp_handledCNPs acted on as sender
out_of_sequencePackets that arrived out of order
packet_seq_errNAKs received for sequence errors
local_ack_timeout_errTimeouts 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.