傻瓜式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 个压测工具均在仓库中。

百度物理网络监控工具开源第二弹:毫秒级监控工具 baize,让你的网络问题无处遁形

5000 包/秒高频探测 + 无需时钟同步的单向丢包检测 + 全路径覆盖。内部跑了多年,现在开源了。


先讲一个实际case。

线上服务突然超时,用户投诉电话打爆了。打开监控大盘,一切正常——没有任何告警。折腾两小时,最后发现是某条链路间歇性轻微丢包,丢包率 0.3‰,传统监控压根抓不到。

百度内部,baize 跑了多年:

  • 集群间高频探测:机房内跨集群链路fullmesh监控
  • 机房间fullmesh探测:机房间,LCC机房链路fullmesh监控()
  • 混合云高频探测:A区和C区之间的混合云链路监控,5000 pps,秒级发现异常
  • 专线 SLA 监控:运营商专线质量持续监测,为 SLA 考核提供数据支撑
  • 网络改造保障:设备割接、链路升级期间持续监控,改造前后对比一目了然
  • 故障回切验证:从灾备切回主链路后,确认回切路径无丢包、无 bitflip后再切流

06 开源与社区

baize 是百度 nettools 工具集的第二个开源工具,MIT 协议。

内部版还支持从数据库拉配置、推数据到 Kafka 聚合,开源版做了简化,但留了可插拔的 Sender 接口——你可以自己实现,把数据发到 ClickHouse、Prometheus 或者任意后端。


网络监控这件事,不是能不能做的问题,是做得够不够细的问题。

每一条链路、每一个端口、每一个比特,都值得被监控。 这是我们在百度内部坚持的标准,今天开源出来,希望对你有用。

被间歇性轻微丢包折磨过的话,去 GitHub 点个 Star,试试 baize。

大厂的内部工具居然开源了! 一窥百度物理网络秒级监控定位的秘密

目前顶尖的云服务商都包含百万台服务器、数十甚至上百个机房、上万台网络设备、百万级网络链路。单单一个GPU集群,就有上万卡的级别。对这些网络和服务器的监控,一个分钟级别的故障,可能就是上百万资产的损失。

这不是一个ping能解决的问题。

今天,我们将百度物理网络黑盒监控方向的工具集 nettools 开源了(https://github.com/baidu/nettools),第一批放出的是 bitflip 和 bitflip6,用于检测网络丢包和比特翻转,在百度内部跑了很长时间了。

阅读全文

百度网络监控工具开源第三弹:lidar — 不只是 pingmesh

上一个工具 bitflip/baize 解决的是丢包和改包持续检测,在百度baize常常用在点到点之间的常态检测中,比如机房内集群间的监控,专线的检测, 新网络方案测试和灰度观察、核心网络设备的切回前检测等场景。

今天介绍的lidar工具,区别于传统的pingmesh探测方案,是我来百度后创造的第一个特殊的底层网络方案,我将其称之为lidar(激光扫描)方案,是一个很形象的比喻,我会话专门一节详细介绍它的优缺点,在这之前,我们介绍传统的赫赫有名的机房大规模的网络监控方案 pingmesh。

https://nettools.rpcx.io

pingmesh 探测以及为什么我们不用它?

PingMesh("Pingmesh: A Large-Scale System for Data Center Network Latency Measurement and Analysis")是微软在SIGCOMM 2015上发表的论文。作者团队来自微软研究院和Azure网络部门。

阅读全文

别只盯着gopacket了,看看这个强大的网络库

Go 网络编程,大家第一反应就是 gopacket。但如果你用过 Scapy,你会发现 gopacket 的 API 繁琐得让人抓狂。goscapy 把 Scapy 的优雅搬到了 Go 里——流式构建、自动校验和、协议自动推断、一行代码搞定数据包,还能嗅探、发送、收响应。


最早接触到python生态圈的scapy是两年前,在和交换机的同学搞交换机探针的时候,他写了几行python代码就实现了一个发包探测程序,我立马就被scapy吸引了,居然写网络测试程序可以这么简单?

03 数据包解析:自动协议推断

构造包只是故事的一半,解析包是另一半。

goscapy 的 Dissect 能从原始字节自动推断出完整的协议栈:

1
2
3
4
5
6
7
raw := []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xaa, 0xbb, ...}
pkt, _ := packet.DissectByProto(raw, "Ethernet")
fmt.Println(pkt.String()) // "Ethernet / IP / ICMP"

// 访问具体层
ipLayer := pkt.GetLayer("IP")
srcIP, _ := ipLayer.Get("src") // net.IP{192, 168, 1, 1}

解析引擎靠注册表驱动。每一层解析完后,查 keyField 找到下一层——Ethernet 看看 type 字段(0x0800 → IP),IP 看看 proto 字段(6 → TCP),TCP 看看 dport(80 → HTTP)。VXLAN 这种隧道协议还能递归解析内层包,最多支持 8 层嵌套。

启发式规则也注册了一大堆:UDP 53 端口 → DNS,TCP 80 → HTTP,UDP 4789 → VXLAN,IP proto 47 → GRE……抓到的包基本都能自动识别到应用层。

04 嗅探和收发包:Scapy 的 sr/srp 到 Go

写过 Scapy 的人一定对 sr()sr1() 不陌生——发一个包,自动等响应,还能做协议级匹配。goscapy 把这套逻辑完整搬过来了。

发包

1
2
3
4
5
6
7
8
9
// 构造结构化的 Packet 对象(不是 Build 出 []byte)
pkt := goscapy.NewEthernet().
DstMAC("ff:ff:ff:ff:ff:ff").
Over(goscapy.NewIP().SrcIP("192.168.1.1").DstIP("8.8.8.8")).
Over(goscapy.NewICMP().Type(8).Code(0)).
Packet()

sendrecv.Send(pkt, "eth0") // L3 发送(IP 层,OS 补 Ethernet)
sendrecv.Sendp(pkt, "eth0") // L2 发送(完整以太网帧)

发包收响应

1
2
3
4
5
6
// 类似 Scapy 的 sr1():发 ICMP Echo,等第一个响应
sent, reply, err := sendrecv.Sr1(pkt, "eth0", 3*time.Second, nil)
if reply != nil {
ipLayer := reply.GetLayer("IP")
srcIP, _ := ipLayer.Get("src") // 8.8.8.8 的回复
}

DefaultMatch 自动匹配响应包——ICMP Echo Request 配 Echo Reply(ID 匹配),TCP SYN 配 SYN-ACK(端口翻转 + ack = seq+1),UDP 配端口翻转,DNS 匹 transaction ID,ARP 配 IP 交换。不用写一行匹配逻辑。

gopacket 更适合:需要解析冷门协议、对 pcap 文件读写有强需求、已经重度依赖 libpcap 生态的项目。

goscapy 更适合:网络工具开发、安全扫描、协议测试、网络监控探测——任何需要快速构造和收发数据包的场景。纯 Go 部署简单,API 用起来舒服。

10 性能:零拷贝序列化

goscapy 在序列化上做了不少优化:

  • SerializeInto:直接写入目标 buffer,无额外堆分配
  • BuildInto:用户提供 buffer,整个包一次序列化完成
  • RecvInto:收包直接读入用户 buffer,减少一次拷贝
  • 校验和零拷贝checksumIPv4Pseudo 直接折叠多个内存区域,不拼接
  • WireSize:预计算序列化大小,一次分配精确大小的 buffer
1
2
3
// 零拷贝发送
buf := make([]byte, 1500) // MTU 大小的 buffer
result, err := pkt.BuildInto(buf) // 直接写入,无额外分配

还有 Linux 特有的高性能接收模式——AF_PACKET mmap、零拷贝 (PACKET_QDISC_BYPASS)、io_uring 原始套接字——适合高频探测场景。这些在 examples 目录里有完整示例(23-packet-mmap、21-zerocopy、22-uring-raw-socket)。

11 丰富的示例库

goscapy 自带了 50+ 个示例,覆盖从基础到高级的几乎所有场景:

  • 基础:ping、traceroute、TCP SYN 扫描、ARP 扫描
  • 协议:DNS 客户端、DHCP 客户端、NTP 客户端
  • 隧道:VXLAN 封装、GRE 隧道、ERSPAN
  • 高级:PCAP 读写、TCP 流重组、BPF 过滤
  • 性能:零拷贝收包、io_uring、packet mmap、批量发送
  • 无线:802.11 WiFi 帧、蓝牙 HCI/L2CAP、Zigbee、LoRaWAN
  • 安全:p0f 指纹、端口扫描、ARP 扫描

每个示例都是可编译运行的小程序,直接 go run 就能跑。


项目地址:github.com/smallnest/goscapy

纯 Go,MIT 协议,零 C 依赖。go get github.com/smallnest/goscapy 就能用。

如果你在做网络工具、安全扫描、协议测试、监控探测——试试 goscapy,可能会让你重新爱上 Go 网络编程。