傻瓜式RDMA高性能网络开发:从零跑到 400 Gb每秒

用 Go 写 RDMA,到底能有多简单?又能有多快?这篇带你从零跑到 400 Gb/s。

开篇:一个让人又爱又怕的技术

如果你做过高性能网络,一定听过 RDMA 这个词。它是 AI 训练集群里 GPU 之间狂飙数据的底层、是分布式存储压榨延迟的杀手锏、是金融交易系统微秒必争的武器。

两种传输 & 两种操作

  • RC(可靠连接,类比 TCP):有序可靠,支持双边和单边操作

  • UD(不可靠数据报,类比 UDP):无连接,一对多

  • 双边操作(Send/Recv):接收方要先挂好接收请求,双方 CPU 都参与

  • 单边操作(RDMA Write/Read):发起方直接读写对端内存,对端 CPU 完全不参与——这是 RDMA 最"魔法"的地方


二 · 先用 perftest 摸清家底

在写代码之前,先得知道你的网卡能跑多快。业界标准是 perftest(linux-rdma 出品的 C 版基准工具)。gordma 贴心地用 Go 复刻了一套对标工具,放在 cmd/ 下:

工具 对标 测什么
go_send_bw / lat ib_send_bw/lat 双边 Send 的带宽 / 延迟
go_write_bw / lat ib_write_bw/lat 单边 Write
go_read_bw / lat ib_read_bw/lat 单边 Read
go_rdmanet_bw / lat —(高级) 测 gordma 高级 API

命名规律很简单:操作(send/write/read) + 指标(bw 带宽 / lat 延迟)。每个工具不带地址就是服务端,带对端地址就是客户端

跑一把带宽测试:

1
2
3
4
5
6
7
go build -o bin/ ./cmd/...

# 服务端(不带地址)
./bin/go_send_bw -s 65536 -n 1000000 -d mlx5_1 -x 3

# 客户端(带服务端 IP)
./bin/go_send_bw -s 65536 -n 1000000 -d mlx5_1 -x 3 33.0.226.25:18515

输出:

1
2
#bytes    #iterations   BW average[MB/s]   MsgRate[Mpps]
65536 1000000 48996.54 0.747628

48996 MB/s ≈ 392 Gb/s(注意单位:go_send_bw 输出的是 MB/s=10⁶ 字节/秒,×8÷1000 才是 Gb/s),这就是这张 400G 网卡的实力基准。记住这个数,后面要拿它当标尺。

⚠️ 单位是个大坑:三个常用工具输出单位各不相同,直接比原始数会差出 8 倍——C 版 ib_send_bwMiB/s(2²⁰ 字节)、Go 版 go_send_bwMB/s(10⁶ 字节)、gordma 的 --rawMiB/s(已对齐 C 版)。本文所有数字都统一换算到 Gb/s(10⁹ bit) 再比较。

💡 小贴士:命令里的 IP 是服务端 -d 指定的那张 RoCE 网卡绑定的 IP,不是CPU网络/SSH 那个 IP。这是新手最容易连不上的坑。两端的 -d(设备)和 -x(GID 索引,RoCE v2 常用 3, 可以使用show_gids查看)要对齐同一张物理网络。


三 · 底层 API:完全掌控,但要写够样板

gordma 的底层包 gordma 一比一映射了 RDMA 的对象模型。想要完全掌控每个工作请求、每个 QP 参数,用它。代价是:你得自己走完那七步。

来看一个完整可跑的 RC 回显(用 rdma_cm 建连,省掉手写状态机):

服务端:收一条,回显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func server(addr string) error {
ln, _ := gordma.Listen(addr) // rdma_cm 监听
defer ln.Close()
cm, _ := ln.Accept() // QP 已在 RTS 状态
defer cm.Close()
qp, cq, pd := cm.QP(), cm.CQ(), cm.PD()

// 注册接收缓冲区——网卡只能 DMA 已注册内存
mr, _ := pd.RegMRBuffer(4096, gordma.AccessLocalWrite)
defer mr.Close()

// 收之前必须先挂 recv,否则对端发来会 RNR
sge := gordma.SGEFromMR(mr, 0, 4096)
qp.PostRecv(gordma.RecvWR{WRID: 1, SGList: []gordma.SGE{sge}})

// 轮询完成队列
wc := make([]gordma.WorkCompletion, 1)
pollOne(cq, wc)
msg := mr.Bytes()[:wc[0].ByteLen]
fmt.Printf("got %q\n", msg)

// 原样发回
copy(mr.Bytes(), msg)
qp.PostSend(gordma.SendWR{
WRID: 2, Opcode: gordma.OpSend,
SGList: []gordma.SGE{gordma.SGEFromMR(mr, 0, len(msg))},
Signaled: true,
})
pollOne(cq, wc) // 等发送完成
return nil
}

// 忙轮询 CQ 直到取到一个完成
func pollOne(cq *gordma.CQ, wc []gordma.WorkCompletion) {
for {
if n, err := cq.Poll(wc); err != nil || n > 0 {
return
}
}
}

每一行都对应一个 RDMA 概念:注册内存 → 先挂 recv → 轮询 CQ → post send。底层 API 的好处是没有任何隐藏行为,你能做单边 Write/Read、能精调 QP 容量、能复刻 perftest——坏处是,样板真的多。


四 · 高级 API:像写 net 一样写 RDMA

如果你只是想写业务,不想碰 MR、WR、CQ 这些——用 rdmanet 子包。它把上面那一大坨全收进了 Dial / Listen / SendMsg / RecvMsg

来看同样的事,高级怎么写。一个 RPC 服务:

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func serve(addr string, opts []rdmanet.Option) error {
ln, err := rdmanet.Listen(addr, opts...)
if err != nil { return err }
defer ln.Close()
for {
conn, err := ln.Accept()
if err != nil { return err }
go handle(conn) // 每个连接一个 goroutine
}
}

func handle(conn *rdmanet.Conn) {
defer conn.Close()
for {
req, err := conn.RecvMsg() // 收一条完整请求
if err == io.EOF { return } // 客户端关闭,正常结束
if err != nil { return }
conn.SendMsg(process(req)) // 处理并回复
}
}

客户端

1
2
3
4
5
conn, _ := rdmanet.Dial("33.0.226.25:18515",
rdmanet.WithDevice("mlx5_1"), rdmanet.WithGIDIndex(3))
defer conn.Close()
conn.SendMsg([]byte("hello"))
reply, _ := conn.RecvMsg() // 阻塞等响应

没有 MR、没有 WR、没有 CQ 轮询、没有状态机。 是不是和标准库 net 一模一样?

rdmanet 还提供了一整套实用能力:

  • 消息语义 SendMsg/RecvMsg:保留边界,大消息自动分片重组
  • 字节流适配器 Read/Write:Conn 直接满足 io.ReadWriteCloser,能配 io.Copy 传文件
  • 批量 I/O SendBatch/RecvBatch:摊薄每次调用开销
  • UD 数据报 PacketConn:无连接、一对多
  • 地址注册表 Registry:带外发现对端

仓库里还附带了 17 个按功能拆分的示例(examples/ 目录),从最小回显到全双工聊天、文件传输、一对多广播,一个功能一个目录,照着抄就行。


五 · RawConn:既要 net 风格,又要榨干网卡

高级 Conn 好用,但有个问题:它为了"保留消息边界 + 流控 + 易用"付出了固定成本——封帧、信用流控、bounce 缓冲拷贝、后台 poller goroutine 的跨线程交接。这些叠加起来,让它在 64KB 大包上只能跑到约 28 Gb/s,远没喂满 400G 网卡。

于是 gordma 给了第三个选择:RawConn

它的理念很直接:把所有花哨的东西全剥掉,直接暴露"注册内存 + 投递 WR + 自己轮询 CQ",在同一个 goroutine 里 post + busy-poll,无封帧、无流控、无交接。这正是 perftest 打满线速的那套循环。

最省事的用法是内置的 PipelineBatch,保持 N 个请求 in-flight(同时在网卡里跑),每完成一个补一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
rc, _ := rdmanet.DialRaw(addr,
rdmanet.WithDevice("mlx5_1"),
rdmanet.WithGIDIndex(3),
rdmanet.WithQueueDepth(128))
defer rc.Close()

mr, _ := rc.RegisterMemory(size * txDepth)
defer mr.Close()

rc.PipelineBatch(iters, txDepth, func(wrID uint64) gordma.SendWR {
slot := int(wrID) % txDepth
return gordma.SendWR{
WRID: wrID,
Opcode: gordma.OpSend,
SGList: []gordma.SGE{gordma.SGEFromMR(mr, slot*size, size)},
Signaled: true,
}
})

RawConn 还支持:

  • 单边 Write/Read:走 TCP 握手交换了对端 rkey/地址,可以直接做"对端 CPU 不参与"的远程读写
  • 批量提交 PostSendBatch:用 WR 链表一次 ibv_post_send 提交多个请求,把 cgo 跨界开销从"每个 WR 一次"降到"每批一次",小包消息率因此能提升约一个数量级
  • 逃生舱 QP()/CQ()/PD():需要时随时下沉到底层自己驱动

代价当然有:RawConn 不替你保留消息边界、不做流控(得自己控制 in-flight 数,否则 RNR)、不托管缓冲区。一句话:先用 Conn,确实要榨干网卡时再上 RawConn

🔬 顺带破一个误解:很多人(包括我一开始)以为"Go 经 cgo 调 RDMA 一定比 C 慢一截"。我用 GORDMA_PROBE=1 把发送循环拆成"提交 WR(post)"和"忙等完成(poll)"两段实测,结论反直觉:一次 ibv_post_send 含 cgo 跨界约 300ns,只占总时间 ~15%,而且 go_send_bwRawConn 两者完全相同。也就是说——cgo 提交开销真实存在但很小,不是性能差距的主因。后面第六节会看到,go_send_bw 状态好时能直接追平 C 版 ib_send_bw,根本没有"Go 追不上 C"的固有差距。


六 · 真刀真枪:带宽压测对比

理论讲完,上数据。同一对 400G RoCE v2 节点,64KB 大包,100 万条消息,实测:

结论很清楚:

  • ConnRawConn,同一个库、同一张卡,吞吐 暴涨约 8 倍,证明那 28 Gb/s 的天花板就是高级那套便利机制的固定成本。
  • RawConn 用纯 Go(加薄薄一层 cgo)把吞吐推到了 230+ Gb/s 的量级,已经和同一个库的底层 go_send_bw 在同一个数量级。

一个反直觉的发现:差距不在 cgo,而且不是固定的

我原本想搞清"RawConn(232) 为什么比 go_send_bw(392) 慢约 1.7 倍",于是做了一组同机、同口径、交替跑的实验(锁核 taskset + 性能调频,尽量压住抖动),用 GORDMA_PROBE=1 拆出 post/poll。结果挖出三件事:

① cgo 提交不是瓶颈。 两个工具的 post(提交 WR)都是 ~300 ns/WR、占比 ~15%,完全相同。所谓"每个 WR 一次 cgo 跨界拖慢了 Go",在这个负载上站不住——提交很便宜,而且两边一样便宜。

② Go 能追平 C。 锁核后 go_send_bw 实测峰值 0.748 Mpps,和 C 版 ib_send_bw 完全一致。早先看到的"go_send_bw 只有 ~314 Gb/s"是机器状态差时的数,不是 cgo 的锅。

③ 差距是"可变"的,不是固定缺陷。 交替跑 3 轮,go_send_bw0.414 / 0.748 / 0.414 Mpps 之间离散双峰跳变,而 RawConn 稳定在 0.42。也就是说:go_send_bw 状态差的那几轮,和 RawConn 几乎持平;两者差距在 **1.05×1.76× 之间晃**,取决于那一轮 go_send_bw 能不能抢到干净的网卡/CPU 窗口。

差距的真正位置在 poll(忙等完成到达):go_send_bw 的 poll 在 0.75~1.33 µs/WR 间大幅波动(状态好就打满线速),RawConn 则被稳定压在 ~1.40 µs。考虑到这是一台共享 GPU 机、400G 链路被其他租户竞争,最合理的解释是环境竞争,而非 RawConn 有独立的代码缺陷——两个工具走的是同一套 QP 建立和 CQ 轮询路径,逐行核对没有能让 RawConn 单独变慢的差异。

🧭 给读者的实用结论:① 不要迷信"Go+cgo 必慢于 C",在大包带宽场景两者能打平;② cgo 的固定开销真实但小,真正要省它得靠批量提交 + 忙轮询(见下文小包测试);③ 想认真比性能,务必锁核、独占机器、多次取中位数,共享机上的单次数字会骗你。

小包更能看出批量提交的威力

64KB 大包很容易撞带宽上限,看不出 CPU 侧的优化。换成 1KB 小包(消息率受限场景):

1
./bin/go_rdmanet_bw --raw -s 1024 -n 5000000 -d mlx5_1 -x 3 -b 128 33.0.226.25:18515
1
raw-batch Send(txDepth=128): 5000000 x 1024 bytes in 0.85s: 47.92 Gb/s, 5.850 Mpps

5.85 Mpps——批量提交(PostSendBatch)在小包上把消息率拉高了一个数量级。这正是榨干高频小消息场景的关键。


尾声:三个档位,按需取用

gordma 最打动我的,是它没有逼你在"易用"和"性能"之间二选一,而是给了一条平滑的升级路径:

你的需求 用哪个 心智负担
写业务,要 net 风格 rdmanet.Conn 像写 socket,几行搞定
既要简单又要极限吞吐 rdmanet.RawConn 自己管内存,几十行
完全掌控每个细节 底层 gordma 复刻 perftest 的程度

而且全部代码在任何平台都能编译(macOS/Windows 走 stub 桩实现,RDMA 调用优雅返回 ErrNotSupported),只有真正运行时才需要 Linux + RDMA 硬件。这意味着你可以在 MacBook 上写代码、跑单元测试,真要压测时再丢到带卡的机器上,开发体验和门槛都友好得多。

如果你正在被 RDMA 编程劝退,或者想给你的 Go 服务接上高性能网络,不妨试试 gordma:

🔗 github.com/smallnest/gordma

go run ./examples/echo-msg 跑通第一个 RDMA 程序开始,你会发现——原来 RDMA 也可以这么"傻瓜"。


本文所有性能数据均为同一对 400G RoCE v2 节点上的实测结果,会随硬件与配置不同而变化。完整教程、API 文档、17 个示例和 8 个压测工具均在仓库中。